From 7c9558fd366c3b2b352654c166ed59ac39a914fd Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 2 Mar 2025 23:05:17 +0000 Subject: [PATCH 001/206] Initial translation of libloot into Rust This should be equivalent to libloot commit 55b341fc6cbdccee52e42923c13a91eddb5ca97d, except for: - Error types are incomplete - Setting a logging callback doesn't work properly - Serialising metadata to YAML isn't implemented - Tests are almost entirely missing - C/C++ FFI doesn't exist --- .gitignore | 1 + Cargo.lock | 1045 ++++++++++++++++++++++++ Cargo.toml | 21 + LICENSE | 674 +++++++++++++++ README.md | 49 ++ src/archive/ba2.rs | 143 ++++ src/archive/bsa.rs | 208 +++++ src/archive/find.rs | 185 +++++ src/archive/mod.rs | 7 + src/archive/parse.rs | 106 +++ src/database.rs | 455 +++++++++++ src/error.rs | 353 ++++++++ src/game.rs | 745 +++++++++++++++++ src/lib.rs | 24 + src/logging.rs | 74 ++ src/metadata/file.rs | 163 ++++ src/metadata/group.rs | 91 +++ src/metadata/location.rs | 77 ++ src/metadata/message.rs | 322 ++++++++ src/metadata/metadata_document.rs | 469 +++++++++++ src/metadata/mod.rs | 17 + src/metadata/plugin_cleaning_data.rs | 150 ++++ src/metadata/plugin_metadata.rs | 361 ++++++++ src/metadata/tag.rs | 106 +++ src/metadata/yaml.rs | 167 ++++ src/plugin.rs | 453 ++++++++++ src/sorting/dfs.rs | 210 +++++ src/sorting/groups.rs | 271 ++++++ src/sorting/mod.rs | 5 + src/sorting/plugins.rs | 1135 ++++++++++++++++++++++++++ src/sorting/validate.rs | 198 +++++ src/sorting/vertex.rs | 75 ++ src/version.rs | 47 ++ 33 files changed, 8407 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/archive/ba2.rs create mode 100644 src/archive/bsa.rs create mode 100644 src/archive/find.rs create mode 100644 src/archive/mod.rs create mode 100644 src/archive/parse.rs create mode 100644 src/database.rs create mode 100644 src/error.rs create mode 100644 src/game.rs create mode 100644 src/lib.rs create mode 100644 src/logging.rs create mode 100644 src/metadata/file.rs create mode 100644 src/metadata/group.rs create mode 100644 src/metadata/location.rs create mode 100644 src/metadata/message.rs create mode 100644 src/metadata/metadata_document.rs create mode 100644 src/metadata/mod.rs create mode 100644 src/metadata/plugin_cleaning_data.rs create mode 100644 src/metadata/plugin_metadata.rs create mode 100644 src/metadata/tag.rs create mode 100644 src/metadata/yaml.rs create mode 100644 src/plugin.rs create mode 100644 src/sorting/dfs.rs create mode 100644 src/sorting/groups.rs create mode 100644 src/sorting/mod.rs create mode 100644 src/sorting/plugins.rs create mode 100644 src/sorting/validate.rs create mode 100644 src/sorting/vertex.rs create mode 100644 src/version.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..a2812b6d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1045 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.15", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dataview" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50eb3a329e19d78c3a3dfa4ec5a51ecb84fa3a20c06edad04be25356018218f9" +dependencies = [ + "derive_pod", +] + +[[package]] +name = "derive_pod" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ea6706d74fca54e15f1d40b5cf7fe7f764aaec61352a9fcec58fe27e042fc8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "either" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7914353092ddf589ad78f25c5c1c21b7f80b0ff8621e7c814c3485b5306da9d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "esplugin" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5be744c54f80f535dc03a47c6a12c5116525edfa8a1365f13e91c42a0ad4d55" +dependencies = [ + "encoding_rs", + "memchr", + "nom 7.1.3", + "unicase", +] + +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "indexmap" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", +] + +[[package]] +name = "keyvalues-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e4c8354918309196302015ac9cae43362f1a13d0d5c5539a33b4c2fd2cd6d25" +dependencies = [ + "pest", + "pest_derive", + "thiserror 1.0.69", +] + +[[package]] +name = "libc" +version = "0.2.170" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" + +[[package]] +name = "libloadorder" +version = "18.2.2" +source = "git+https://github.com/Ortham/libloadorder.git?rev=6245dbc6824c4751daef5ab01dfe7b9497f5446f#6245dbc6824c4751daef5ab01dfe7b9497f5446f" +dependencies = [ + "dirs", + "encoding_rs", + "esplugin", + "keyvalues-parser", + "rayon", + "regex", + "rust-ini", + "unicase", + "windows", +] + +[[package]] +name = "libloot" +version = "0.25.3" +dependencies = [ + "crc32fast", + "esplugin", + "fancy-regex", + "libloadorder", + "log", + "loot-condition-interpreter", + "petgraph", + "rayon", + "saphyr", + "saphyr-parser", + "tempfile", + "unicase", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "log" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" + +[[package]] +name = "loot-condition-interpreter" +version = "5.2.0" +source = "git+https://github.com/loot/loot-condition-interpreter.git?rev=f7d9947fa434037743ce81e2d409184ec0bfb693#f7d9947fa434037743ce81e2d409184ec0bfb693" +dependencies = [ + "crc32fast", + "esplugin", + "nom 8.0.0", + "pelite", + "regex", + "unicase", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[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" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "pelite" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88dccf4bd32294364aeb7bd55d749604450e9db54605887551f21baea7617685" +dependencies = [ + "dataview", + "libc", + "no-std-compat", + "pelite-macros", + "winapi", +] + +[[package]] +name = "pelite-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7cf3f8ecebb0f4895f4892a8be0a0dc81b498f9d56735cb769dc31bf00815b" + +[[package]] +name = "pest" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" +dependencies = [ + "memchr", + "thiserror 2.0.11", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.11", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rust-ini" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f" +dependencies = [ + "cfg-if", + "ordered-multimap", + "trim-in-place", + "unicase", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "saphyr" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31513a748cdf8fb8d1b64dcc14fdd997fa02f2431671c7ce34efbea5e7eeea" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", + "saphyr-parser", +] + +[[package]] +name = "saphyr-parser" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "123f0a6da68f3072c7c761450276d1d444cb391c8be182a757cd26cf684cb77f" +dependencies = [ + "arraydeque", + "hashlink", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "2.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230" +dependencies = [ + "cfg-if", + "fastrand", + "getrandom 0.3.1", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +dependencies = [ + "thiserror-impl 2.0.11", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "trim-in-place" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf874e74c7a99773e62b1c671427abf01a425e77c3d3fb9fb1e4883ea934529" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5467f79cc1ba3f52ebb2ed41dbb459b8e7db636cc3429458d9a852e15bc24dec" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.60.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-implement" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26fd936d991781ea39e87c3a27285081e3c0da5ca0fcbc02d368cc6f52ff01" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + +[[package]] +name = "windows-numerics" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06374efe858fab7e4f881500e6e86ec8bc28f9462c47e5a9941a0142ad86b189" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..4b43d1bf --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "libloot" +version = "0.25.3" +edition = "2024" +license = "GPL-3.0" + +[dependencies] +crc32fast = "1.4.2" +esplugin = "6.1.1" +libloadorder = { git = "https://github.com/Ortham/libloadorder.git", rev = "6245dbc6824c4751daef5ab01dfe7b9497f5446f" } +log = { version = "0.4.26", features = ["std"] } +loot-condition-interpreter = { git = "https://github.com/loot/loot-condition-interpreter.git", rev = "f7d9947fa434037743ce81e2d409184ec0bfb693" } +petgraph = "0.7.1" +fancy-regex = "0.14.0" +saphyr = "0.0.3" +saphyr-parser = "0.0.3" +unicase = "2.8.1" +rayon = "1.10.0" + +[dev-dependencies] +tempfile = "3.17.1" diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..94a9ed02 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 00000000..1e0c4f54 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# libloot-rs + +This is an **incomplete** and **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++. + +## Current status + +Currently complete: + +- [x] Public API types and function declarations (excluding errors) +- [ ] Public API error types +- [x] Public API doc comments +- [x] Library versioning +- [ ] Setting a logging callback +- [x] Parsing metadata from YAML +- [ ] Serialising metadata to YAML +- [x] Game-related functionality +- [x] Plugin-related functionality +- [x] Archive-related functionality +- [x] Metadata-related functionality (excluding writing YAML) +- [x] Sorting functionality +- [ ] Unit tests +- [ ] Integration tests +- [ ] C++ FFI +- [ ] Python FFI + +The complete bits should match libloot commit [55b341fc6cbdccee52e42923c13a91eddb5ca97d](https://github.com/loot/libloot/commit/55b341fc6cbdccee52e42923c13a91eddb5ca97d), which is libloot v0.25.3 plus a few changes prompted by this translation. + +## Build + +Make sure you have [Rust](https://www.rust-lang.org/) installed. + +To build the library, set the `LIBLOOT_REVISION` env var and then run Cargo. Using PowerShell: + +```powershell +$env:LIBLOOT_REVISION = git rev-parse --short HEAD +cargo build --release +``` + +There aren't many tests, but those that exist can be run using: + +``` +cargo test +``` + +The public API has doc comments copied from libloot, and the API documentation can be built and viewed using: + +``` +cargo doc --open +``` diff --git a/src/archive/ba2.rs b/src/archive/ba2.rs new file mode 100644 index 00000000..e7d575f0 --- /dev/null +++ b/src/archive/ba2.rs @@ -0,0 +1,143 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + hash::{DefaultHasher, Hash, Hasher}, + io::{BufRead, Seek}, +}; + +use crate::error::{GeneralError, InvalidArgumentError}; + +use super::parse::{to_u32, to_u64}; + +pub(super) const TYPE_ID: [u8; 4] = *b"BTDX"; +const HEADER_SIZE: usize = 24; +const BA2_GENERAL_TYPE: [u8; 4] = *b"GNRL"; +const BA2_TEXTURE_TYPE: [u8; 4] = *b"DX10"; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +struct Header { + type_id: [u8; 4], + version: u32, + archive_type: [u8; 4], + file_count: u32, + file_paths_offset: u64, +} + +impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { + type Error = InvalidArgumentError; + + fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result { + let header = Self { + type_id: TYPE_ID, + version: to_u32(&value), + archive_type: <[u8; 4]>::try_from(&value[4..8]) + .expect("Bytes slice is large enough to hold a 4-byte array"), + file_count: to_u32(&value[12..]), + file_paths_offset: to_u64(&value[16..]), + }; + + // The header version is 1, 7 or 8 for Fallout 4 and 2 or 3 for Starfield. + if !matches!(header.version, 1 | 2 | 3 | 7 | 8) { + return Err(InvalidArgumentError { + message: "BA2 file header version is invalid".into(), + }); + } + + if !matches!(header.archive_type, BA2_GENERAL_TYPE | BA2_TEXTURE_TYPE) { + return Err(InvalidArgumentError { + message: "BA2 file header archive type is invalid".into(), + }); + } + + Ok(header) + } +} + +pub(super) fn read_assets( + mut reader: T, +) -> Result>, GeneralError> { + let mut header_buffer = [0; HEADER_SIZE - TYPE_ID.len()]; + + reader.read_exact(&mut header_buffer)?; + + let header = Header::try_from(header_buffer)?; + + let mut assets = BTreeMap::new(); + + reader.seek(std::io::SeekFrom::Start(header.file_paths_offset))?; + + for _ in 0..header.file_count { + let mut length_buf = [0; 2]; + reader.read_exact(&mut length_buf)?; + + let path_length = u16::from_le_bytes(length_buf); + let mut file_path_bytes = vec![0; path_length.into()]; + reader.read_exact(file_path_bytes.as_mut_slice())?; + + normalise_path(&mut file_path_bytes); + + let file_path_bytes = trim_slashes(&file_path_bytes); + + let (folder_hash, file_hash) = rsplit_on(file_path_bytes, b'\\') + .map(|(folder_path, file_path)| (hash(&folder_path), hash(&file_path))) + .unwrap_or_else(|| (0, hash(&file_path_bytes))); + + let file_hashes: &mut BTreeSet = assets.entry(folder_hash).or_default(); + + if !file_hashes.insert(file_hash) { + return Err(InvalidArgumentError { + message: format!( + "Unexpected collision for file name hash {:x} in set for folder name hash {:x}", + file_hash, folder_hash + ), + } + .into()); + } + } + + Ok(assets) +} + +fn normalise_path(path_bytes: &mut [u8]) { + for byte in path_bytes { + // Ignore any non-ASCII characters. + if *byte > 127 { + continue; + } + + *byte = match byte { + b'/' => b'\\', + _ => byte.to_ascii_lowercase(), + } + } +} + +fn trim_slashes(mut path_bytes: &[u8]) -> &[u8] { + while let [first, rest @ ..] = path_bytes { + if *first == b'\\' { + path_bytes = rest; + } else { + break; + } + } + + while let [rest @ .., last] = path_bytes { + if *last == b'\\' { + path_bytes = rest; + } else { + break; + } + } + + path_bytes +} + +fn rsplit_on(slice: &[u8], needle: u8) -> Option<(&[u8], &[u8])> { + let index = slice.iter().rposition(|b| *b == needle)?; + Some((&slice[..index], &slice[index + 1..])) +} + +fn hash(value: &T) -> u64 { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() +} diff --git a/src/archive/bsa.rs b/src/archive/bsa.rs new file mode 100644 index 00000000..70910735 --- /dev/null +++ b/src/archive/bsa.rs @@ -0,0 +1,208 @@ +use std::{ + collections::{BTreeMap, BTreeSet, btree_map::Entry}, + io::BufRead, +}; + +use crate::error::{GeneralError, InvalidArgumentError}; + +use super::parse::{to_u32, to_u64, to_usize}; + +pub(super) const TYPE_ID: [u8; 4] = *b"BSA\0"; +const HEADER_SIZE: usize = 36; +const FILE_RECORD_SIZE: usize = 16; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +struct Header { + type_id: [u8; 4], + version: u32, + records_offset: u32, + archive_flags: u32, + folder_count: u32, + total_file_count: u32, + total_folder_names_length: u32, + total_file_names_length: u32, + content_type_flags: u32, +} + +impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { + type Error = InvalidArgumentError; + + fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result { + let header = Self { + type_id: TYPE_ID, + version: to_u32(&value), + records_offset: to_u32(&value[4..]), + archive_flags: to_u32(&value[8..]), + folder_count: to_u32(&value[12..]), + total_file_count: to_u32(&value[16..]), + total_folder_names_length: to_u32(&value[20..]), + total_file_names_length: to_u32(&value[24..]), + content_type_flags: to_u32(&value[28..]), + }; + + if header.records_offset != 36 { + return Err(InvalidArgumentError { + message: format!( + "BSA file has an invalid records offset value: {}", + header.records_offset + ), + }); + } + + if (header.archive_flags & 0x40) != 0 { + return Err(InvalidArgumentError { + message: "BSA file uses big-endian numbers".into(), + }); + } + + Ok(header) + } +} + +struct FolderRecord { + name_hash: u64, + file_count: u32, + file_records_offset: u32, +} + +// Also used for v104 BSAs. +mod v103 { + use crate::archive::parse::{to_u32, to_u64}; + + use super::FolderRecord; + + pub(super) const FOLDER_RECORD_SIZE: usize = 16; + + pub(super) fn read_folder_record(value: &[u8]) -> FolderRecord { + assert!(value.len() >= FOLDER_RECORD_SIZE); + + FolderRecord { + name_hash: to_u64(value), + file_count: to_u32(&value[8..]), + file_records_offset: to_u32(&value[12..]), + } + } +} + +mod v105 { + use crate::archive::parse::{to_u32, to_u64}; + + use super::FolderRecord; + + pub(super) const FOLDER_RECORD_SIZE: usize = 24; + + pub(super) fn read_folder_record(value: &[u8]) -> FolderRecord { + assert!(value.len() >= FOLDER_RECORD_SIZE); + + FolderRecord { + name_hash: to_u64(value), + file_count: to_u32(&value[8..]), + file_records_offset: to_u32(&value[16..]), + } + } +} + +pub(super) fn read_assets( + mut reader: T, +) -> Result>, GeneralError> { + let mut header_buffer = [0; HEADER_SIZE - TYPE_ID.len()]; + + reader.read_exact(&mut header_buffer)?; + + let header = Header::try_from(header_buffer)?; + + match header.version { + 103 | 104 => read_assets_with_header::( + reader, + &header, + v103::read_folder_record, + ), + 105 => read_assets_with_header::( + reader, + &header, + v105::read_folder_record, + ), + _ => Err(InvalidArgumentError { + message: format!("BSA file has an unrecognised version: {}", header.version), + } + .into()), + } +} + +fn read_assets_with_header( + mut reader: T, + header: &Header, + read_folder_record: impl Fn(&[u8]) -> FolderRecord, +) -> Result>, GeneralError> { + let mut folders_buffer: Vec = vec![0; U * to_usize(header.folder_count)]; + + reader.read_exact(folders_buffer.as_mut_slice())?; + + let file_records_size = to_usize(header.folder_count) + + to_usize(header.total_folder_names_length) + + to_usize(header.total_file_count) * FILE_RECORD_SIZE; + + let mut file_records_buffer: Vec = vec![0; file_records_size]; + + reader.read_exact(file_records_buffer.as_mut_slice())?; + + let folder_record_offset_baseline = + HEADER_SIZE + folders_buffer.len() + to_usize(header.total_file_names_length); + + let mut assets = BTreeMap::new(); + for chunk in folders_buffer.chunks_exact(U) { + let folder_record = read_folder_record(chunk); + + let entry = assets.entry(folder_record.name_hash); + if let Entry::Occupied(_) = entry { + return Err(InvalidArgumentError { + message: format!( + "Unexpected collision for folder name hash {:x}", + folder_record.name_hash + ), + } + .into()); + } + + let file_records_offset = if (header.archive_flags & 0x1) == 0 { + to_usize(folder_record.file_records_offset) - folder_record_offset_baseline + } else { + let folder_name_length_offset = + to_usize(folder_record.file_records_offset) - folder_record_offset_baseline; + + if let Some(folder_name_length) = file_records_buffer.get(folder_name_length_offset) { + folder_name_length_offset + 1 + to_usize(u32::from(*folder_name_length)) + } else { + return Err(InvalidArgumentError { + message: "BSA file contains an invalid folder name length offset".into(), + } + .into()); + } + }; + + let file_records_buffer = match file_records_buffer.get(file_records_offset..) { + Some(s) => s, + None => { + return Err(InvalidArgumentError { + message: "BSA file contains an invalid file records offset".into(), + } + .into()); + } + }; + + let file_hashes: &mut BTreeSet = entry.or_default(); + + for file_chunk in file_records_buffer + .chunks_exact(FILE_RECORD_SIZE) + .take(to_usize(folder_record.file_count)) + { + let file_hash = to_u64(file_chunk); + + if !file_hashes.insert(file_hash) { + return Err(InvalidArgumentError { message: format!("Unexpected collision for file name hash {:x} in set for folder name hash {:x}", file_hash, folder_record.name_hash)}.into()); + } + } + } + + Ok(assets) +} diff --git a/src/archive/find.rs b/src/archive/find.rs new file mode 100644 index 00000000..5f855feb --- /dev/null +++ b/src/archive/find.rs @@ -0,0 +1,185 @@ +use std::{ + fs::OpenOptions, + path::{Path, PathBuf}, +}; + +use crate::{GameType, game::GameCache, plugin::has_ascii_extension}; + +const BSA_FILE_EXTENSION: &str = "bsa"; + +pub fn find_associated_archives( + game_type: GameType, + game_cache: &GameCache, + plugin_path: &Path, +) -> Vec { + match game_type { + GameType::TES3 | GameType::OpenMW => Vec::new(), + + // Skyrim (non-SE) plugins can only load BSAs that have exactly the same + // basename, ignoring file extensions. + GameType::TES5 => find_associated_archive(plugin_path), + + // Skyrim SE can load BSAs that have exactly the same basename, ignoring + // file extensions, and also BSAs with filenames of the form " + // - Textures.bsa" (case-insensitively). This assumes that Skyrim VR + // works the same way as Skyrim SE. + GameType::TES5SE | GameType::TES5VR => find_associated_archives_with_suffixes(plugin_path, BSA_FILE_EXTENSION, &["", " - Textures"]), + + // Oblivion .esp files can load archives which begin with the plugin + // basename. + GameType::TES4 => { + if has_ascii_extension(plugin_path, "esp") { + Vec::new() + } else { + find_associated_archives_with_arbitrary_suffixes(plugin_path, game_cache) + } + }, + + // FO3, FNV, FO4 plugins can load archives which begin with the plugin + // basename. This assumes that FO4 VR works the same way as FO4. + GameType::FO3 | GameType::FONV | GameType::FO4 | GameType::FO4VR => + find_associated_archives_with_arbitrary_suffixes(plugin_path, game_cache) + , + + // The game will load a BA2 that's suffixed with " - Voices_" + // where is whatever language Starfield is configured to use + // (sLanguage in the ini), so this isn't exactly correct but will work + // so long as a plugin with voices has voices for English, which seems + // likely. + GameType::Starfield => find_associated_archives_with_suffixes(plugin_path, "ba2", &[" - Main", " - Textures", " - Localization", " - Voices_en"]), + } +} + +fn find_associated_archive(plugin_path: &Path) -> Vec { + let archive_path = plugin_path.with_extension(BSA_FILE_EXTENSION); + + if archive_path.exists() { + vec![archive_path] + } else { + Vec::new() + } +} + +fn find_associated_archives_with_suffixes( + plugin_path: &Path, + archive_extension: &str, + supported_suffixes: &[&str], +) -> Vec { + let file_stem = match plugin_path.file_stem() { + Some(s) => s, + None => return Vec::new(), + }; + + supported_suffixes + .iter() + .map(|suffix| { + let mut filename = file_stem.to_os_string(); + filename.push(suffix); + filename.push("."); + filename.push(archive_extension); + + plugin_path.with_file_name(filename) + }) + .filter(|p| p.exists()) + .collect() +} + +fn find_associated_archives_with_arbitrary_suffixes( + plugin_path: &Path, + game_cache: &GameCache, +) -> Vec { + let plugin_stem_len = match plugin_path.file_stem().and_then(|s| s.to_str()) { + Some(s) => s.len(), + None => return Vec::new(), + }; + + game_cache + .archives() + .filter(|path| { + // Need to check if it starts with the given plugin's basename, + // but case insensitively. This is hard to do accurately, so + // instead check if the plugin with the same length basename and + // and the given plugin's file extension is equivalent. + let archive_filename = match path.file_name().and_then(|s| s.to_str()) { + Some(f) => f, + None => return false, + }; + + // Can't just slice the archive filename to the same length as the plugin file stem directly because that might not slice on a character boundary, so truncate the byte slice and then check it's still valid UTF-8. + let filename = + match std::str::from_utf8(&archive_filename.as_bytes()[..plugin_stem_len]) { + Ok(f) => f, + Err(_) => return false, + }; + + let archive_plugin_path = plugin_path.with_file_name(filename); + + are_file_paths_equivalent(&archive_plugin_path, plugin_path) + }) + .cloned() + .collect() +} + +#[cfg(windows)] +fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { + // See + // Or windows::Win32::Foundation::ERROR_SHARING_VIOLATION in the "windows" crate. + const ERROR_SHARING_VIOLATION: i32 = 32; + use std::os::windows::fs::OpenOptionsExt; + + let lhs_file = match OpenOptions::new().read(true).share_mode(0).open(lhs) { + Ok(f) => f, + Err(_) => return false, + }; + + let result = match OpenOptions::new().read(true).share_mode(0).open(rhs) { + Ok(f) => { + dbg!(f); + false + } + Err(e) => { + if let Some(error_code) = e.raw_os_error() { + error_code == ERROR_SHARING_VIOLATION + } else { + false + } + } + }; + + drop(lhs_file); + + result +} + +#[cfg(not(windows))] +fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { + use std::fs::unix::fs::MetadataExt; + + let lhs_metadata = match lhs.metadata() { + Ok(m) => m, + _ => return false, + }; + + let rhs_metadata = match rhs.metadata() { + Ok(m) => m, + _ => return false, + }; + + lhs_metadata.dev() == rhs_metadata.dev() && lhs_metadata.ino() == rhs_metadata.ino() +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn are_file_paths_equivalent_should_be_true_if_given_the_same_path_twice() { + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("test"); + std::fs::write(&file_path, "").unwrap(); + + assert!(are_file_paths_equivalent(&file_path, &file_path)) + } +} diff --git a/src/archive/mod.rs b/src/archive/mod.rs new file mode 100644 index 00000000..cfe6cf56 --- /dev/null +++ b/src/archive/mod.rs @@ -0,0 +1,7 @@ +mod ba2; +mod bsa; +mod find; +mod parse; + +pub use find::find_associated_archives; +pub use parse::assets_in_archives; diff --git a/src/archive/parse.rs b/src/archive/parse.rs new file mode 100644 index 00000000..c0825824 --- /dev/null +++ b/src/archive/parse.rs @@ -0,0 +1,106 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::File, + io::{BufReader, Read}, + path::{Path, PathBuf}, +}; + +use crate::{ + error::{GeneralError, InvalidArgumentError}, + plugin::has_ascii_extension, +}; + +use super::{ba2, bsa}; + +pub fn assets_in_archives(archive_paths: &[PathBuf]) -> BTreeMap> { + let mut archive_assets: BTreeMap> = BTreeMap::new(); + + for archive_path in archive_paths { + log::trace!( + "Getting assets loaded from the Bethesda archive at \"{}\"", + archive_path.display() + ); + + let assets = match get_assets_in_archive(archive_path) { + Ok(a) => a, + Err(e) => { + log::error!( + "Encountered an error while trying to read the Bethesda archive at \"{}\": {}", + archive_path.display(), + e + ); + continue; + } + }; + + let warn_on_hash_collisions = should_warn_on_hash_collisions(archive_path); + + for (folder_hash, file_hashes) in assets { + let entry_file_hashes = archive_assets.entry(folder_hash).or_default(); + + for file_hash in file_hashes { + if !entry_file_hashes.insert(file_hash) && warn_on_hash_collisions { + log::warn!( + "The folder and file with hashes {:x} and {:x} in \"{}\" are present in another Bethesda archive.", + folder_hash, + file_hash, + archive_path.display() + ); + } + } + } + } + + archive_assets +} + +fn should_warn_on_hash_collisions(archive_path: &Path) -> bool { + if !has_ascii_extension(archive_path, "ba2") { + return true; + } + + let filename = archive_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_ascii_lowercase(); + + filename.starts_with("fallout4 - ") || filename.starts_with("dlcultrahighresolution - ") +} + +fn get_assets_in_archive( + archive_path: &Path, +) -> Result>, GeneralError> { + let mut reader = BufReader::new(File::open(archive_path)?); + + let mut type_id: [u8; 4] = [0; 4]; + reader.read_exact(&mut type_id)?; + + match type_id { + bsa::TYPE_ID => bsa::read_assets(reader), + ba2::TYPE_ID => ba2::read_assets(reader), + _ => Err(InvalidArgumentError { + message: format!( + "Bethesda archive at \"{}\" has an unrecognised type ID", + archive_path.display() + ), + } + .into()), + } +} + +pub(super) fn to_u32(bytes: &[u8]) -> u32 { + let array = + <[u8; 4]>::try_from(&bytes[..4]).expect("Bytes slice is large enough to hold a u32"); + u32::from_le_bytes(array) +} + +pub(super) fn to_u64(bytes: &[u8]) -> u64 { + let array = + <[u8; 8]>::try_from(&bytes[..4]).expect("Bytes slice is large enough to hold a u64"); + u64::from_le_bytes(array) +} + +pub(super) fn to_usize(size: u32) -> usize { + usize::try_from(size).expect("usize can hold a u32") +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 00000000..f3c50d21 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,455 @@ +use std::{path::Path, str::FromStr}; + +use loot_condition_interpreter::Expression; + +use crate::{ + error::{FileAccessError, GeneralError, InvalidArgumentError}, + metadata::{ + File, Group, Message, PluginCleaningData, PluginMetadata, + metadata_document::MetadataDocument, + }, + sorting::{ + groups::{build_groups_graph, find_path}, + vertex::Vertex, + }, +}; + +/// The interface through which metadata can be accessed. +#[derive(Debug)] +pub struct Database { + masterlist: MetadataDocument, + userlist: MetadataDocument, + condition_evaluator_state: loot_condition_interpreter::State, +} + +impl Database { + #[must_use] + pub(crate) fn new(condition_evaluator_state: loot_condition_interpreter::State) -> Self { + Self { + masterlist: MetadataDocument::default(), + userlist: MetadataDocument::default(), + condition_evaluator_state, + } + } + + pub(crate) fn condition_evaluator_state_mut( + &mut self, + ) -> &mut loot_condition_interpreter::State { + &mut self.condition_evaluator_state + } + + /// Loads the masterlist from the given path. + /// + /// Replaces any existing data that was previously loaded from a masterlist. + pub fn load_masterlist(&mut self, path: &Path) -> Result<(), GeneralError> { + if path.exists() { + self.masterlist.load(path) + } else { + Err(FileAccessError { + message: format!( + "The given masterlist path does not exist: {}", + path.display() + ), + } + .into()) + } + } + + /// Loads the masterlist from the given path, using the prelude at the given + /// path. + /// + /// Replaces any existing data that was previously loaded from a masterlist. + pub fn load_masterlist_with_prelude( + &mut self, + masterlist_path: &Path, + prelude_path: &Path, + ) -> Result<(), GeneralError> { + if !masterlist_path.exists() { + Err(FileAccessError { + message: format!( + "The given masterlist path does not exist: {}", + masterlist_path.display() + ), + } + .into()) + } else if !prelude_path.exists() { + Err(FileAccessError { + message: format!( + "The given prelude path does not exist: {}", + prelude_path.display() + ), + } + .into()) + } else { + self.masterlist + .load_with_prelude(masterlist_path, prelude_path) + } + } + + /// Loads the userlist from the given path. + /// + /// Replaces any existing data that was previously loaded from a userlist. + pub fn load_userlist(&mut self, path: &Path) -> Result<(), GeneralError> { + if path.exists() { + self.userlist.load(path) + } else { + Err(FileAccessError { + message: format!("The given userlist path does not exist: {}", path.display()), + } + .into()) + } + } + + /// Writes a metadata file containing all loaded user-added metadata. + /// + /// If `output_path` already exists, it will be written if `overwrite` is + /// `true`, otherwise no data will be written. + pub fn write_user_metadata( + &self, + output_path: &Path, + overwrite: bool, + ) -> Result<(), GeneralError> { + validate_write_path(output_path, overwrite)?; + + self.userlist.save(output_path) + } + + /// Writes a metadata file that only contains plugin Bash Tag suggestions + /// and dirty info. + /// + /// If `output_path` already exists, it will be written if `overwrite` is + /// `true`, otherwise no data will be written. + pub fn write_minimal_list( + &self, + output_path: &Path, + overwrite: bool, + ) -> Result<(), GeneralError> { + validate_write_path(output_path, overwrite)?; + + let mut doc = MetadataDocument::default(); + + for plugin in self.masterlist.plugins() { + let mut minimal_plugin = PluginMetadata::new(plugin.name())?; + minimal_plugin.set_tags(plugin.tags().to_vec()); + minimal_plugin.set_dirty_info(plugin.dirty_info().to_vec()); + + doc.set_plugin_metadata(minimal_plugin); + } + + doc.save(output_path) + } + + /// Gets the Bash Tags that are listed in the loaded metadata lists. + /// + /// Bash Tag suggestions can include Bash Tags not in this list. + pub fn known_bash_tags(&self) -> Vec { + let mut tags = self.masterlist.bash_tags().to_vec(); + tags.extend_from_slice(self.userlist.bash_tags()); + + tags + } + + /// Get all general messages listed in the loaded metadata lists. + /// + /// If `evaluate_conditions` is `true`, any metadata conditions are + /// evaluated before the metadata is returned, otherwise unevaluated + /// metadata is returned. Evaluating general message conditions also clears + /// the condition cache before evaluating conditions. + pub fn general_messages( + &mut self, + evaluate_conditions: bool, + ) -> Result, GeneralError> { + let messages_iter = self + .masterlist + .messages() + .iter() + .chain(self.userlist.messages()); + + if evaluate_conditions { + self.condition_evaluator_state.clear_condition_cache()?; + + let messages = messages_iter + .filter_map(|m| { + filter_map_on_condition(m, m.condition(), &self.condition_evaluator_state) + }) + .collect::, _>>()?; + + Ok(messages) + } else { + Ok(messages_iter.cloned().collect()) + } + } + + /// Gets the groups that are defined in the loaded metadata lists. + /// + /// If `include_user_metadata` is `true`, any group metadata present in the + /// userlist is included in the returned metadata, otherwise the metadata + /// returned only includes metadata from the masterlist. + pub fn groups(&self, include_user_metadata: bool) -> Vec { + if include_user_metadata { + merge_groups(self.masterlist.groups(), self.userlist.groups()) + } else { + self.masterlist.groups().to_vec() + } + } + + /// Gets the groups that are defined or extended in the loaded userlist. + pub fn user_groups(&self) -> &[Group] { + self.userlist.groups() + } + + /// Sets the group definitions to store in the userlist, replacing any + /// definitions already loaded from the userlist. + pub fn set_user_groups(&mut self, groups: Vec) { + self.userlist.set_groups(groups); + } + + /// Get the "shortest" path between the two given groups according to their + /// "load after" metadata. + /// + /// The "shortest" path is defined as the path that maximises the amount of + /// user metadata involved while minimising the amount of masterlist + /// metadata involved. It's not the path involving the fewest groups. + /// + /// If there is no path between the two groups, the returned [Vec] will be + /// empty. + pub fn groups_path( + &self, + from_group_name: &str, + to_group_name: &str, + ) -> Result, GeneralError> { + let graph = build_groups_graph(self.masterlist.groups(), self.userlist.groups())?; + + find_path(&graph, from_group_name, to_group_name) + } + + /// Get all of a plugin's loaded metadata. + /// + /// If `include_user_metadata` is `true`, any user metadata the plugin has + /// is included in the returned metadata, otherwise the metadata returned + /// only includes metadata from the masterlist. + /// + /// If `evaluateConditions` is `true`, any metadata conditions are evaluated + /// before the metadata otherwise unevaluated metadata is returned. + /// Evaluating plugin metadata conditions does **not** clear the condition + /// cache. + pub fn plugin_metadata( + &self, + plugin_name: &str, + include_user_metadata: bool, + evaluate_conditions: bool, + ) -> Result, GeneralError> { + let mut metadata = self.masterlist.find_plugin(plugin_name)?; + + if include_user_metadata { + if let Some(mut user_metadata) = self.userlist.find_plugin(plugin_name)? { + if let Some(metadata) = metadata { + user_metadata.merge_metadata(&metadata); + } + metadata = Some(user_metadata); + } + } + + if evaluate_conditions { + if let Some(metadata) = metadata { + return evaluate_all_conditions(metadata, &self.condition_evaluator_state) + .map_err(Into::into); + } + } + + Ok(metadata) + } + + /// Get a plugin's metadata loaded from the given userlist. + /// + /// If `evaluateConditions` is `true`, any metadata conditions are evaluated + /// before the metadata otherwise unevaluated metadata is returned. + /// Evaluating plugin metadata conditions does **not** clear the condition + /// cache. + pub fn plugin_user_metadata( + &self, + plugin_name: &str, + evaluate_conditions: bool, + ) -> Result, GeneralError> { + let metadata = self.userlist.find_plugin(plugin_name); + + if evaluate_conditions { + if let Ok(Some(metadata)) = metadata { + return evaluate_all_conditions(metadata, &self.condition_evaluator_state) + .map_err(Into::into); + } + } + + metadata + } + + /// Sets a plugin's user metadata, replacing any loaded user metadata for + /// that plugin. + pub fn set_plugin_user_metadata(&mut self, plugin_metadata: PluginMetadata) { + self.userlist.set_plugin_metadata(plugin_metadata); + } + + /// Discards all loaded user metadata for the plugin with the given + /// filename. + pub fn discard_plugin_user_metadata(&mut self, plugin: &str) { + self.userlist.remove_plugin_metadata(plugin); + } + + /// Discards all loaded user metadata for all groups, plugins, and any + /// user-added general messages and known bash tags. + pub fn discard_all_user_metadata(&mut self) { + self.userlist.clear(); + } +} + +fn validate_write_path(output_path: &Path, overwrite: bool) -> Result<(), GeneralError> { + if !output_path.parent().map(|p| p.exists()).unwrap_or(false) { + Err(InvalidArgumentError { + message: "The output directory does not exist.".into(), + } + .into()) + } else if !overwrite && output_path.exists() { + Err(FileAccessError { + message: "Output file exists but overwrite is not set to true.".into(), + } + .into()) + } else { + Ok(()) + } +} + +fn merge_groups(lhs: &[Group], rhs: &[Group]) -> Vec { + let mut groups = lhs.to_vec(); + + let mut new_groups = Vec::new(); + + for rhs_group in rhs { + if let Some(group) = groups.iter_mut().find(|g| g.name() == rhs_group.name()) { + if rhs_group.description().is_some() || !rhs_group.after_groups().is_empty() { + let mut new_group = group.clone(); + + if let Some(description) = rhs_group.description() { + new_group = new_group.with_description(description.to_string()); + } + + if !rhs_group.after_groups().is_empty() { + let mut after_groups = new_group.after_groups().to_vec(); + after_groups.extend_from_slice(rhs_group.after_groups()); + + new_group = new_group.with_after_groups(after_groups); + } + + *group = new_group; + } + } else { + new_groups.push(rhs_group.clone()); + } + } + + groups.extend(new_groups); + + groups +} + +fn evaluate_all_conditions( + mut metadata: PluginMetadata, + state: &loot_condition_interpreter::State, +) -> Result, loot_condition_interpreter::Error> { + metadata.set_load_after_files(filter_files_on_conditions( + metadata.load_after_files(), + state, + )?); + + metadata.set_requirements(filter_files_on_conditions(metadata.requirements(), state)?); + + metadata.set_incompatibilities(filter_files_on_conditions( + metadata.incompatibilities(), + state, + )?); + + metadata.set_messages( + metadata + .messages() + .iter() + .filter_map(|m| filter_map_on_condition(m, m.condition(), state)) + .collect::, _>>()?, + ); + + metadata.set_tags( + metadata + .tags() + .iter() + .filter_map(|t| filter_map_on_condition(t, t.condition(), state)) + .collect::, _>>()?, + ); + + if !metadata.is_regex_plugin() { + metadata.set_dirty_info(filter_cleaning_data_on_conditions( + metadata.name(), + metadata.dirty_info(), + state, + )?); + + metadata.set_clean_info(filter_cleaning_data_on_conditions( + metadata.name(), + metadata.clean_info(), + state, + )?); + } + + if metadata.has_name_only() { + Ok(None) + } else { + Ok(Some(metadata)) + } +} + +fn evaluate_condition( + condition: Option<&str>, + state: &loot_condition_interpreter::State, +) -> Result { + if let Some(condition) = condition { + Expression::from_str(condition).and_then(|e| e.eval(state)) + } else { + Ok(true) + } +} + +fn filter_map_on_condition( + item: &T, + condition: Option<&str>, + state: &loot_condition_interpreter::State, +) -> Option> { + evaluate_condition(condition, state) + .map(|r| r.then(|| item.clone())) + .transpose() +} + +fn filter_files_on_conditions( + files: &[File], + state: &loot_condition_interpreter::State, +) -> Result, loot_condition_interpreter::Error> { + files + .iter() + .filter_map(|file| filter_map_on_condition(file, file.condition(), state)) + .collect::, _>>() +} + +fn filter_cleaning_data_on_conditions( + plugin_name: &str, + cleaning_info: &[PluginCleaningData], + state: &loot_condition_interpreter::State, +) -> Result, loot_condition_interpreter::Error> { + if plugin_name.is_empty() { + return Ok(Vec::new()); + } + + cleaning_info + .iter() + .filter_map(|i| { + let condition = format!("checksum(\"{}\", {:08X})", plugin_name, i.crc()); + + filter_map_on_condition(i, Some(condition.as_str()), state) + }) + .collect::, _>>() +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..264a6dbd --- /dev/null +++ b/src/error.rs @@ -0,0 +1,353 @@ +use std::fmt::Display; + +use petgraph::graph::NodeIndex; +use saphyr::Marker; + +use crate::{ + metadata::{ + MessageContent, + yaml::{YamlObjectType, to_yaml}, + }, + sorting::vertex::Vertex, +}; + +#[derive(Debug)] +pub struct CyclicInteractionError { + pub cycle: Vec, +} + +impl CyclicInteractionError { + pub fn new(cycle: Vec) -> Self { + Self { cycle } + } +} + +impl Display for CyclicInteractionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let cycle: String = self + .cycle + .iter() + .map(|v| { + if let Some(edge_type) = v.out_edge_type() { + format!("{} --[{}]-> ", v.name(), edge_type) + } else { + v.name().to_string() + } + }) + .chain(self.cycle.first().iter().map(|v| v.name().to_string())) + .collect(); + write!(f, "Cyclic interaction detected: {}", cycle) + } +} + +impl std::error::Error for CyclicInteractionError {} + +#[derive(Debug)] +pub struct FileAccessError { + pub message: String, +} + +impl FileAccessError { + pub(crate) fn new(message: String) -> Self { + FileAccessError { message } + } +} + +impl std::fmt::Display for FileAccessError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for FileAccessError {} + +#[derive(Debug)] +pub struct UndefinedGroupError { + pub group_name: String, +} + +impl UndefinedGroupError { + pub fn new(group_name: String) -> Self { + Self { group_name } + } +} + +impl Display for UndefinedGroupError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "The group \"{}\" does not exist", self.group_name) + } +} + +impl std::error::Error for UndefinedGroupError {} + +#[derive(Debug)] +pub struct InvalidArgumentError { + pub message: String, +} + +impl std::fmt::Display for InvalidArgumentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for InvalidArgumentError {} + +#[derive(Debug)] +pub(crate) struct YamlMergeKeyError { + value: saphyr::MarkedYaml, +} + +impl YamlMergeKeyError { + pub(crate) fn new(value: saphyr::MarkedYaml) -> Self { + YamlMergeKeyError { value } + } +} + +impl std::fmt::Display for YamlMergeKeyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut output = String::new(); + + let yaml = to_yaml(&self.value); + + if saphyr::YamlEmitter::new(&mut output).dump(&yaml).is_ok() { + write!( + f, + "Invalid YAML merge key value at line {} column {}: {}", + self.value.span.start.line(), + self.value.span.start.col(), + output + ) + } else { + write!( + f, + "Invalid YAML merge key value at line {} column {}: {:?}", + self.value.span.start.line(), + self.value.span.start.col(), + self.value + ) + } + } +} + +impl std::error::Error for YamlMergeKeyError {} + +#[derive(Debug)] +pub struct YamlParseError { + marker: saphyr::Marker, + message: String, +} + +impl YamlParseError { + pub fn new(marker: Marker, message: String) -> Self { + YamlParseError { marker, message } + } + + pub fn missing_key(marker: Marker, key: &str, yaml_type: YamlObjectType) -> Self { + YamlParseError::new( + marker, + format!("'{}' key missing from '{}' map object", key, yaml_type), + ) + } +} + +impl std::fmt::Display for YamlParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Encountered a YAML parsing error at line {} column {}: {}", + self.marker.line(), + self.marker.col(), + self.message + ) + } +} + +impl std::error::Error for YamlParseError {} + +#[derive(Debug)] +pub struct InvalidMultilingualMessageContents {} + +impl std::fmt::Display for InvalidMultilingualMessageContents { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Multilingual messages must contain a content string that uses the {} language code", + MessageContent::DEFAULT_LANGUAGE + ) + } +} + +impl std::error::Error for InvalidMultilingualMessageContents {} + +#[derive(Debug)] +pub struct PoisonedMutexError; + +impl std::fmt::Display for PoisonedMutexError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "A mutex is poisoned",) + } +} + +impl std::error::Error for PoisonedMutexError {} + +#[derive(Debug)] +pub struct PathfindingError { + message: String, +} + +impl PathfindingError { + pub fn new(message: String) -> Self { + Self { message } + } +} + +impl std::fmt::Display for PathfindingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for PathfindingError {} + +#[derive(Debug)] +pub struct SortingLogicError { + message: String, +} + +impl SortingLogicError { + pub fn new(message: String) -> Self { + Self { message } + } +} + +impl std::fmt::Display for SortingLogicError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for SortingLogicError {} + +#[derive(Debug)] +pub struct GeneralError(Box); + +impl std::fmt::Display for GeneralError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl std::error::Error for GeneralError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.0.as_ref()) + } +} + +impl From for GeneralError { + fn from(value: std::io::Error) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: saphyr::ScanError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: YamlMergeKeyError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: FileAccessError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: YamlParseError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: loot_condition_interpreter::Error) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: std::num::TryFromIntError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: InvalidMultilingualMessageContents) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: InvalidArgumentError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: loadorder::Error) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From> for GeneralError { + fn from(_: std::sync::PoisonError) -> Self { + GeneralError(Box::new(PoisonedMutexError)) + } +} + +impl From for GeneralError { + fn from(value: esplugin::Error) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From> for GeneralError { + fn from(value: Box) -> Self { + GeneralError(value) + } +} + +impl From for GeneralError { + fn from(value: UndefinedGroupError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: CyclicInteractionError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: PathfindingError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From> for GeneralError { + fn from(_: petgraph::algo::Cycle) -> Self { + GeneralError(Box::new(CyclicInteractionError { cycle: Vec::new() })) + } +} + +impl From for GeneralError { + fn from(value: SortingLogicError) -> Self { + GeneralError(Box::new(value)) + } +} diff --git a/src/game.rs b/src/game.rs new file mode 100644 index 00000000..ba513080 --- /dev/null +++ b/src/game.rs @@ -0,0 +1,745 @@ +use std::{ + collections::{HashMap, HashSet}, + fmt::Display, + path::{Path, PathBuf}, + sync::{Arc, RwLock}, +}; + +use loadorder::WritableLoadOrder; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; + +use crate::{ + database::Database, + error::{GeneralError, InvalidArgumentError}, + metadata::{ + Filename, + plugin_metadata::{GHOST_FILE_EXTENSION, iends_with_ascii}, + }, + plugin::{LoadScope, Plugin, is_valid_plugin, plugins_metadata}, + sorting::{ + groups::build_groups_graph, + plugins::{PluginSortingData, sort_plugins}, + }, +}; + +/// Codes used to create database handles for specific games. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum GameType { + /// The Elder Scrolls IV: Oblivion + TES4, + /// The Elder Scrolls V: Skyrim + TES5, + /// Fallout 3 + FO3, + /// Fallout: New Vegas + FONV, + /// Fallout 4 + FO4, + /// The Elder Scrolls V: Skyrim Special Edition + TES5SE, + /// Fallout 4 VR + FO4VR, + /// Skyrim VR + TES5VR, + /// The Elder Scrolls III: Morrowind + TES3, + /// Starfield + Starfield, + /// OpenMW + OpenMW, +} + +impl Display for GameType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GameType::TES4 => write!(f, "The Elder Scrolls IV: Oblivion"), + GameType::TES5 => write!(f, "The Elder Scrolls V: Skyrim"), + GameType::FO3 => write!(f, "Fallout 3"), + GameType::FONV => write!(f, "Fallout: New Vegas"), + GameType::FO4 => write!(f, "Fallout 4"), + GameType::TES5SE => write!(f, "The Elder Scrolls V: Skyrim Special Edition"), + GameType::FO4VR => write!(f, "Fallout 4 VR"), + GameType::TES5VR => write!(f, "The Elder Scrolls V: Skyrim VR"), + GameType::TES3 => write!(f, "The Elder Scrolls III: Morrowind"), + GameType::Starfield => write!(f, "Starfield"), + GameType::OpenMW => write!(f, "OpenMW"), + } + } +} + +impl From for loadorder::GameId { + fn from(value: GameType) -> Self { + match value { + GameType::TES4 => loadorder::GameId::Oblivion, + GameType::TES5 => loadorder::GameId::Skyrim, + GameType::FO3 => loadorder::GameId::Fallout3, + GameType::FONV => loadorder::GameId::FalloutNV, + GameType::FO4 => loadorder::GameId::Fallout4, + GameType::TES5SE => loadorder::GameId::SkyrimSE, + GameType::FO4VR => loadorder::GameId::Fallout4VR, + GameType::TES5VR => loadorder::GameId::SkyrimVR, + GameType::TES3 => loadorder::GameId::Morrowind, + GameType::Starfield => loadorder::GameId::Starfield, + GameType::OpenMW => loadorder::GameId::OpenMW, + } + } +} + +impl From for loot_condition_interpreter::GameType { + fn from(value: GameType) -> Self { + match value { + GameType::TES4 => loot_condition_interpreter::GameType::Oblivion, + GameType::TES5 => loot_condition_interpreter::GameType::Skyrim, + GameType::FO3 => loot_condition_interpreter::GameType::Fallout3, + GameType::FONV => loot_condition_interpreter::GameType::FalloutNV, + GameType::FO4 => loot_condition_interpreter::GameType::Fallout4, + GameType::TES5SE => loot_condition_interpreter::GameType::SkyrimSE, + GameType::FO4VR => loot_condition_interpreter::GameType::Fallout4VR, + GameType::TES5VR => loot_condition_interpreter::GameType::SkyrimVR, + GameType::TES3 => loot_condition_interpreter::GameType::Morrowind, + GameType::Starfield => loot_condition_interpreter::GameType::Starfield, + GameType::OpenMW => loot_condition_interpreter::GameType::OpenMW, + } + } +} + +impl From for esplugin::GameId { + fn from(value: GameType) -> Self { + match value { + GameType::TES4 => esplugin::GameId::Oblivion, + GameType::TES5 => esplugin::GameId::Skyrim, + GameType::FO3 => esplugin::GameId::Fallout3, + GameType::FONV => esplugin::GameId::FalloutNV, + GameType::FO4 | GameType::FO4VR => esplugin::GameId::Fallout4, + GameType::TES5SE | GameType::TES5VR => esplugin::GameId::SkyrimSE, + GameType::TES3 | GameType::OpenMW => esplugin::GameId::Morrowind, + GameType::Starfield => esplugin::GameId::Starfield, + } + } +} + +/// The interface through which game-specific functionality is provided. +#[derive(Debug)] +pub struct Game { + game_type: GameType, + game_path: PathBuf, + load_order: Box<(dyn WritableLoadOrder + Send + Sync + 'static)>, + // Stored in an Arc> to support loading metadata in parallel with + // loading plugins. + database: Arc>, + cache: GameCache, +} + +impl Game { + /// Initialise a new game handle, which is then used by all game-specific + /// functions. + /// + /// - `game_type` is a value representing which game to create the handle + /// for, + /// - `game_path` is the relative or absolute path to the directory + /// containing the game's executable. + /// + /// This function will attempt to look up the game's local data path, which + /// may fail in some situations (e.g. when running libloot natively on Linux + /// for a game other than Morrowind or OpenMW). [Game::with_local_path] + /// can be used to provide the local path instead. + pub fn new(game_type: GameType, game_path: &Path) -> Result { + log::info!( + "Attempting to create a game handle for game type {} with game path {:?}", + game_type, + game_path + ); + + let resolved_game_path = resolve_path(game_path); + if !resolved_game_path.is_dir() { + return Err(InvalidArgumentError { + message: format!( + "Given game path \"{:?}\" does not resolve to a valid directory.", + game_path + ), + } + .into()); + } + + let load_order = + loadorder::GameSettings::new(game_type.into(), &resolved_game_path)?.into_load_order(); + + let condition_evaluator_state = + new_condition_evaluator_state(game_type, &resolved_game_path, load_order.as_ref()); + + Ok(Game { + game_type, + game_path: resolved_game_path, + load_order, + database: Arc::new(RwLock::new(Database::new(condition_evaluator_state))), + cache: GameCache::default(), + }) + } + + /// Initialise a new game handle, which is then used by all game-specific + /// functions. + /// + /// - `game_type` is a value representing which game to create the handle + /// for, + /// - `game_path` is the relative or absolute path to the directory + /// containing the game's executable. + /// - `game_local_path` is the relative or absolute path to the game's local + /// data path. The local data folder is usually in `%%LOCALAPPDATA%`, but + /// Morrowind has no local data folder and OpenMW's is in the user's My + /// Games folder on Windows and in `$HOME/.config` on Linux. + pub fn with_local_path( + game_type: GameType, + game_path: &Path, + game_local_path: &Path, + ) -> Result { + log::info!( + "Attempting to create a game handle for game type {} with game path {:?} and game local path {:?}", + game_type, + game_path, + game_local_path + ); + + let resolved_game_path = resolve_path(game_path); + if !resolved_game_path.is_dir() { + return Err(InvalidArgumentError { + message: format!( + "Given game path \"{:?}\" does not resolve to a valid directory.", + game_path + ), + } + .into()); + } + + let resolved_game_local_path = resolve_path(game_local_path); + if resolved_game_local_path.exists() && !resolved_game_local_path.is_dir() { + return Err(InvalidArgumentError { + message: format!( + "Given game local path \"{:?}\" resolves to a path that exists but is not a valid directory.", + game_local_path + ), + }.into()); + } + + let load_order = loadorder::GameSettings::with_local_path( + game_type.into(), + &resolved_game_path, + &resolved_game_local_path, + )? + .into_load_order(); + + let condition_evaluator_state = + new_condition_evaluator_state(game_type, &resolved_game_path, load_order.as_ref()); + + Ok(Game { + game_type, + game_path: resolved_game_path, + load_order, + database: Arc::new(RwLock::new(Database::new(condition_evaluator_state))), + cache: GameCache::default(), + }) + } + + /// Get the game's type. + pub fn game_type(&self) -> GameType { + self.game_type + } + + /// Gets the currently-set additional data paths. + /// + /// The following games are configured with additional data paths by + /// default: + /// + /// - Fallout 4, when installed from the Microsoft Store + /// - Starfield + /// - OpenMW + pub fn additional_data_paths(&self) -> &[PathBuf] { + self.load_order + .game_settings() + .additional_plugins_directories() + } + + /// Set additional data paths. + /// + /// The additional data paths are used when interacting with the load order, + /// evaluating conditions and scanning for archives (BSA/BA2 depending on + /// the game). Additional data paths are used in the order they are given + /// (except with OpenMW, which checks them in reverse order), and take + /// precedence over the game's main data path. + /// + /// Setting additional data paths clears the condition cache in this game's + /// database object. + pub fn set_additional_data_paths( + &mut self, + additional_data_paths: &[&Path], + ) -> Result<(), GeneralError> { + let paths: Vec<_> = additional_data_paths + .iter() + .map(|p| p.to_path_buf()) + .collect(); + + let mut database = self.database.write()?; + let state = database.condition_evaluator_state_mut(); + state.clear_condition_cache()?; + + self.load_order + .game_settings_mut() + .set_additional_plugins_directories(paths.clone()); + + state.set_additional_data_paths(paths); + + Ok(()) + } + + /// Get the object used for accessing metadata-related functionality. + pub fn database(&self) -> Arc> { + Arc::clone(&self.database) + } + + /// Check if a file is a valid plugin. + /// + /// The validity check is not exhaustive: it generally checks that the + /// file is a valid plugin file extension for the game and that its header + /// (if applicable) can be parsed. + /// + /// `plugin_path` can be absolute or relative: relative paths are resolved + /// relative to the game's plugins directory, while absolute paths are used + /// as given. + pub fn is_valid_plugin(&self, plugin_path: &Path) -> bool { + is_valid_plugin(self.game_type, plugin_path) + } + + /// Fully parses plugins and loads their data. + /// + /// If a given plugin filename (or one that is case-insensitively equal) has + /// already been loaded, its previously-loaded data data is discarded. + /// + /// If the game is Morrowind, OpenMW or Starfield, it's only valid to fully + /// load a plugin if its masters are already loaded or included in the same + /// input slice. + /// + /// Relative paths in `plugin_paths` are resolved relative to the game's + /// plugins directory, while absolute paths are used as given. Each plugin + /// filename must be unique within the vector. + /// + /// Loading plugins clears the condition cache in this game's database + /// object. + pub fn load_plugins(&mut self, plugin_paths: &[&Path]) -> Result<(), GeneralError> { + let mut plugins = self.load_plugins_common(plugin_paths, LoadScope::WholePlugin)?; + + if matches!( + self.game_type, + GameType::TES3 | GameType::OpenMW | GameType::Starfield + ) { + let plugins_metadata = plugins_metadata(&plugins)?; + + for plugin in &mut plugins { + plugin.resolve_record_ids(&plugins_metadata)?; + } + } + + self.store_plugins(plugins) + } + + /// Parses plugin headers and loads their data. + /// + /// If a given plugin filename (or one that is case-insensitively equal) has + /// already been loaded, its previously-loaded data data is discarded. + /// + /// Relative paths in `plugin_paths` are resolved relative to the game's + /// plugins directory, while absolute paths are used as given. Each plugin + /// filename must be unique within the vector. + /// + /// Loading plugins clears the condition cache in this game's database + /// object. + pub fn load_plugin_headers(&mut self, plugin_paths: &[&Path]) -> Result<(), GeneralError> { + let plugins = self.load_plugins_common(plugin_paths, LoadScope::HeaderOnly)?; + + self.store_plugins(plugins) + } + + fn load_plugins_common( + &mut self, + plugin_paths: &[&Path], + load_scope: LoadScope, + ) -> Result, GeneralError> { + validate_plugin_paths(self.game_type, plugin_paths)?; + + let data_path = data_path(self.game_type, &self.game_path); + + let archive_paths = + find_archives(self.game_type, self.additional_data_paths(), &data_path)?; + + self.cache.set_archive_paths(archive_paths); + + log::trace!("Starting loading {}s.", load_scope); + + let plugins: Vec<_> = plugin_paths + .par_iter() + .filter_map(|path| { + try_load_plugin(&data_path, path, self.game_type, &self.cache, load_scope) + }) + .collect(); + + Ok(plugins) + } + + fn store_plugins(&mut self, plugins: Vec) -> Result<(), GeneralError> { + self.cache.insert_plugins(plugins); + + let mut database = self.database.write()?; + update_loaded_plugin_state( + database.condition_evaluator_state_mut(), + self.cache.plugins(), + ) + } + + /// Clears the plugins loaded by previous calls to [Game::load_plugins] or + /// [Game::load_plugin_headers]. + pub fn clear_loaded_plugins(&mut self) { + self.cache.clear_plugins(); + } + + /// Get data for a loaded plugin. + pub fn plugin(&self, plugin_name: &str) -> Option<&Plugin> { + self.cache.plugin(plugin_name) + } + + /// Get data for all loaded plugins. + pub fn loaded_plugins(&self) -> Vec<&Plugin> { + self.cache.plugins().collect() + } + + /// Calculates a new load order for the game's installed plugins (including + /// inactive plugins) and returns the sorted order. + /// + /// This pulls metadata from the masterlist and userlist if they are loaded, + /// and uses the loaded data of each plugin. No changes are applied to the + /// load order used by the game. This function does not load or evaluate the + /// masterlist or userlist. + /// + /// The order in which plugins are listed in `plugin_filenames` is used as + /// their current load order. All given plugins must have been already been + /// loaded using [Game::load_plugins] or [Game::load_plugin_headers]. + pub fn sort_plugins(&self, plugin_names: &[&str]) -> Result, GeneralError> { + let plugins = plugin_names + .iter() + .map(|n| { + self.plugin(n).ok_or_else(|| InvalidArgumentError { + message: format!("The plugin \"{}\" has not been loaded.", n), + }) + }) + .collect::, _>>()?; + + let database = self.database.read()?; + + let plugins_sorting_data = plugins + .into_iter() + .enumerate() + .map(|(i, p)| { + let masterlist_metadata = database.plugin_metadata(p.name(), false, true)?; + let user_metadata = database.plugin_user_metadata(p.name(), true)?; + PluginSortingData::new(p, masterlist_metadata.as_ref(), user_metadata.as_ref(), i) + }) + .collect::, _>>()?; + + if log::log_enabled!(log::Level::Debug) { + log::debug!("Current load order:"); + for plugin_name in plugin_names { + log::debug!("\t{}", plugin_name); + } + } + + let groups_graph = build_groups_graph(&database.groups(false), database.user_groups())?; + + let new_load_order = sort_plugins( + plugins_sorting_data, + &groups_graph, + self.load_order.game_settings().early_loading_plugins(), + )?; + + if log::log_enabled!(log::Level::Debug) { + log::debug!("Sorted load order:"); + for plugin_name in &new_load_order { + log::debug!("\t{}", plugin_name); + } + } + + Ok(new_load_order) + } + + /// Load the current load order state, discarding any previously held state. + /// + /// This function should be called whenever the load order or active state + /// of plugins "on disk" changes, so that the cached state is updated to + /// reflect the changes. + /// + /// Loading the current load order state clears the condition cache in this + /// game's database object. + pub fn load_current_load_order_state(&mut self) -> Result<(), GeneralError> { + self.load_order.load()?; + + let mut database = self.database.write()?; + let state = database.condition_evaluator_state_mut(); + state.clear_condition_cache()?; + state.set_active_plugins(&self.load_order.active_plugin_names()); + Ok(()) + } + + /// Check if the load order is ambiguous. + /// + /// This checks that all plugins in the current load order state have a + /// well-defined position in the "on disk" state, and that all data sources + /// are consistent. If the load order is ambiguous, different applications + /// may read different load orders from the same source data. + pub fn is_load_order_ambiguous(&self) -> Result { + self.load_order.is_ambiguous() + } + + /// Gets the path to the file that holds the list of active plugins. + /// The active plugins file path is often within the game's local path, but + /// its name and location varies by game and game configuration, so this + /// function exposes the path that libloot uses. + pub fn active_plugins_file_path(&self) -> &PathBuf { + self.load_order.game_settings().active_plugins_file() + } + + /// Check if the given plugin is active. + pub fn is_plugin_active(&self, plugin_name: &str) -> bool { + self.load_order.is_active(plugin_name) + } + + /// Get the current load order. + pub fn load_order(&self) -> Vec<&str> { + self.load_order.plugin_names() + } + + /// Set the game's load order. + /// + /// There is no way to persist the load order of inactive OpenMW plugins, so + /// setting an OpenMW load order will have no effect if the relative order + /// of active plugins is unchanged. + pub fn set_load_order(&mut self, load_order: &[&str]) -> Result<(), loadorder::Error> { + self.load_order.set_load_order(load_order) + } +} + +fn resolve_path(path: &Path) -> PathBuf { + if path.is_symlink() { + path.read_link().unwrap_or_else(|_| path.to_path_buf()) + } else { + path.to_path_buf() + } +} + +fn data_path(game_type: GameType, game_path: &Path) -> PathBuf { + match game_type { + GameType::TES3 => game_path.join("Data Files"), + GameType::OpenMW => game_path.join("resources/vfs"), + _ => game_path.join("Data"), + } +} + +fn new_condition_evaluator_state( + game_type: GameType, + game_path: &Path, + load_order: &(dyn WritableLoadOrder + Send + Sync + 'static), +) -> loot_condition_interpreter::State { + let data_path = data_path(game_type, game_path); + + let mut condition_evaluator_state = + loot_condition_interpreter::State::new(game_type.into(), data_path); + condition_evaluator_state.set_additional_data_paths( + load_order + .game_settings() + .additional_plugins_directories() + .to_vec(), + ); + + condition_evaluator_state +} + +fn validate_plugin_paths( + game_type: GameType, + plugin_paths: &[&Path], +) -> Result<(), InvalidArgumentError> { + // Check that all plugin filenames are unique. + let mut set = HashSet::new(); + for path in plugin_paths { + let filename = match path.file_name() { + Some(f) => f.to_string_lossy(), + None => { + return Err(InvalidArgumentError { + message: format!("The path \"{}\" has no filename.", path.display()), + }); + } + }; + if !set.insert(Filename::new(filename.to_string())) { + return Err(InvalidArgumentError { + message: format!("The filename \"{}\" is not unique.", filename), + }); + } + } + + let invalid_path = plugin_paths + .par_iter() + .find_any(|path| !is_valid_plugin(game_type, path)); + if let Some(invalid_path) = invalid_path { + return Err(InvalidArgumentError { + message: format!("\"{}\" is not a valid plugin", invalid_path.display()), + }); + } + + Ok(()) +} + +fn find_archives( + game_type: GameType, + additional_data_paths: &[PathBuf], + data_path: &Path, +) -> std::io::Result> { + let extension = archive_file_extension(game_type); + + let mut archive_paths = Vec::new(); + for path in additional_data_paths { + let paths = find_archives_in_path(path, extension)?; + archive_paths.extend(paths); + } + + let paths = find_archives_in_path(data_path, extension)?; + archive_paths.extend(paths); + + Ok(archive_paths) +} + +fn archive_file_extension(game_type: GameType) -> &'static str { + match game_type { + GameType::FO4 | GameType::FO4VR | GameType::Starfield => ".ba2", + _ => ".bsa", + } +} + +fn find_archives_in_path( + parent_path: &Path, + archive_file_extension: &str, +) -> std::io::Result> { + if !parent_path.exists() { + return Ok(Vec::new()); + } + + let paths = std::fs::read_dir(parent_path)? + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_type().map(|f| f.is_file()).unwrap_or(false) + && iends_with_ascii(&e.file_name().to_string_lossy(), archive_file_extension) + }) + .map(|e| e.path()) + .collect(); + + Ok(paths) +} + +fn try_load_plugin( + data_path: &Path, + plugin_path: &Path, + game_type: GameType, + game_cache: &GameCache, + load_scope: LoadScope, +) -> Option { + let resolved_path = resolve_plugin_path(game_type, data_path, plugin_path); + + match Plugin::new(game_type, game_cache, &resolved_path, load_scope) { + Ok(p) => Some(p), + Err(e) => { + log::error!( + "Caught error while trying to load \"{}\": {}", + plugin_path.display(), + e + ); + None + } + } +} + +fn resolve_plugin_path(game_type: GameType, data_path: &Path, plugin_path: &Path) -> PathBuf { + let plugin_path = data_path.join(plugin_path); + + if game_type != GameType::OpenMW && !plugin_path.exists() { + if let Some(filename) = plugin_path.file_name() { + log::debug!( + "Could not find plugin at {}, adding {} file extension", + plugin_path.display(), + GHOST_FILE_EXTENSION + ); + let mut filename = filename.to_os_string(); + filename.push(GHOST_FILE_EXTENSION); + plugin_path.with_file_name(filename) + } else { + plugin_path + } + } else { + plugin_path + } +} + +fn update_loaded_plugin_state<'a>( + state: &mut loot_condition_interpreter::State, + plugins: impl Iterator, +) -> Result<(), GeneralError> { + let mut plugin_versions = Vec::new(); + let mut plugin_crcs = Vec::new(); + + for plugin in plugins { + if let Some(version) = plugin.version() { + plugin_versions.push((plugin.name(), version)); + } + + if let Some(crc) = plugin.crc() { + plugin_crcs.push((plugin.name(), crc)); + } + } + + state.clear_condition_cache()?; + + state.set_plugin_versions(&plugin_versions); + + state.set_cached_crcs(&plugin_crcs)?; + + Ok(()) +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct GameCache { + plugins: HashMap, + archive_paths: HashSet, +} + +impl GameCache { + fn set_archive_paths(&mut self, archive_paths: Vec) { + self.archive_paths.clear(); + self.archive_paths.extend(archive_paths); + } + + fn insert_plugins(&mut self, plugins: Vec) { + for plugin in plugins { + self.plugins + .insert(Filename::new(plugin.name().to_string()), plugin); + } + } + + fn clear_plugins(&mut self) { + self.plugins.clear(); + } + + fn plugins(&self) -> impl Iterator { + self.plugins.values() + } + + fn plugin(&self, plugin_name: &str) -> Option<&Plugin> { + self.plugins.get(&Filename::new(plugin_name.to_string())) + } + + pub fn archives(&self) -> impl Iterator { + self.archive_paths.iter() + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..4abe3ac8 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,24 @@ +mod archive; +mod database; +pub mod error; +mod game; +mod logging; +pub mod metadata; +mod plugin; +mod sorting; +mod version; + +pub use database::Database; +use fancy_regex::{Regex, RegexBuilder}; +pub use game::{Game, GameType}; +pub use logging::{LogLevel, set_logging_callback}; +pub use plugin::Plugin; +pub use sorting::vertex::{EdgeType, Vertex}; +pub use version::{ + LIBLOOT_VERSION_MAJOR, LIBLOOT_VERSION_MINOR, LIBLOOT_VERSION_PATCH, is_compatible, + libloot_revision, libloot_version, +}; + +fn regex(name: &str) -> Result> { + Ok(RegexBuilder::new(name).case_insensitive(true).build()?) +} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 00000000..eaa096d7 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,74 @@ +use log::{Metadata, Record}; + +// const LOGGER: OnceCell>> = OnceCell::new(); + +/// Set the callback function that is called when logging. +/// +/// The `callback` function's first parameter is the level of the message being +/// logged, and the second is the message itself. +pub fn set_logging_callback(callback: T) +where + T: Fn(LogLevel, &str) + Send + Sync + 'static, +{ + // FIXME: set_boxed_logger can only be called once, and it's not possible to retrieve and downcast the logger from log once set. + let logger = Box::new(CallbackLogger { callback }); + + log::set_boxed_logger(logger) + .map(|_| log::set_max_level(log::LevelFilter::Trace)) + .unwrap(); +} + +/// Codes used to specify different levels of API logging. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warning, + Error, + Fatal, +} + +impl std::fmt::Display for LogLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LogLevel::Trace => write!(f, "trace"), + LogLevel::Debug => write!(f, "debug"), + LogLevel::Info => write!(f, "info"), + LogLevel::Warning => write!(f, "warning"), + LogLevel::Error => write!(f, "error"), + LogLevel::Fatal => write!(f, "fatal"), + } + } +} + +impl From for LogLevel { + fn from(value: log::Level) -> Self { + match value { + log::Level::Trace => LogLevel::Trace, + log::Level::Debug => LogLevel::Debug, + log::Level::Info => LogLevel::Info, + log::Level::Warn => LogLevel::Warning, + log::Level::Error => LogLevel::Error, + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +struct CallbackLogger { + callback: T, +} + +impl log::Log for CallbackLogger { + fn enabled(&self, _metadata: &Metadata) -> bool { + true + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + (self.callback)(record.level().into(), &format!("{}", record.args())); + } + } + + fn flush(&self) {} +} diff --git a/src/metadata/file.rs b/src/metadata/file.rs new file mode 100644 index 00000000..b157a617 --- /dev/null +++ b/src/metadata/file.rs @@ -0,0 +1,163 @@ +use std::str::FromStr; + +use loot_condition_interpreter::Expression; +use saphyr::{MarkedYaml, YamlData}; +use unicase::UniCase; + +use crate::error::{GeneralError, InvalidMultilingualMessageContents, YamlParseError}; + +use super::{ + message::{MessageContent, parse_message_contents_yaml, validate_message_contents}, + yaml::{YamlObjectType, as_string_node, get_required_string_value, get_string_value}, +}; + +/// Represents a file in a game's Data folder, including files in +/// subdirectories. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct File { + name: Filename, + display_name: Option, + detail: Vec, + condition: Option, +} + +impl File { + /// Construct a [File] with the given name. This can also be a relative path. + #[must_use] + pub fn new(name: String) -> Self { + Self { + name: Filename::new(name), + ..Default::default() + } + } + + /// Set the name to be displayed for the file in messages, formatted using + /// CommonMark. + #[must_use] + pub fn with_display_name(mut self, display_name: String) -> Self { + self.display_name = Some(display_name); + self + } + + /// Set the condition string. + #[must_use] + pub fn with_condition(mut self, condition: String) -> Self { + self.condition = Some(condition); + self + } + + /// Set the detail message content, which may be appended to any messages + /// generated for this file. If multilingual, one language must be + /// [MessageContent::DEFAULT_LANGUAGE]. + pub fn with_detail( + mut self, + detail: Vec, + ) -> Result { + validate_message_contents(&detail)?; + self.detail = detail; + Ok(self) + } + + /// Gets the name of the file (which may actually be a path). + pub fn name(&self) -> &Filename { + &self.name + } + + /// Get the display name of the file. + pub fn display_name(&self) -> Option<&str> { + self.display_name.as_deref() + } + + /// Get the detail message content of the file. + /// + /// If this file causes an error message to be displayed, the detail message + /// content should be appended to that message, as it provides more detail + /// about the error (e.g. suggestions for how to resolve it). + pub fn detail(&self) -> &[MessageContent] { + &self.detail + } + + /// Get the condition string. + pub fn condition(&self) -> Option<&str> { + self.condition.as_deref() + } +} + +/// Represents a case-insensitive filename. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Filename(UniCase); + +impl Filename { + /// Construct a Filename using the given string. + #[must_use] + pub fn new(s: String) -> Self { + Filename(UniCase::new(s)) + } + + /// Get this Filename as a string. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for &Filename { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for Filename { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom<&MarkedYaml> for File { + type Error = GeneralError; + + fn try_from(value: &MarkedYaml) -> Result { + match &value.data { + YamlData::String(s) => Ok(File { + name: Filename(UniCase::new(s.to_string())), + display_name: None, + detail: Vec::new(), + condition: None, + }), + YamlData::Hash(h) => { + let name = + get_required_string_value(value.span.start, h, "name", YamlObjectType::File)?; + + let display_name = get_string_value(h, "display", YamlObjectType::File)?; + + let detail = match h.get(&as_string_node("detail")) { + Some(n) => parse_message_contents_yaml( + n, + "detail", + YamlObjectType::PluginCleaningData, + )?, + None => Vec::new(), + }; + + let condition = match get_string_value(h, "condition", YamlObjectType::File)? { + Some(n) => { + Expression::from_str(n)?; + Some(n.to_string()) + } + None => None, + }; + + Ok(File { + name: Filename(UniCase::new(name.to_string())), + display_name: display_name.map(|s| s.to_string()), + detail, + condition, + }) + } + _ => Err(YamlParseError::new( + value.span.start, + "'file' object must be a map or string".into(), + ) + .into()), + } + } +} diff --git a/src/metadata/group.rs b/src/metadata/group.rs new file mode 100644 index 00000000..2dd1750d --- /dev/null +++ b/src/metadata/group.rs @@ -0,0 +1,91 @@ +use saphyr::MarkedYaml; + +use super::yaml::{ + YamlObjectType, get_as_hash, get_required_string_value, get_string_value, get_strings_vec_value, +}; +use crate::error::YamlParseError; + +/// Represents a group to which plugin metadata objects can belong. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Group { + name: String, + description: Option, + after_groups: Vec, +} + +impl Group { + /// Construct a [Group] with the given name. + #[must_use] + pub fn new(name: String) -> Self { + Self { + name, + ..Default::default() + } + } + + /// Set a description for the group. + #[must_use] + pub fn with_description(mut self, description: String) -> Self { + self.description = Some(description); + self + } + + /// Set the names of the groups that this group loads after. + #[must_use] + pub fn with_after_groups(mut self, after_groups: Vec) -> Self { + self.after_groups = after_groups; + self + } + + /// The name of the group to which all plugins belong by default. + pub const DEFAULT_NAME: &'static str = "default"; + + /// Get the name of the group. + pub fn name(&self) -> &str { + &self.name + } + + /// Get the description of the group. + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + /// Get the names of the groups that this group loads after. + pub fn after_groups(&self) -> &[String] { + &self.after_groups + } +} + +impl std::default::Default for Group { + /// Construct a Group with the default name and an empty set of groups to + /// load after. + #[must_use] + fn default() -> Self { + Self { + name: Group::DEFAULT_NAME.to_string(), + description: Default::default(), + after_groups: Default::default(), + } + } +} + +impl TryFrom<&MarkedYaml> for Group { + type Error = YamlParseError; + + fn try_from(value: &MarkedYaml) -> Result { + let hash = get_as_hash(value, YamlObjectType::Group)?; + + let name = + get_required_string_value(value.span.start, hash, "name", YamlObjectType::Group)?; + + let description = get_string_value(hash, "description", YamlObjectType::Group)?; + + let after = get_strings_vec_value(hash, "after", YamlObjectType::Group)?; + + Ok(Group { + name: name.to_string(), + description: description.map(|d| d.to_string()), + after_groups: after.iter().map(|a| a.to_string()).collect(), + }) + } +} diff --git a/src/metadata/location.rs b/src/metadata/location.rs new file mode 100644 index 00000000..99572e93 --- /dev/null +++ b/src/metadata/location.rs @@ -0,0 +1,77 @@ +use saphyr::{MarkedYaml, YamlData}; + +use crate::error::{GeneralError, YamlParseError}; + +use super::yaml::{YamlObjectType, get_required_string_value}; + +/// Represents a URL at which the parent plugin can be found. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Location { + url: String, + name: Option, +} + +impl Location { + /// Construct a [Location] with the given URL. + #[must_use] + pub fn new(url: String) -> Self { + Location { + url, + ..Default::default() + } + } + + /// Set a name for the URL, eg. the page or site name. + #[must_use] + pub fn with_name(mut self, name: String) -> Self { + self.name = Some(name); + self + } + + /// Get the URL. + pub fn url(&self) -> &str { + &self.url + } + + /// Get the descriptive name of this location. + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } +} + +impl TryFrom<&MarkedYaml> for Location { + type Error = GeneralError; + + fn try_from(value: &MarkedYaml) -> Result { + match &value.data { + YamlData::String(s) => Ok(Location { + url: s.clone(), + name: None, + }), + YamlData::Hash(h) => { + let link = get_required_string_value( + value.span.start, + h, + "link", + YamlObjectType::Location, + )?; + let name = get_required_string_value( + value.span.start, + h, + "name", + YamlObjectType::Location, + )?; + + Ok(Location { + url: link.to_string(), + name: Some(name.to_string()), + }) + } + _ => Err(YamlParseError::new( + value.span.start, + "'tag' object must be a map or string".into(), + ) + .into()), + } + } +} diff --git a/src/metadata/message.rs b/src/metadata/message.rs new file mode 100644 index 00000000..e260eaeb --- /dev/null +++ b/src/metadata/message.rs @@ -0,0 +1,322 @@ +use std::str::FromStr; + +use loot_condition_interpreter::Expression; +use saphyr::{MarkedYaml, YamlData}; + +use super::yaml::{ + YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_string_value, + get_strings_vec_value, +}; +use crate::error::{GeneralError, InvalidMultilingualMessageContents, YamlParseError}; + +/// Codes used to indicate the type of a message. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum MessageType { + /// A notification message that is of no significant severity. + #[default] + Say, + /// A warning message, used to indicate that an issue may be present that + /// the user may wish to act on. + Warn, + /// An error message, used to indicate that an issue that requires user + /// action is present. + Error, +} + +/// Represents a message's localised text content. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct MessageContent { + text: String, + language: String, +} + +impl MessageContent { + /// The code for the default language assumed for message content. + pub const DEFAULT_LANGUAGE: &'static str = "en"; + + /// Construct a [MessageContent] object with the given text in the default + /// language. + #[must_use] + pub fn new(text: String) -> Self { + MessageContent { + text, + ..Default::default() + } + } + + /// Set the language code to the given value. + #[must_use] + pub fn with_language(mut self, language: String) -> Self { + self.language = language; + self + } + + /// Get the message text. + pub fn text(&self) -> &str { + &self.text + } + + /// Get the text's language code. + pub fn language(&self) -> &str { + &self.language + } +} + +impl std::default::Default for MessageContent { + /// Construct a [MessageContent] object with an empty message string and the + /// default language. + #[must_use] + fn default() -> Self { + Self { + text: Default::default(), + language: MessageContent::DEFAULT_LANGUAGE.into(), + } + } +} + +/// Choose a [MessageContent] object from those given in `content` based on the +/// given `language`. +/// +/// The locale or language code for the preferred language to select. Codes are +/// of the form `[language code]_[country code]`. +/// +/// * If the vector only contains a single element, that element is returned. +/// * If content with a language that exactly matches the given locale or +/// language code is present, that content is returned. +/// * If a locale code is given and there is no exact match but content for that +/// locale's language is present, that content is returned. +/// * If a language code is given and there is no exact match but content for a +/// locale in that language is present, that content is returned. +/// * If no locale or language code matches are found and content in the default +/// language is present, that content is returned. +/// * Otherwise, an empty [Option] is returned. +pub fn select_message_content<'a>( + content: &'a [MessageContent], + language: &str, +) -> Option<&'a MessageContent> { + if content.is_empty() { + None + } else if let [c] = content { + Some(c) + } else { + let language_code = language.split_once('_').map(|p| p.0); + + let mut matched = None; + let mut english = None; + + for mc in content { + if mc.language == language { + return Some(mc); + } else if matched.is_none() { + if language_code.is_some_and(|c| c == mc.language) { + matched = Some(mc); + } else if language_code.is_none() { + if let Some((content_language_code, _)) = mc.language.split_once('_') { + if content_language_code == language { + matched = Some(mc); + } + } + } + + if mc.language == MessageContent::DEFAULT_LANGUAGE { + english = Some(mc); + } + } + } + + if matched.is_some() { + matched + } else if english.is_some() { + english + } else { + None + } + } +} + +/// Represents a message with localisable text content. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Message { + message_type: MessageType, + content: Vec, + condition: Option, +} + +impl Message { + /// Construct a [Message] with the given type and a content string in the + /// language given by [MessageContent::DEFAULT_LANGUAGE]. + #[must_use] + pub fn new(message_type: MessageType, content: String) -> Self { + Self { + message_type, + content: vec![MessageContent { + text: content, + language: MessageContent::DEFAULT_LANGUAGE.to_string(), + }], + condition: None, + } + } + + /// Construct a [Message] with the given type and content. If more than one + /// [MessageContent] object is given, one must use + /// the language code given by [MessageContent::DEFAULT_LANGUAGE]. + pub fn multilingual( + message_type: MessageType, + content: Vec, + ) -> Result { + validate_message_contents(&content)?; + + Ok(Self { + message_type, + content, + condition: None, + }) + } + + /// Set the condition string. + #[must_use] + pub fn with_condition(mut self, condition: String) -> Self { + self.condition = Some(condition); + self + } + + /// Get the message type. + pub fn message_type(&self) -> MessageType { + self.message_type + } + + /// Get the message content. + pub fn content(&self) -> &[MessageContent] { + &self.content + } + + /// Get the condition string. + pub fn condition(&self) -> Option<&str> { + self.condition.as_deref() + } +} + +pub(crate) fn validate_message_contents( + contents: &[MessageContent], +) -> Result<(), InvalidMultilingualMessageContents> { + if contents.len() > 1 { + let english_string_exists = contents + .iter() + .any(|c| c.language == MessageContent::DEFAULT_LANGUAGE); + + if !english_string_exists { + return Err(InvalidMultilingualMessageContents {}); + } + } + + Ok(()) +} + +impl TryFrom<&MarkedYaml> for MessageContent { + type Error = YamlParseError; + + fn try_from(value: &MarkedYaml) -> Result { + let hash = get_as_hash(value, YamlObjectType::MessageContent)?; + + let text = + get_required_string_value(value.span.start, hash, "text", YamlObjectType::Message)?; + + let language = + get_required_string_value(value.span.start, hash, "lang", YamlObjectType::Message)?; + + Ok(MessageContent { + text: text.to_string(), + language: language.to_string(), + }) + } +} + +pub(crate) fn parse_message_contents_yaml( + value: &MarkedYaml, + key: &str, + parent_yaml_type: YamlObjectType, +) -> Result, GeneralError> { + let contents = match &value.data { + YamlData::String(s) => { + vec![MessageContent { + text: s.to_string(), + language: MessageContent::DEFAULT_LANGUAGE.to_string(), + }] + } + YamlData::Array(a) => a + .iter() + .map(MessageContent::try_from) + .collect::, _>>()?, + _ => { + return Err(YamlParseError::new( + value.span.start, + format!( + "'{}' key in '{}' map is not a list or string", + key, parent_yaml_type + ), + ) + .into()); + } + }; + + validate_message_contents(&contents)?; + + Ok(contents) +} + +impl TryFrom<&MarkedYaml> for Message { + type Error = GeneralError; + + fn try_from(value: &MarkedYaml) -> Result { + let hash = get_as_hash(value, YamlObjectType::Message)?; + + let message_type = + get_required_string_value(value.span.start, hash, "type", YamlObjectType::Message)?; + let message_type = match message_type { + "warn" => MessageType::Warn, + "error" => MessageType::Error, + _ => MessageType::Say, + }; + + let mut content = match hash.get(&as_string_node("content")) { + Some(n) => parse_message_contents_yaml(n, "content", YamlObjectType::Message)?, + None => { + return Err(YamlParseError::missing_key( + value.span.start, + "content", + YamlObjectType::Message, + ) + .into()); + } + }; + + let subs = get_strings_vec_value(hash, "subs", YamlObjectType::Message)?; + + if !subs.is_empty() { + for mc in &mut content { + for (index, sub) in subs.iter().enumerate() { + let placeholder = format!("{}", index); + if !mc.text.contains(&placeholder) { + return Err(YamlParseError::new(value.span.start, format!("Failed to substitute \"{}\" into message, no placeholder \"{}\" was found", sub, placeholder)).into()); + } + + mc.text = mc.text.replace(&placeholder, sub); + } + } + } + + let condition = match get_string_value(hash, "condition", YamlObjectType::Message)? { + Some(c) => { + Expression::from_str(c)?; + Some(c.to_string()) + } + None => None, + }; + + Ok(Message { + message_type, + content, + condition, + }) + } +} diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs new file mode 100644 index 00000000..e7bbc5aa --- /dev/null +++ b/src/metadata/metadata_document.rs @@ -0,0 +1,469 @@ +use std::{ + collections::{HashMap, HashSet}, + path::Path, + sync::LazyLock, +}; + +use saphyr::{MarkedYaml, YamlData}; + +use crate::error::{FileAccessError, GeneralError, YamlMergeKeyError, YamlParseError}; + +use super::{ + file::Filename, + group::Group, + message::Message, + plugin_metadata::PluginMetadata, + yaml::{YamlObjectType, as_string_node, get_as_slice}, +}; + +static MERGE_KEY: LazyLock = LazyLock::new(|| as_string_node("<<")); + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MetadataDocument { + bash_tags: Vec, + groups: Vec, + messages: Vec, + plugins: HashMap, + regex_plugins: Vec, +} + +impl MetadataDocument { + pub fn load(&mut self, file_path: &Path) -> Result<(), GeneralError> { + log::trace!("Loading file: {:?}", file_path); + + let content = std::fs::read_to_string(file_path)?; + + self.load_from_str(&content)?; + + log::trace!( + "Successfully loaded metadata from file at \"{:?}\".", + file_path + ); + + Ok(()) + } + + pub fn load_with_prelude( + &mut self, + masterlist_path: &Path, + prelude_path: &Path, + ) -> Result<(), GeneralError> { + let masterlist = std::fs::read_to_string(masterlist_path)?; + let prelude = std::fs::read_to_string(prelude_path)?; + + let masterlist = replace_prelude(masterlist, prelude); + + self.load_from_str(&masterlist)?; + + log::trace!( + "Successfully loaded metadata from file at \"{:?}\".", + masterlist_path + ); + + Ok(()) + } + + fn load_from_str(&mut self, string: &str) -> Result<(), GeneralError> { + let mut docs = MarkedYaml::load_from_str(string)?; + + let doc = docs + .pop() + .ok_or_else(|| FileAccessError::new("No documents in the loaded YAML".into()))?; + if !docs.is_empty() { + return Err(FileAccessError::new(format!( + "YAML file contained more than one document, found {}", + docs.len() + 1 + )) + .into()); + } + let doc = process_merge_keys(doc)?; + + let doc = match doc.data { + YamlData::Hash(h) => h, + _ => { + return Err(YamlParseError::new( + doc.span.start, + "The root of the YAML document is not a map.".into(), + ) + .into()); + } + }; + + let mut plugins: HashMap = HashMap::new(); + let mut regex_plugins: Vec = Vec::new(); + for plugin in get_as_slice(&doc, "plugins", YamlObjectType::MetadataDocument)? { + let plugin = PluginMetadata::try_from(plugin)?; + if plugin.is_regex_plugin() { + regex_plugins.push(plugin); + } else { + let filename = Filename::new(plugin.name().to_string()); + if plugins.contains_key(&filename) { + return Err(FileAccessError::new(format!( + "More than one entry exists for plugin \"{}\"", + plugin.name() + )) + .into()); + } + plugins.insert(filename, plugin); + } + } + + let messages = get_as_slice(&doc, "globals", YamlObjectType::MetadataDocument)? + .iter() + .map(Message::try_from) + .collect::, _>>()?; + + let mut bash_tags = Vec::new(); + let mut str_set = HashSet::new(); + for bash_tag_yaml in get_as_slice(&doc, "bash_tags", YamlObjectType::MetadataDocument)? { + let bash_tag = match bash_tag_yaml.data.as_str() { + Some(b) => b, + None => { + return Err(YamlParseError::new( + bash_tag_yaml.span.start, + "Found a non-string Bash Tag.".into(), + ) + .into()); + } + }; + + if str_set.contains(bash_tag) { + return Err(YamlParseError::new( + bash_tag_yaml.span.start, + format!("More than one entry exists for Bash Tag \"{}\"", bash_tag), + ) + .into()); + } + + bash_tags.push(bash_tag.to_string()); + str_set.insert(bash_tag); + } + + let mut group_names = HashSet::new(); + let mut groups = Vec::new(); + for group_yaml in get_as_slice(&doc, "groups", YamlObjectType::MetadataDocument)? { + let group = Group::try_from(group_yaml)?; + + let name = group.name().to_string(); + if group_names.contains(&name) { + return Err(YamlParseError::new( + group_yaml.span.start, + format!("More than one entry exists for group \"{}\"", group.name()), + ) + .into()); + } + + groups.push(group); + group_names.insert(name); + } + + if !group_names.contains(Group::DEFAULT_NAME) { + groups.insert(0, Group::default()); + } + + self.plugins = plugins; + self.regex_plugins = regex_plugins; + self.messages = messages; + self.bash_tags = bash_tags; + self.groups = groups; + + Ok(()) + } + + pub fn save(&self, file_path: &Path) -> Result<(), GeneralError> { + // let mut hash = saphyr::Hash::new(); + + // hash.insert( + // Yaml::String("bash_tags".into()), + // Yaml::Array( + // self.bash_tags + // .iter() + // .map(|b| Yaml::String(b.to_string())) + // .collect(), + // ), + // ); + + // let mut yaml = Yaml::Hash(hash); + + // let mut output = String::new(); + // FIXME: Can't handle the error because it's a type that's not exported by saphyr. + // let emitter = YamlEmitter::new(&mut output).dump(&yaml)?; + + todo!() + } + + pub fn bash_tags(&self) -> &[String] { + &self.bash_tags + } + + pub fn groups(&self) -> &[Group] { + &self.groups + } + + pub fn messages(&self) -> &[Message] { + &self.messages + } + + pub fn plugins(&self) -> impl Iterator { + self.plugins.values().chain(self.regex_plugins.iter()) + } + + pub fn find_plugin(&self, plugin_name: &str) -> Result, GeneralError> { + let mut metadata = match self.plugins.get(&Filename::new(plugin_name.to_string())) { + Some(m) => m.clone(), + None => PluginMetadata::new(plugin_name)?, + }; + + // Now we want to also match possibly multiple regex entries. + for regex_plugin in &self.regex_plugins { + if regex_plugin.name_matches(plugin_name) { + metadata.merge_metadata(regex_plugin); + } + } + + if metadata.has_name_only() { + Ok(None) + } else { + Ok(Some(metadata)) + } + } + + pub fn set_groups(&mut self, groups: Vec) { + self.groups = groups; + } + + pub fn set_plugin_metadata(&mut self, plugin_metadata: PluginMetadata) { + self.plugins.insert( + Filename::new(plugin_metadata.name().to_string()), + plugin_metadata, + ); + } + + pub fn remove_plugin_metadata(&mut self, plugin_name: &str) { + self.plugins.remove(&Filename::new(plugin_name.to_string())); + } + + pub fn clear(&mut self) { + self.bash_tags.clear(); + self.groups.clear(); + self.messages.clear(); + self.plugins.clear(); + self.regex_plugins.clear(); + } +} + +fn process_merge_keys(mut yaml: MarkedYaml) -> Result { + match yaml.data { + YamlData::Alias(_) => panic!("Alias encountered!"), + YamlData::Array(a) => { + yaml.data = merge_array_elements(a).map(YamlData::Array)?; + Ok(yaml) + } + YamlData::Hash(h) => { + yaml.data = merge_hash_keys(h).map(YamlData::Hash)?; + Ok(yaml) + } + _ => Ok(yaml), + } +} + +fn merge_array_elements( + array: saphyr::AnnotatedArray, +) -> Result, YamlMergeKeyError> { + array.into_iter().map(process_merge_keys).collect() +} + +fn merge_hash_keys( + hash: saphyr::AnnotatedHash, +) -> Result, YamlMergeKeyError> { + let mut hash: saphyr::AnnotatedHash = hash + .into_iter() + .map(|(key, value)| { + process_merge_keys(key) + .and_then(|key| process_merge_keys(value).map(|value| (key, value))) + }) + .collect::>()?; + + if let Some(value) = hash.remove(&MERGE_KEY) { + merge_into_hash(hash, value) + } else { + Ok(hash) + } +} + +fn merge_into_hash( + hash: saphyr::AnnotatedHash, + value: MarkedYaml, +) -> Result, YamlMergeKeyError> { + match value.data { + YamlData::::Array(a) => a.into_iter().try_fold(hash, |acc, e| { + if let YamlData::Hash(h) = e.data { + Ok(merge_hashes(acc, h)) + } else { + Err(YamlMergeKeyError::new(e)) + } + }), + YamlData::::Hash(h) => Ok(merge_hashes(hash, h)), + _ => Err(YamlMergeKeyError::new(value)), + } +} + +fn merge_hashes( + mut hash1: saphyr::AnnotatedHash, + hash2: saphyr::AnnotatedHash, +) -> saphyr::AnnotatedHash { + for (key, value) in hash2 { + hash1.entry(key).or_insert(value); + } + hash1 +} + +fn replace_prelude(masterlist: String, prelude: String) -> String { + if let Some((start, end)) = find_prelude_bounds(&masterlist) { + let prelude = indent_prelude(prelude); + + masterlist[..start].to_string() + &prelude + &masterlist[end..] + } else { + masterlist + } +} + +fn find_prelude_bounds(masterlist: &str) -> Option<(usize, usize)> { + let prelude_on_first_line = "prelude:"; + let prelude_on_new_line = "\nprelude:"; + + let start = if masterlist.starts_with(prelude_on_first_line) { + prelude_on_first_line.len() + } else if let Some(pos) = masterlist.find(prelude_on_new_line) { + pos + prelude_on_new_line.len() + } else { + return None; + }; + + let mut pos = start; + while let Some(next_line_break_pos) = masterlist[pos..].find('\n') { + if next_line_break_pos == masterlist.len() - 1 { + break; + } + + pos = next_line_break_pos + 1; + + if let Some(c) = masterlist.as_bytes().get(pos) { + if *c != b' ' && *c != b'#' && *c != b'\n' { + return Some((start, next_line_break_pos)); + } + } + } + + Some((start, masterlist.len())) +} + +fn indent_prelude(prelude: String) -> String { + let prelude = ("\n ".to_string() + &prelude.replace("\n", "\n ")).replace(" \n", "\n"); + + if prelude.ends_with("\n ") { + prelude[..prelude.len() - 2].to_string() + } else { + prelude + } +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + const METADATA_LIST_YAML: &str = r#"bash_tags: + - 'C.Climate' + - 'Relev' + +groups: + - name: group1 + after: + - group2 + - name: group2 + after: + - default + +globals: + - type: say + content: 'A global message.' + +plugins: + - name: 'Blank.esm' + priority: -100 + msg: + - type: warn + content: 'This is a warning.' + - type: say + content: 'This message should be removed when evaluating conditions.' + condition: 'active("Blank - Different.esm")' + + - name: 'Blank.+\.esp' + after: + - 'Blank.esm' + + - name: 'Blank.+(Different)?.*\.esp' + inc: + - 'Blank.esp' + + - name: 'Blank.esp' + group: group2 + dirty: + - crc: 0xDEADBEEF + util: utility + "#; + + #[test] + fn load_should_resolve_aliases() { + let tmp_dir = tempdir().unwrap(); + let yaml = r#" + prelude: + - &anchor + type: say + content: test message + + globals: + - *anchor + "#; + + let path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&path, yaml).unwrap(); + + let mut metadata_list = MetadataDocument::default(); + metadata_list.load(&path).unwrap(); + } + + #[test] + fn load_should_resolve_merge_keys() { + let tmp_dir = tempdir().unwrap(); + let yaml = r#" + prelude: + - &anchor + type: say + content: test message + + globals: + - <<: *anchor + condition: file("test.esp") + "#; + + let path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&path, yaml).unwrap(); + + let mut metadata_list = MetadataDocument::default(); + metadata_list.load(&path).unwrap(); + } + + #[test] + fn load_should_deserialise_masterlist() { + let tmp_dir = tempdir().unwrap(); + + let path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&path, METADATA_LIST_YAML).unwrap(); + + let mut metadata_list = MetadataDocument::default(); + metadata_list.load(&path).unwrap(); + } +} diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs new file mode 100644 index 00000000..3143ecd5 --- /dev/null +++ b/src/metadata/mod.rs @@ -0,0 +1,17 @@ +mod file; +mod group; +mod location; +mod message; +pub(crate) mod metadata_document; +mod plugin_cleaning_data; +pub(crate) mod plugin_metadata; +mod tag; +pub(crate) mod yaml; + +pub use file::{File, Filename}; +pub use group::Group; +pub use location::Location; +pub use message::{Message, MessageContent, MessageType, select_message_content}; +pub use plugin_cleaning_data::PluginCleaningData; +pub use plugin_metadata::PluginMetadata; +pub use tag::{Tag, TagSuggestion}; diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs new file mode 100644 index 00000000..5443a23a --- /dev/null +++ b/src/metadata/plugin_cleaning_data.rs @@ -0,0 +1,150 @@ +use saphyr::MarkedYaml; + +use crate::error::{GeneralError, InvalidMultilingualMessageContents, YamlParseError}; + +use super::{ + message::{MessageContent, parse_message_contents_yaml, validate_message_contents}, + yaml::{YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_u32_value}, +}; + +/// Represents data identifying the plugin under which it is stored as dirty or +/// clean. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct PluginCleaningData { + crc: u32, + itm_count: u32, + deleted_reference_count: u32, + deleted_navmesh_count: u32, + cleaning_utility: String, + detail: Vec, +} + +impl PluginCleaningData { + /// Construct a [PluginCleaningData] object with the given CRC and cleaning + /// utility, no detail and the ITM, deleted reference and deleted navmesh + /// counts set to zero. + #[must_use] + pub fn new(crc: u32, cleaning_utility: String) -> Self { + Self { + crc, + cleaning_utility, + ..Default::default() + } + } + + /// Set the number of Identical To Master records found in the plugin. + #[must_use] + pub fn with_itm_count(mut self, itm_count: u32) -> Self { + self.itm_count = itm_count; + self + } + + /// Set the number of deleted references found in the plugin. + #[must_use] + pub fn with_deleted_reference_count(mut self, deleted_reference_count: u32) -> Self { + self.deleted_reference_count = deleted_reference_count; + self + } + + /// Set the number of deleted navmeshes found in the plugin. + #[must_use] + pub fn with_deleted_navmesh_count(mut self, deleted_navmesh_count: u32) -> Self { + self.deleted_navmesh_count = deleted_navmesh_count; + self + } + + /// Set the detail message content, which may be appended to any messages + /// generated for this cleaning data. If multilingual, one language must be + /// [MessageContent::DEFAULT_LANGUAGE]. + pub fn with_detail( + mut self, + detail: Vec, + ) -> Result { + validate_message_contents(&detail)?; + self.detail = detail; + Ok(self) + } + + /// Get the CRC that identifies the plugin that the cleaning data is for. + pub fn crc(&self) -> u32 { + self.crc + } + + /// Get the number of Identical To Master records found in the plugin. + pub fn itm_count(&self) -> u32 { + self.itm_count + } + + /// Get the number of deleted references found in the plugin. + pub fn deleted_reference_count(&self) -> u32 { + self.deleted_reference_count + } + + /// Get the number of deleted navmeshes found in the plugin. + pub fn deleted_navmesh_count(&self) -> u32 { + self.deleted_navmesh_count + } + + /// Get the cleaning utility that was used to check the plugin. + /// + /// The string may include a cleaning utility name, possibly related + /// information such as a version number and/or a CommonMark-formatted URL + /// to the utility's download location. + pub fn cleaning_utility(&self) -> &str { + &self.cleaning_utility + } + + /// Get any additional informative message content supplied with the + /// cleaning data, eg. a link to a cleaning guide or information on wild + /// edits or manual cleaning steps. + pub fn detail(&self) -> &[MessageContent] { + &self.detail + } +} + +impl TryFrom<&MarkedYaml> for PluginCleaningData { + type Error = GeneralError; + + fn try_from(value: &MarkedYaml) -> Result { + let hash = get_as_hash(value, YamlObjectType::PluginCleaningData)?; + + let crc = match get_u32_value(hash, "crc", YamlObjectType::PluginCleaningData)? { + Some(n) => n, + None => { + return Err(YamlParseError::missing_key( + value.span.start, + "crc", + YamlObjectType::PluginCleaningData, + ) + .into()); + } + }; + + let util = get_required_string_value( + value.span.start, + hash, + "util", + YamlObjectType::PluginCleaningData, + )?; + + let itm = get_u32_value(hash, "itm", YamlObjectType::PluginCleaningData)?.unwrap_or(0); + let udr = get_u32_value(hash, "udr", YamlObjectType::PluginCleaningData)?.unwrap_or(0); + let nav = get_u32_value(hash, "nav", YamlObjectType::PluginCleaningData)?.unwrap_or(0); + + let detail = match hash.get(&as_string_node("detail")) { + Some(n) => { + parse_message_contents_yaml(n, "detail", YamlObjectType::PluginCleaningData)? + } + None => Vec::new(), + }; + + Ok(PluginCleaningData { + crc, + itm_count: itm, + deleted_reference_count: udr, + deleted_navmesh_count: nav, + cleaning_utility: util.to_string(), + detail, + }) + } +} diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs new file mode 100644 index 00000000..f53a445e --- /dev/null +++ b/src/metadata/plugin_metadata.rs @@ -0,0 +1,361 @@ +use fancy_regex::Regex; +use saphyr::MarkedYaml; + +use crate::{ + error::{GeneralError, YamlParseError}, + regex, +}; + +use super::{ + file::File, + location::Location, + message::Message, + plugin_cleaning_data::PluginCleaningData, + tag::Tag, + yaml::{ + YamlObjectType, get_as_hash, get_as_slice, get_required_string_value, get_string_value, + }, +}; + +pub(crate) const GHOST_FILE_EXTENSION: &str = ".ghost"; + +/// Represents a plugin's metadata. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct PluginMetadata { + name: PluginName, + group: Option, + load_after: Vec, + requirements: Vec, + incompatibilities: Vec, + messages: Vec, + tags: Vec, + dirty_info: Vec, + clean_info: Vec, + locations: Vec, +} + +impl PluginMetadata { + /// Construct a [PluginMetadata] object with no metadata for a plugin with + /// the given filename. + pub fn new(name: &str) -> Result> { + Ok(Self { + name: PluginName::new(name)?, + ..Default::default() + }) + } + + /// Get the plugin name. + pub fn name(&self) -> &str { + &self.name.string + } + + /// Get the plugin's group. + /// + /// The [Option] is `None` if no group is explicitly set. + pub fn group(&self) -> Option<&str> { + self.group.as_deref() + } + + /// Get the plugins that the plugin must load after. + pub fn load_after_files(&self) -> &[File] { + &self.load_after + } + + /// Get the files that the plugin requires to be installed. + pub fn requirements(&self) -> &[File] { + &self.requirements + } + + /// Get the files that the plugin is incompatible with. + pub fn incompatibilities(&self) -> &[File] { + &self.incompatibilities + } + + /// Get the plugin's messages. + pub fn messages(&self) -> &[Message] { + &self.messages + } + + /// Get the plugin's Bash Tag suggestions. + pub fn tags(&self) -> &[Tag] { + &self.tags + } + + /// Get the plugin's dirty plugin information. + pub fn dirty_info(&self) -> &[PluginCleaningData] { + &self.dirty_info + } + + /// Get the plugin's clean plugin information. + pub fn clean_info(&self) -> &[PluginCleaningData] { + &self.clean_info + } + + /// Get the locations at which this plugin can be found. + pub fn locations(&self) -> &[Location] { + &self.locations + } + + /// Set the plugin's group. + pub fn set_group(&mut self, group: &str) { + self.group = Some(group.to_string()) + } + + /// Unsets the plugin's group, so that it is implicitly a member of the + /// default group. + pub fn unset_group(&mut self) { + self.group = None + } + + /// Get the plugins that the plugin must load after. + pub fn set_load_after_files(&mut self, files: Vec) { + self.load_after = files; + } + + /// Get the files that the plugin requires to be installed. + pub fn set_requirements(&mut self, files: Vec) { + self.requirements = files; + } + + /// Get the files that the plugin is incompatible with. + pub fn set_incompatibilities(&mut self, files: Vec) { + self.incompatibilities = files; + } + + /// Get the plugin's messages. + pub fn set_messages(&mut self, messages: Vec) { + self.messages = messages; + } + + /// Get the plugin's Bash Tag suggestions. + pub fn set_tags(&mut self, tags: Vec) { + self.tags = tags; + } + + /// Get the plugin's dirty plugin information. + pub fn set_dirty_info(&mut self, dirty_info: Vec) { + self.dirty_info = dirty_info; + } + + /// Get the plugin's clean plugin information. + pub fn set_clean_info(&mut self, clean_info: Vec) { + self.clean_info = clean_info; + } + + /// Get the locations at which this plugin can be found. + pub fn set_locations(&mut self, locations: Vec) { + self.locations = locations; + } + + /// Merge metadata from the given [PluginMetadata] object into this object. + /// + /// If an equal metadata object already exists in this PluginMetadata + /// object, it is not duplicated. This object's group is replaced by the + /// given object's group if the latter is explicit. + pub fn merge_metadata(&mut self, plugin: &PluginMetadata) { + if plugin.has_name_only() { + return; + } + + if self.group.is_none() && plugin.group.is_some() { + self.group = plugin.group.clone(); + } + + merge_vecs(&mut self.load_after, &plugin.load_after); + merge_vecs(&mut self.requirements, &plugin.requirements); + merge_vecs(&mut self.incompatibilities, &plugin.incompatibilities); + merge_vecs(&mut self.tags, &plugin.tags); + self.messages.extend(plugin.messages.iter().cloned()); + merge_vecs(&mut self.dirty_info, &plugin.dirty_info); + merge_vecs(&mut self.clean_info, &plugin.clean_info); + merge_vecs(&mut self.locations, &plugin.locations); + } + + /// Check if no plugin metadata is set. + pub fn has_name_only(&self) -> bool { + self.group.is_none() + && self.load_after.is_empty() + && self.requirements.is_empty() + && self.incompatibilities.is_empty() + && self.messages.is_empty() + && self.tags.is_empty() + && self.dirty_info.is_empty() + && self.clean_info.is_empty() + && self.locations.is_empty() + } + + /// Check if the plugin name is a regular expression. + /// + /// Returns `true` if the plugin name contains any of the characters `:\*?|` + /// and `false` otherwise. + pub fn is_regex_plugin(&self) -> bool { + self.name.regex.is_some() + } + + /// Check if the given plugin name matches this plugin metadata object's + /// name field. + /// + /// If the name field is a regular expression, the given plugin name will be + /// matched against it, otherwise the strings will be compared + /// case-insensitively. The given plugin name must be literal, i.e. not a + /// regular expression. + pub fn name_matches(&self, other_name: &str) -> bool { + if let Some(regex) = &self.name.regex { + regex.is_match(other_name).inspect_err(|e| { + log::error!("Encountered an error while trying to match the regex {} to the string {}: {}", regex.as_str(), other_name, e); + }).unwrap_or(false) + } else { + unicase::eq(self.name.string.as_str(), other_name) + } + } + + /// Serialises the plugin metadata as YAML. + pub fn as_yaml(&self) -> String { + todo!() + } +} + +#[derive(Clone, Debug, Default)] +struct PluginName { + string: String, + regex: Option, +} + +impl PluginName { + fn new(name: &str) -> Result> { + let name = trim_dot_ghost(name).to_string(); + + if is_regex_name(&name) { + let regex = regex(&format!("^{}$", &name))?; + Ok(Self { + string: name, + regex: Some(regex), + }) + } else { + Ok(Self { + string: name, + regex: None, + }) + } + } +} + +impl std::cmp::PartialEq for PluginName { + fn eq(&self, other: &Self) -> bool { + self.string == other.string + } +} + +impl std::cmp::Eq for PluginName {} + +impl std::cmp::PartialOrd for PluginName { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl std::cmp::Ord for PluginName { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.string.cmp(&other.string) + } +} + +impl std::hash::Hash for PluginName { + fn hash(&self, state: &mut H) { + self.string.hash(state); + } +} + +pub(crate) fn trim_dot_ghost(string: &str) -> &str { + if iends_with_ascii(string, GHOST_FILE_EXTENSION) { + &string[..(string.len() - 6)] + } else { + string + } +} + +pub(crate) fn iends_with_ascii(string: &str, suffix: &str) -> bool { + // as_bytes().into_iter() is faster than bytes(). + string.len() >= suffix.len() + && string + .as_bytes() + .iter() + .rev() + .zip(suffix.as_bytes().iter().rev()) + .all(|(string_byte, suffix_byte)| string_byte.eq_ignore_ascii_case(suffix_byte)) +} + +fn is_regex_name(name: &str) -> bool { + name.contains(|c| ":\\*?|".chars().any(|n| c == n)) +} + +fn merge_vecs(target: &mut Vec, source: &[T]) { + let initial_target_len = target.len(); + for element in source { + if !target[..initial_target_len].contains(element) { + target.push(element.clone()) + } + } +} + +impl TryFrom<&MarkedYaml> for PluginMetadata { + type Error = GeneralError; + + fn try_from(value: &MarkedYaml) -> Result { + let hash = get_as_hash(value, YamlObjectType::PluginMetadata)?; + + let name = get_required_string_value( + value.span.start, + hash, + "name", + YamlObjectType::PluginMetadata, + )?; + let name = match PluginName::new(name) { + Ok(n) => n, + Err(e) => { + return Err(YamlParseError::new( + value.span.start, + format!("Invalid regex in \"name\" key: {}", e), + ) + .into()); + } + }; + + let group = get_string_value(hash, "group", YamlObjectType::PluginMetadata)?; + + let load_after = get_vec::(hash, "after")?; + let requirements = get_vec::(hash, "req")?; + let incompatibilities = get_vec::(hash, "inc")?; + let messages = get_vec::(hash, "msg")?; + let tags = get_vec::(hash, "tag")?; + let dirty_info = get_vec::(hash, "dirty")?; + let clean_info = get_vec::(hash, "clean")?; + let locations = get_vec::(hash, "url")?; + + Ok(PluginMetadata { + name, + group: group.map(|g| g.to_string()), + load_after, + requirements, + incompatibilities, + messages, + dirty_info, + clean_info, + tags, + locations, + }) + } +} + +fn get_vec<'a, T: TryFrom<&'a MarkedYaml, Error = GeneralError>>( + hash: &'a saphyr::AnnotatedHash, + key: &str, +) -> Result, GeneralError> +where + GeneralError: From<>::Error>, +{ + get_as_slice(hash, key, YamlObjectType::PluginMetadata)? + .iter() + .map(|e| T::try_from(e)) + .collect::, _>>() +} diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs new file mode 100644 index 00000000..5bde1e38 --- /dev/null +++ b/src/metadata/tag.rs @@ -0,0 +1,106 @@ +use std::str::FromStr; + +use loot_condition_interpreter::Expression; +use saphyr::YamlData; + +use super::yaml::{YamlObjectType, get_required_string_value, get_string_value}; +use crate::error::{GeneralError, YamlParseError}; + +/// Represents whether a Bash Tag suggestion is for addition or removal. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum TagSuggestion { + #[default] + Addition, + Removal, +} + +/// Represents a Bash Tag suggestion for a plugin. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Tag { + name: String, + suggestion: TagSuggestion, + condition: Option, +} + +impl Tag { + /// Create a [Tag] suggestion for the given tag name. + #[must_use] + pub fn new(name: String, suggestion: TagSuggestion) -> Self { + Self { + name, + suggestion, + condition: None, + } + } + + /// Set the condition string. + #[must_use] + pub fn with_condition(mut self, condition: String) -> Self { + self.condition = Some(condition); + self + } + + /// Get the tag's name. + pub fn name(&self) -> &str { + &self.name + } + + /// Get if the tag should be added. + pub fn is_addition(&self) -> bool { + self.suggestion == TagSuggestion::Addition + } + + /// Get the condition string. + pub fn condition(&self) -> Option<&str> { + self.condition.as_deref() + } +} + +impl TryFrom<&saphyr::MarkedYaml> for Tag { + type Error = GeneralError; + + fn try_from(value: &saphyr::MarkedYaml) -> Result { + match &value.data { + YamlData::String(s) => { + let (name, suggestion) = name_and_suggestion(s); + Ok(Tag { + name, + suggestion, + condition: None, + }) + } + YamlData::Hash(h) => { + let name = + get_required_string_value(value.span.start, h, "name", YamlObjectType::Tag)?; + + let condition = match get_string_value(h, "condition", YamlObjectType::Tag)? { + Some(n) => { + Expression::from_str(n)?; + Some(n.to_string()) + } + None => None, + }; + + let (name, suggestion) = name_and_suggestion(name); + Ok(Tag { + name, + suggestion, + condition, + }) + } + _ => Err(YamlParseError::new( + value.span.start, + "'tag' object must be a map or string".into(), + ) + .into()), + } + } +} + +fn name_and_suggestion(value: &str) -> (String, TagSuggestion) { + if let Some(name) = value.strip_prefix("-") { + (name.to_string(), TagSuggestion::Removal) + } else { + (value.to_string(), TagSuggestion::Addition) + } +} diff --git a/src/metadata/yaml.rs b/src/metadata/yaml.rs new file mode 100644 index 00000000..e7feca4f --- /dev/null +++ b/src/metadata/yaml.rs @@ -0,0 +1,167 @@ +use saphyr::{AnnotatedArray, AnnotatedHash, MarkedYaml, Marker, Yaml, YamlData}; + +use crate::error::{GeneralError, YamlParseError}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum YamlObjectType { + File, + Group, + Location, + Message, + MessageContent, + PluginCleaningData, + PluginMetadata, + Tag, + MetadataDocument, +} + +impl std::fmt::Display for YamlObjectType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + YamlObjectType::File => write!(f, "file"), + YamlObjectType::Group => write!(f, "group"), + YamlObjectType::Location => write!(f, "location"), + YamlObjectType::Message => write!(f, "message"), + YamlObjectType::MessageContent => write!(f, "message content"), + YamlObjectType::PluginCleaningData => write!(f, "plugin cleaning data"), + YamlObjectType::PluginMetadata => write!(f, "plugin metadata"), + YamlObjectType::Tag => write!(f, "tag"), + YamlObjectType::MetadataDocument => write!(f, "metadata document"), + } + } +} + +pub fn to_yaml(yaml: &MarkedYaml) -> Yaml { + match &yaml.data { + saphyr::YamlData::Real(v) => Yaml::Real(v.clone()), + saphyr::YamlData::Integer(v) => Yaml::Integer(*v), + saphyr::YamlData::String(v) => Yaml::String(v.clone()), + saphyr::YamlData::Boolean(v) => Yaml::Boolean(*v), + saphyr::YamlData::Array(v) => Yaml::Array(to_array(v)), + saphyr::YamlData::Hash(v) => Yaml::Hash(to_hash(v)), + saphyr::YamlData::Alias(v) => Yaml::Alias(*v), + saphyr::YamlData::Null => Yaml::Null, + saphyr::YamlData::BadValue => Yaml::BadValue, + } +} + +fn to_array(array: &AnnotatedArray) -> saphyr::Array { + array.iter().map(to_yaml).collect() +} + +fn to_hash(hash: &AnnotatedHash) -> saphyr::Hash { + hash.iter() + .map(|(key, value)| (to_yaml(key), to_yaml(value))) + .collect() +} + +pub fn as_string_node(value: &str) -> MarkedYaml { + MarkedYaml { + span: saphyr_parser::Span::default(), + data: YamlData::String(value.into()), + } +} + +pub fn get_string_value<'a>( + hash: &'a AnnotatedHash, + key: &str, + yaml_type: YamlObjectType, +) -> Result, YamlParseError> { + match hash.get(&as_string_node(key)) { + Some(n) => match n.data.as_str() { + Some(n) => Ok(Some(n)), + None => Err(YamlParseError::new( + n.span.start, + format!("'{}' key in '{}' map is not a string", key, yaml_type), + )), + }, + None => Ok(None), + } +} + +pub fn get_required_string_value<'a>( + marker: Marker, + hash: &'a AnnotatedHash, + key: &str, + yaml_type: YamlObjectType, +) -> Result<&'a str, YamlParseError> { + match get_string_value(hash, key, yaml_type)? { + Some(n) => Ok(n), + None => Err(YamlParseError::missing_key(marker, key, yaml_type)), + } +} + +pub fn get_strings_vec_value<'a>( + hash: &'a AnnotatedHash, + key: &str, + yaml_type: YamlObjectType, +) -> Result, YamlParseError> { + match hash.get(&as_string_node(key)) { + Some(n) => match n.data.as_vec() { + Some(n) => n + .iter() + .map(|e| match e.data.as_str() { + Some(s) => Ok(s), + None => Err(YamlParseError::new( + e.span.start, + "Element in list is not a string".into(), + )), + }) + .collect::, _>>(), + None => Err(YamlParseError::new( + n.span.start, + format!("'{}' key in '{}' map is not a list", key, yaml_type), + )), + }, + None => Ok(Vec::new()), + } +} + +pub fn get_as_hash( + value: &MarkedYaml, + yaml_type: YamlObjectType, +) -> Result<&AnnotatedHash, YamlParseError> { + match value.data.as_hash() { + Some(h) => Ok(h), + None => Err(YamlParseError::new( + value.span.start, + format!("'{}' object must be a map", yaml_type), + )), + } +} + +pub fn get_u32_value( + hash: &AnnotatedHash, + key: &str, + yaml_type: YamlObjectType, +) -> Result, GeneralError> { + match hash.get(&as_string_node(key)) { + Some(n) => match n.data.as_i64() { + Some(n) => Ok(Some(n.try_into()?)), + None => Err(YamlParseError::new( + n.span.start, + format!("'{}' key in '{}' map is not a string", key, yaml_type), + ) + .into()), + }, + None => Ok(None), + } +} + +pub fn get_as_slice<'a>( + hash: &'a saphyr::AnnotatedHash, + key: &str, + yaml_type: YamlObjectType, +) -> Result<&'a [MarkedYaml], YamlParseError> { + if let Some(value) = hash.get(&as_string_node(key)) { + match value.data.as_vec() { + Some(n) => Ok(n.as_slice()), + None => Err(YamlParseError::new( + value.span.start, + format!("'{}' key in '{}' map is not an array", key, yaml_type), + )), + } + } else { + Ok(&[]) + } +} diff --git a/src/plugin.rs b/src/plugin.rs new file mode 100644 index 00000000..9a8be83c --- /dev/null +++ b/src/plugin.rs @@ -0,0 +1,453 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::File, + hash::Hasher, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +use esplugin::ParseOptions; +use fancy_regex::Regex; + +use crate::{ + GameType, + archive::{assets_in_archives, find_associated_archives}, + error::{GeneralError, InvalidArgumentError}, + game::GameCache, + regex, +}; + +static VERSION_REGEXES: LazyLock> = LazyLock::new(|| { + /* The string below matches the range of version strings supported by + Pseudosem v1.0.1, excluding space separators, as they make version + extraction from inside sentences very tricky and have not been + seen "in the wild". */ + let pseudosem_regex_str = r"(\d+(?:\.\d+)+(?:[-._:]?[A-Za-z0-9]+)*)(?!,)"; + + Box::new([ + /* The string below matches timestamps that use forwardslashes for date + separators. However, Pseudosem v1.0.1 will only compare the first + two digits as it does not recognise forwardslashes as separators. */ + regex(r"(\d{1,2}/\d{1,2}/\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})") + .expect("Hardcoded version timestamp regex should be valid"), + regex(&(String::from(r"version:?\s") + pseudosem_regex_str)) + .expect("Hardcoded version-prefixed pseudosem version regex should be valid"), + regex(&(String::from(r"(?:^|v|\s)") + pseudosem_regex_str)) + .expect("Hardcoded pseudosem version regex should be valid"), + /* The string below matches a number containing one or more + digits found at the start of the search string or preceded by + 'v' or 'version:. */ + regex(r"(?:^|v|version:\s*)(\d+)") + .expect("Hardcoded prefixed version number regex should be valid"), + ]) +}); + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(crate) enum LoadScope { + HeaderOnly, + WholePlugin, +} + +impl std::fmt::Display for LoadScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LoadScope::HeaderOnly => write!(f, "plugin header"), + LoadScope::WholePlugin => write!(f, "whole plugin"), + } + } +} + +/// Represents a plugin file that has been loaded. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Plugin { + name: String, + plugin: Option, + game_type: GameType, + crc: Option, + version: Option, + tags: Vec, + archive_paths: Vec, + archive_assets: BTreeMap>, +} + +impl Plugin { + pub(crate) fn new( + game_type: GameType, + game_cache: &GameCache, + plugin_path: &Path, + load_scope: LoadScope, + ) -> Result { + let name = name_string(plugin_path)?; + + let (parse_options, crc) = if load_scope == LoadScope::HeaderOnly { + (ParseOptions::header_only(), None) + } else { + let crc = calculate_crc(plugin_path)?; + (ParseOptions::whole_plugin(), Some(crc)) + }; + + let mut version = None; + let mut tags = Vec::new(); + let mut archive_paths = Vec::new(); + let mut archive_assets = BTreeMap::new(); + let plugin = + if game_type != GameType::OpenMW || !has_ascii_extension(plugin_path, "omwscripts") { + let mut plugin = esplugin::Plugin::new(game_type.into(), plugin_path); + plugin.parse_file(parse_options)?; + + if let Some(description) = plugin.description()? { + tags = extract_bash_tags(&description); + version = extract_version(&description)?; + } + + archive_paths = find_associated_archives(game_type, game_cache, plugin_path); + + archive_assets = assets_in_archives(&archive_paths); + + Some(plugin) + } else { + None + }; + + Ok(Self { + name, + plugin, + game_type, + crc, + version, + tags, + archive_paths, + archive_assets, + }) + } + + /// Get the plugin's filename. + /// + /// If the plugin was ghosted when it was loaded, this filename will be + /// without the .ghost suffix, unless the game is OpenMW, in which case + /// ghosted plugins are not supported. + pub fn name(&self) -> &str { + &self.name + } + + /// Get the value of the version field in the `HEDR` subrecord of the + /// plugin's `TES4` record. + /// + /// Returns `None` if the `TES4` record does not exist (e.g. for Morrowind + /// and OpenMW) or if the `HEDR` subrecord could not be found, of if the + /// version field's value was `NaN`. + pub fn header_version(&self) -> Option { + self.plugin.as_ref().and_then(|p| p.header_version()) + } + + /// Get the plugin's version number from its description field. + /// + /// The description field may not contain a version number, or libloot may + /// be unable to detect it. The description field parsing may fail to + /// extract the version number correctly, though it functions correctly in + /// all known cases. + pub fn version(&self) -> Option<&str> { + self.version.as_deref() + } + + /// Get the plugin's masters. + pub fn masters(&self) -> Result, GeneralError> { + self.plugin + .as_ref() + .map(|p| p.masters().map_err(Into::into)) + .unwrap_or_else(|| Ok(Vec::new())) + } + + /// Get any Bash Tags found in the plugin's description field. + pub fn bash_tags(&self) -> &[String] { + &self.tags + } + + /// Get the plugin's CRC-32 checksum. + /// + /// This will be `None` if the plugin is not fully loaded. + pub fn crc(&self) -> Option { + self.crc + } + + /// Check if the plugin is a master plugin. + /// + /// What causes a plugin to be a master plugin varies by game, but is + /// usually indicated by the plugin having its master flag set and/or by its + /// file extension. However, OpenMW uses neither for determining plugins' + /// load order so all OpenMW plugins are treated as non-masters. + /// + /// The term "master" is potentially confusing: a plugin A may not be a + /// *master plugin*, but may still be a *master of* another plugin by being + /// listed as such in that plugin's header record. Master plugins are + /// sometimes referred to as *master files* or simply *masters*, while the + /// other meaning is always referenced in relation to another plugin. + pub fn is_master(&self) -> bool { + if self.game_type == GameType::OpenMW { + false + } else { + self.plugin + .as_ref() + .map(|p| p.is_master_file()) + .unwrap_or(false) + } + } + + /// Check if the plugin is a light plugin. + pub fn is_light_plugin(&self) -> bool { + self.plugin + .as_ref() + .map(|p| p.is_light_plugin()) + .unwrap_or(false) + } + + /// Check if the plugin is a medium plugin. + pub fn is_medium_plugin(&self) -> bool { + self.plugin + .as_ref() + .map(|p| p.is_medium_plugin()) + .unwrap_or(false) + } + + /// Check if the plugin is an update plugin. + pub fn is_update_plugin(&self) -> bool { + self.plugin + .as_ref() + .map(|p| p.is_update_plugin()) + .unwrap_or(false) + } + + /// Check if the plugin is a blueprint plugin. + pub fn is_blueprint_plugin(&self) -> bool { + self.plugin + .as_ref() + .map(|p| p.is_blueprint_plugin()) + .unwrap_or(false) + } + + /// Check if the plugin is or would be valid as a light plugin. + pub fn is_valid_as_light_plugin(&self) -> Result { + self.plugin + .as_ref() + .map(|p| p.is_valid_as_light_plugin().map_err(Into::into)) + .unwrap_or(Ok(false)) + } + + /// Check if the plugin is or would be valid as a medium plugin. + pub fn is_valid_as_medium_plugin(&self) -> Result { + self.plugin + .as_ref() + .map(|p| p.is_valid_as_medium_plugin().map_err(Into::into)) + .unwrap_or(Ok(false)) + } + + /// Check if the plugin is or would be valid as an update plugin. + pub fn is_valid_as_update_plugin(&self) -> Result { + self.plugin + .as_ref() + .map(|p| p.is_valid_as_update_plugin().map_err(Into::into)) + .unwrap_or(Ok(false)) + } + + /// Check if the plugin contains any records other than its `TES3`/`TES4` + /// header. + pub fn is_empty(&self) -> bool { + self.plugin + .as_ref() + .and_then(|p| p.record_and_group_count()) + .unwrap_or(0) + == 0 + } + + /// Check if the plugin loads an archive (BSA/BA2 depending on the game). + pub fn loads_archive(&self) -> bool { + !self.archive_paths.is_empty() + } + + /// Check if two plugins contain a record with the same ID. + /// + /// FormIDs are compared for all games apart from Morrowind, which doesn't + /// have FormIDs and so has other identifying data compared. + pub fn do_records_overlap(&self, plugin: &Plugin) -> Result { + if let (Some(plugin), Some(other_plugin)) = (&self.plugin, &plugin.plugin) { + plugin.overlaps_with(other_plugin).map_err(Into::into) + } else { + Ok(false) + } + } + + pub(crate) fn override_record_count(&self) -> Result { + self.plugin + .as_ref() + .map(|p| p.count_override_records().map_err(Into::into)) + .unwrap_or(Ok(0)) + } + + pub(crate) fn asset_count(&self) -> usize { + self.archive_assets.values().fold(0, |acc, e| acc + e.len()) + } + + pub(crate) fn do_assets_overlap(&self, plugin: &Plugin) -> bool { + let mut assets_iter = self.archive_assets.iter(); + let mut other_assets_iter = plugin.archive_assets.iter(); + + let mut assets = assets_iter.next(); + let mut other_assets = other_assets_iter.next(); + while let (Some((folder, files)), Some((other_folder, other_files))) = + (assets, other_assets) + { + if folder < other_folder { + assets = assets_iter.next(); + } else if folder > other_folder { + other_assets = other_assets_iter.next(); + } else if files.intersection(other_files).next().is_some() { + return true; + } else { + // The folder hashes are equal but they don't contain any of the same + // file hashes, move on to the next folder. It doesn't matter which + // iterator gets incremented. + assets = assets_iter.next(); + } + } + + false + } + + pub(crate) fn resolve_record_ids( + &mut self, + plugins_metadata: &[esplugin::PluginMetadata], + ) -> Result<(), esplugin::Error> { + if let Some(plugin) = &mut self.plugin { + plugin.resolve_record_ids(plugins_metadata) + } else { + Ok(()) + } + } +} + +pub(crate) fn is_valid_plugin(game_type: GameType, plugin_path: &Path) -> bool { + if game_type == GameType::OpenMW && has_ascii_extension(plugin_path, "omwscripts") { + true + } else if has_plugin_file_extension(game_type, plugin_path) { + esplugin::Plugin::is_valid(game_type.into(), plugin_path, ParseOptions::header_only()) + } else { + log::debug!( + "The file \"{}\" is not a valid plugin", + plugin_path.display() + ); + false + } +} + +fn has_plugin_file_extension(game_type: GameType, plugin_path: &Path) -> bool { + let extension = if game_type != GameType::OpenMW && has_ascii_extension(plugin_path, "ghost") { + plugin_path + .file_stem() + .and_then(|s| Path::new(s).extension()) + } else { + plugin_path.extension() + }; + + if let Some(extension) = extension { + if extension.eq_ignore_ascii_case("esp") + || extension.eq_ignore_ascii_case("esm") + || (game_type == GameType::OpenMW + && (extension.eq_ignore_ascii_case("omwaddon") + || extension.eq_ignore_ascii_case("omwgame") + || extension.eq_ignore_ascii_case("omwscripts"))) + { + true + } else { + matches!( + game_type, + GameType::FO4 + | GameType::FO4VR + | GameType::TES5SE + | GameType::TES5VR + | GameType::Starfield + ) && extension.eq_ignore_ascii_case("esl") + } + } else { + false + } +} + +pub(crate) fn has_ascii_extension(path: &Path, extension: &str) -> bool { + path.extension() + .map(|e| e.eq_ignore_ascii_case(extension)) + .unwrap_or(false) +} + +pub(crate) fn plugins_metadata( + plugins: &[Plugin], +) -> Result, esplugin::Error> { + let esplugins: Vec<_> = plugins.iter().filter_map(|p| p.plugin.as_ref()).collect(); + esplugin::plugins_metadata(&esplugins) +} + +fn name_string(path: &Path) -> Result { + match path.file_name() { + Some(f) => match f.to_str() { + Some(f) => Ok(f.to_string()), + None => Err(InvalidArgumentError { + message: format!("The path \"{:?}\" has a non-Unicode filename", path), + }), + }, + None => Err(InvalidArgumentError { + message: format!("The path \"{}\" has no filename", path.display()), + }), + } +} + +fn calculate_crc(path: &Path) -> std::io::Result { + let file = File::open(path)?; + let mut reader = BufReader::new(file); + let mut hasher = crc32fast::Hasher::new(); + + let mut buffer = reader.fill_buf()?; + while !buffer.is_empty() { + hasher.write(buffer); + let length = buffer.len(); + reader.consume(length); + + buffer = reader.fill_buf()?; + } + + Ok(hasher.finalize()) +} + +fn extract_bash_tags(description: &str) -> Vec { + let bash_tags_opener = "{{BASH:"; + + if let Some(mut start_pos) = description.find(bash_tags_opener) { + start_pos += bash_tags_opener.len(); + + if let Some(end_pos) = description[start_pos..].find("}}") { + return description[start_pos..start_pos + end_pos] + .split(",") + .map(|s| s.trim().to_string()) + .collect(); + } + } + Vec::new() +} + +fn extract_version(description: &str) -> Result, Box> { + for regex in &*VERSION_REGEXES { + let version = regex + .captures(description)? + .iter() + .flat_map(|captures| captures.iter()) + .flatten() + .map(|m| m.as_str().trim()) + .find(|v| !v.is_empty()) + .map(|v| v.to_string()); + + if version.is_some() { + return Ok(version); + } + } + + Ok(None) +} diff --git a/src/sorting/dfs.rs b/src/sorting/dfs.rs new file mode 100644 index 00000000..c5236b09 --- /dev/null +++ b/src/sorting/dfs.rs @@ -0,0 +1,210 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +use petgraph::{ + Graph, + graph::{EdgeReference, NodeIndex}, + visit::EdgeRef, +}; + +use crate::{EdgeType, Vertex}; + +pub trait DfsVisitor<'a> { + fn visit_tree_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>); + + fn visit_forward_or_cross_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>); + + fn visit_back_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>); + + fn discover_node(&mut self, node_index: NodeIndex); + + fn finish_node(&mut self, node_index: NodeIndex); +} + +pub trait BidirBfsVisitor { + fn visit_forward_bfs_edge(&mut self, source: NodeIndex, target: NodeIndex); + + fn visit_reverse_bfs_edge(&mut self, source: NodeIndex, target: NodeIndex); + + fn visit_intersection_node(&mut self, node: NodeIndex); +} + +pub fn bidirectional_bfs( + graph: &Graph, + from_index: NodeIndex, + to_index: NodeIndex, + visitor: &mut impl BidirBfsVisitor, +) -> bool { + let mut forward_queue = VecDeque::from([from_index]); + let mut reverse_queue = VecDeque::from([to_index]); + let mut forward_visited = HashSet::from([from_index]); + let mut reverse_visited = HashSet::from([to_index]); + + while let (Some(forward_current), Some(reverse_current)) = + (forward_queue.pop_front(), reverse_queue.pop_front()) + { + if forward_current == to_index || reverse_visited.contains(&forward_current) { + visitor.visit_intersection_node(forward_current); + return true; + } + + for adjacent in graph.neighbors(forward_current) { + if !forward_visited.contains(&adjacent) { + visitor.visit_forward_bfs_edge(forward_current, adjacent); + + forward_visited.insert(adjacent); + forward_queue.push_back(adjacent); + } + } + + if reverse_current == from_index || forward_visited.contains(&reverse_current) { + visitor.visit_intersection_node(reverse_current); + return true; + } + + for adjacent in graph.neighbors_directed(reverse_current, petgraph::Direction::Incoming) { + if !reverse_visited.contains(&adjacent) { + visitor.visit_reverse_bfs_edge(adjacent, reverse_current); + + reverse_visited.insert(adjacent); + reverse_queue.push_back(adjacent); + } + } + } + + false +} + +// Petgraph has APIs for performing depth-first searches, but they don't give any information about the current edge, only its source and target nodes, which is a problem if the same pair of nodes can have multiple edges between them with different weights. As such, implement it myself. +pub fn find_cycle( + graph: &Graph, + node_mapper: impl FnMut(&N) -> String, +) -> Option> { + let mut cycle_detector = CycleDetector::new(graph, node_mapper); + + let mut colour_map = HashMap::new(); + + for node_index in graph.node_indices() { + depth_first_search(graph, &mut colour_map, node_index, &mut cycle_detector); + + if cycle_detector.found_cycle() { + return cycle_detector.into_cycle_path(); + } + } + + None +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum Colour { + #[default] + White, + Grey, + Black, +} + +pub fn depth_first_search<'a, N>( + graph: &'a Graph, + colour_map: &mut HashMap, + start_node_index: NodeIndex, + visitor: &mut impl DfsVisitor<'a>, +) { + let mut stack = vec![(start_node_index, graph.edges(start_node_index))]; + + colour_map.insert(start_node_index, Colour::Grey); + visitor.discover_node(start_node_index); + + while let Some((current, unprocessed_edges)) = stack.last_mut() { + if let Some(edge) = unprocessed_edges.next() { + let target = edge.target(); + + match colour_map.get(&target).unwrap_or(&Colour::White) { + Colour::White => { + visitor.visit_tree_edge(edge); + + colour_map.insert(target, Colour::Grey); + visitor.discover_node(target); + + stack.push((target, graph.edges(target))); + } + Colour::Grey => visitor.visit_back_edge(edge), + Colour::Black => visitor.visit_forward_or_cross_edge(edge), + } + } else { + colour_map.insert(*current, Colour::Black); + visitor.finish_node(*current); + + stack.pop(); + } + } +} + +#[derive(Clone, Debug)] +struct CycleDetector<'a, N, F: FnMut(&N) -> String> { + graph: &'a Graph, + get_node_name: F, + trail: Vec, + found_cycle: bool, +} + +impl<'a, N, F: FnMut(&N) -> String> CycleDetector<'a, N, F> { + fn new(graph: &'a Graph, get_node_name: F) -> Self { + CycleDetector { + graph, + get_node_name, + trail: Vec::new(), + found_cycle: false, + } + } + + fn found_cycle(&self) -> bool { + self.found_cycle + } + + fn into_cycle_path(self) -> Option> { + if self.found_cycle { + Some(self.trail) + } else { + None + } + } +} + +impl<'a, N, F: FnMut(&N) -> String> DfsVisitor<'a> for CycleDetector<'a, N, F> { + fn visit_tree_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>) { + if self.found_cycle { + return; + } + + let source = edge_ref.source(); + let name = (self.get_node_name)(&self.graph[source]); + let edge_type = *edge_ref.weight(); + + let vertex = Vertex::new(name).with_out_edge_type(edge_type); + + self.trail.push(vertex); + } + + fn visit_forward_or_cross_edge(&mut self, _: EdgeReference<'a, EdgeType>) {} + + fn visit_back_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>) { + self.visit_tree_edge(edge_ref); + + let target_name = (self.get_node_name)(&self.graph[edge_ref.target()]); + + if let Some(pos) = self.trail.iter().position(|v| v.name() == target_name) { + self.trail.drain(..pos); + self.found_cycle = true; + } else { + log::error!("The target of a back edge cannot be found in the current visitor trail"); + } + } + + fn discover_node(&mut self, _: NodeIndex) {} + + fn finish_node(&mut self, _: NodeIndex) { + if !self.found_cycle { + self.trail.pop(); + } + } +} diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs new file mode 100644 index 00000000..9c7e0a66 --- /dev/null +++ b/src/sorting/groups.rs @@ -0,0 +1,271 @@ +use std::{cmp::Reverse, collections::HashMap}; + +use petgraph::{Graph, algo::bellman_ford, graph::NodeIndex}; + +use crate::{ + EdgeType, Vertex, + error::{ + CyclicInteractionError, GeneralError, InvalidArgumentError, PathfindingError, + UndefinedGroupError, + }, + metadata::Group, + sorting::dfs::find_cycle, +}; + +use super::dfs::{DfsVisitor, depth_first_search}; + +pub type GroupsGraph = Graph; + +pub fn build_groups_graph( + masterlist_groups: &[Group], + userlist_groups: &[Group], +) -> Result { + let masterlist_groups = sorted_by_name(masterlist_groups); + let userlist_groups = sorted_by_name(userlist_groups); + + let mut graph = GroupsGraph::new(); + let mut group_nodes: HashMap<&str, NodeIndex> = HashMap::new(); + + log::trace!("Adding masterlist groups to groups graph..."); + add_groups( + &mut graph, + &mut group_nodes, + &masterlist_groups, + EdgeType::MasterlistLoadAfter, + )?; + + log::trace!("Adding user groups to groups graph..."); + add_groups( + &mut graph, + &mut group_nodes, + &userlist_groups, + EdgeType::UserLoadAfter, + )?; + + if let Some(cycle) = find_cycle(&graph, |node| node.clone()) { + Err(CyclicInteractionError { cycle }.into()) + } else { + Ok(graph) + } +} + +fn sorted_by_name(groups: &[Group]) -> Vec<&Group> { + let mut groups: Vec<_> = groups.iter().collect(); + groups.sort_by_key(|a| a.name()); + + groups +} + +fn add_groups<'a>( + graph: &mut GroupsGraph, + group_nodes: &mut HashMap<&'a str, NodeIndex>, + groups: &[&'a Group], + edge_type: EdgeType, +) -> Result<(), UndefinedGroupError> { + for group in groups { + let key = group.name(); + if !group_nodes.contains_key(key) { + let node_index = graph.add_node(group.name().to_string()); + group_nodes.insert(key, node_index); + } + } + + for group in groups { + if log::log_enabled!(log::Level::Trace) { + log::trace!( + "Group \"{}\" directly loads after groups \"{}\"", + group.name(), + group.after_groups().join(", ") + ); + } + + let node_index = group_nodes + .get(group.name()) + .expect("Group node should have just been added"); + + for other_group_name in sorted_clone(group.after_groups()) { + if let Some(other_index) = group_nodes.get(other_group_name) { + graph.update_edge(*other_index, *node_index, edge_type); + } else { + return Err(UndefinedGroupError::new(other_group_name.to_string())); + } + } + } + + Ok(()) +} + +fn sorted_clone(strings: &[String]) -> Vec<&str> { + let mut strings: Vec<_> = strings.iter().map(|s| s.as_str()).collect(); + strings.sort(); + + strings +} + +pub fn find_path( + graph: &GroupsGraph, + from_group_name: &str, + to_group_name: &str, +) -> Result, GeneralError> { + let float_graph: Graph<&String, f32> = graph.map( + |_, n| n, + |_, e| { + if *e == EdgeType::UserLoadAfter { + // A very small number so that user edges are practically always preferred. + -1000000.0 + } else { + 1.0 + } + }, + ); + + let from_vertex = find_node_by_weight(graph, from_group_name)?; + let to_vertex = find_node_by_weight(graph, to_group_name)?; + + let paths = bellman_ford(&float_graph, from_vertex).map_err(|_| InvalidArgumentError { + message: "Groups graph contains a negative cycle".into(), + })?; + + let mut path = vec![Vertex::new(graph[to_vertex].clone())]; + let mut current = to_vertex; + while current != from_vertex { + let preceding_vertex = match paths.predecessors.get(current.index()) { + Some(Some(v)) => v, + Some(None) => { + log::info!( + "No path found from {} to {} while looking for path to {}", + graph[from_vertex], + graph[current], + graph[to_vertex] + ); + return Ok(Vec::new()); + } + _ => { + return Err(PathfindingError::new( + "Could not find a node in the graph while pathfinding".into(), + ) + .into()); + } + }; + + if *preceding_vertex == current { + log::error!( + "Unreachable vertex {} encountered while looking for vertex {}", + graph[current], + graph[from_vertex] + ); + return Ok(Vec::new()); + } + + let edge = match graph.find_edge(*preceding_vertex, current) { + Some(e) => e, + None => { + return Err(PathfindingError::new(format!( + "Could not find edge from \"{}\" to \"{}\"", + graph[*preceding_vertex], graph[current] + )) + .into()); + } + }; + + let vertex = Vertex::new(graph[*preceding_vertex].clone()).with_out_edge_type(graph[edge]); + path.push(vertex); + + current = *preceding_vertex; + } + + path.reverse(); + + Ok(path) +} + +fn find_node_by_weight( + graph: &Graph, + weight: &str, +) -> Result { + match graph + .node_indices() + .find(|i| graph.node_weight(*i).map(|w| *w == weight).unwrap_or(false)) + { + Some(n) => Ok(n), + None => { + log::error!("Can't find group with name {}", weight); + Err(InvalidArgumentError { + message: format!("Can't find group with name {}", weight), + }) + } + } +} + +/// Sort the group vertices so that root vertices come first, in order of +/// decreasing path length, but otherwise preserving the existing +/// (lexicographical) ordering. +pub fn sorted_group_nodes(graph: &GroupsGraph) -> Vec { + let mut nodes: Vec<(NodeIndex, bool, usize)> = graph + .node_indices() + .map(|n| { + if is_root_node(graph, n) { + let mut visitor = GroupsPathLengthVisitor::new(); + + depth_first_search(graph, &mut HashMap::new(), n, &mut visitor); + + (n, true, visitor.max_path_length()) + } else { + (n, false, 0) + } + }) + .collect(); + + nodes.sort_by_key(|a| Reverse((a.1, a.2))); + + nodes.into_iter().map(|n| n.0).collect() +} + +fn is_root_node(graph: &GroupsGraph, node_index: NodeIndex) -> bool { + graph + .neighbors_directed(node_index, petgraph::Direction::Incoming) + .next() + .is_none() +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +struct GroupsPathLengthVisitor { + max_path_length: usize, + current_path_length: usize, +} + +impl GroupsPathLengthVisitor { + fn new() -> Self { + Default::default() + } + + fn max_path_length(&self) -> usize { + self.max_path_length + } +} + +impl<'a> DfsVisitor<'a> for GroupsPathLengthVisitor { + fn visit_tree_edge(&mut self, _: petgraph::graph::EdgeReference<'a, EdgeType>) {} + + fn visit_forward_or_cross_edge(&mut self, _: petgraph::graph::EdgeReference<'a, EdgeType>) {} + + fn visit_back_edge(&mut self, _: petgraph::graph::EdgeReference<'a, EdgeType>) {} + + fn discover_node(&mut self, _: NodeIndex) { + self.current_path_length += 1; + if self.current_path_length > self.max_path_length { + self.max_path_length = self.current_path_length + } + } + + fn finish_node(&mut self, _: NodeIndex) { + self.current_path_length -= 1; + } +} + +pub fn get_default_group_node(graph: &GroupsGraph) -> Result { + graph + .node_indices() + .find(|n| graph[*n] == Group::DEFAULT_NAME) + .ok_or_else(|| UndefinedGroupError::new(Group::DEFAULT_NAME.to_string())) +} diff --git a/src/sorting/mod.rs b/src/sorting/mod.rs new file mode 100644 index 00000000..e4739c42 --- /dev/null +++ b/src/sorting/mod.rs @@ -0,0 +1,5 @@ +mod dfs; +pub mod groups; +pub mod plugins; +mod validate; +pub mod vertex; diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs new file mode 100644 index 00000000..6e8f1b82 --- /dev/null +++ b/src/sorting/plugins.rs @@ -0,0 +1,1135 @@ +use std::{ + collections::{HashMap, HashSet}, + rc::Rc, +}; + +use log::log_enabled; +use petgraph::{ + Graph, + graph::{EdgeReference, NodeIndex}, + visit::EdgeRef, +}; + +use crate::{ + EdgeType, Plugin, + error::{CyclicInteractionError, GeneralError, SortingLogicError, UndefinedGroupError}, + metadata::{File, Group, PluginMetadata}, + sorting::groups::{get_default_group_node, sorted_group_nodes}, +}; + +use super::{ + dfs::{BidirBfsVisitor, DfsVisitor, bidirectional_bfs, depth_first_search, find_cycle}, + groups::GroupsGraph, + validate::{validate_plugin_groups, validate_specific_and_hardcoded_edges}, +}; + +#[derive(Debug)] +pub struct PluginSortingData<'a> { + plugin: &'a Plugin, + pub(super) is_master: bool, + override_record_count: usize, + + load_order_index: usize, + + pub(super) group: String, + group_is_user_metadata: bool, + pub(super) masterlist_load_after: Vec, + pub(super) user_load_after: Vec, + pub(super) masterlist_req: Vec, + pub(super) user_req: Vec, +} + +impl<'a> PluginSortingData<'a> { + pub fn new( + plugin: &'a Plugin, + masterlist_metadata: Option<&PluginMetadata>, + user_metadata: Option<&PluginMetadata>, + load_order_index: usize, + ) -> Result { + let override_record_count = plugin.override_record_count()?; + + Ok(Self { + plugin, + is_master: plugin.is_master(), + override_record_count, + load_order_index, + group: user_metadata + .and_then(|m| m.group()) + .or_else(|| masterlist_metadata.and_then(|m| m.group())) + .unwrap_or(Group::DEFAULT_NAME) + .to_string(), + group_is_user_metadata: user_metadata.and_then(|m| m.group()).is_some(), + masterlist_load_after: masterlist_metadata + .map(|m| to_filenames(m.load_after_files())) + .unwrap_or_default(), + user_load_after: user_metadata + .map(|m| to_filenames(m.load_after_files())) + .unwrap_or_default(), + masterlist_req: masterlist_metadata + .map(|m| to_filenames(m.requirements())) + .unwrap_or_default(), + user_req: user_metadata + .map(|m| to_filenames(m.requirements())) + .unwrap_or_default(), + }) + } + + pub(super) fn name(&self) -> &str { + self.plugin.name() + } + + fn is_blueprint_master(&self) -> bool { + self.is_master && self.plugin.is_blueprint_plugin() + } + + fn asset_count(&self) -> usize { + self.plugin.asset_count() + } + + pub(super) fn masters(&self) -> Result, GeneralError> { + self.plugin.masters() + } + + fn do_records_overlap(&self, other: &PluginSortingData) -> Result { + self.plugin.do_records_overlap(other.plugin) + } + + fn do_assets_overlap(&self, other: &PluginSortingData) -> bool { + self.plugin.do_assets_overlap(other.plugin) + } +} + +fn to_filenames(files: &[File]) -> Vec { + files + .iter() + .map(|f| f.name().as_str().to_string()) + .collect() +} + +type InnerPluginsGraph<'a> = Graph>, EdgeType>; + +#[derive(Debug, Default)] +struct PluginsGraph<'a> { + // Put the sorting data in Rc so that it can be held onto while mutating the graph. + inner: InnerPluginsGraph<'a>, + paths_cache: HashMap>, +} + +impl<'a> PluginsGraph<'a> { + fn new() -> Self { + PluginsGraph::default() + } + + fn add_node(&mut self, plugin: PluginSortingData<'a>) { + self.inner.add_node(Rc::new(plugin)); + } + + fn add_edge(&mut self, from: NodeIndex, to: NodeIndex, edge_type: EdgeType) { + if self.is_path_cached(from, to) { + return; + } + + log::debug!( + "Adding {} edge from \"{}\" to \"{}\".", + edge_type, + self.inner[from].name(), + self.inner[to].name() + ); + + self.inner.add_edge(from, to, edge_type); + + self.cache_path(from, to); + } + + fn node_indices(&self) -> petgraph::graph::NodeIndices { + self.inner.node_indices() + } + + fn add_specific_edges(&mut self) -> Result<(), GeneralError> { + log::trace!("Adding edges based on plugin data and non-group metadata..."); + + let mut node_index_iter = self.node_indices(); + while let Some(node_index) = node_index_iter.next() { + let plugin = Rc::clone(&self[node_index]); + + // This loop should have no effect now that master-flagged and + // non-master-flagged plugins are sorted separately, but is kept + // as a safety net. + for other_node_index in node_index_iter.clone() { + let other_plugin = &self[other_node_index]; + + if plugin.is_master == other_plugin.is_master { + continue; + } + + if other_plugin.is_master { + self.add_edge(other_node_index, node_index, EdgeType::MasterFlag); + } else { + self.add_edge(node_index, other_node_index, EdgeType::MasterFlag); + } + } + + for master in plugin.masters()? { + if let Some(other_node_index) = self.node_index_by_name(&master) { + self.add_edge(other_node_index, node_index, EdgeType::Master); + } + } + + for file in &plugin.masterlist_req { + if let Some(other_node_index) = self.node_index_by_name(file) { + self.add_edge( + other_node_index, + node_index, + EdgeType::MasterlistRequirement, + ); + } + } + + for file in &plugin.user_req { + if let Some(other_node_index) = self.node_index_by_name(file) { + self.add_edge(other_node_index, node_index, EdgeType::UserRequirement); + } + } + + for file in &plugin.masterlist_load_after { + if let Some(other_node_index) = self.node_index_by_name(file) { + self.add_edge(other_node_index, node_index, EdgeType::MasterlistLoadAfter); + } + } + + for file in &plugin.user_load_after { + if let Some(other_node_index) = self.node_index_by_name(file) { + self.add_edge(other_node_index, node_index, EdgeType::UserLoadAfter); + } + } + } + + Ok(()) + } + + fn add_early_loading_plugin_edges(&mut self, early_loading_plugins: &[String]) { + log::trace!( + "Adding edges for implicitly active plugins and plugins with hardcoded positions..." + ); + + if early_loading_plugins.is_empty() { + return; + } + + let mut early_loading_plugin_indices: HashMap<&str, NodeIndex> = HashMap::new(); + let mut other_plugin_indices = Vec::new(); + for node_index in self.node_indices() { + let plugin = &self[node_index]; + if let Some(p) = early_loading_plugins + .iter() + .find(|e| unicase::eq(e.as_str(), plugin.name())) + { + early_loading_plugin_indices.insert(p.as_str(), node_index); + } else { + other_plugin_indices.push(node_index); + } + } + + if early_loading_plugin_indices.is_empty() { + return; + } + + let mut last_early_loading_plugin_index = None; + for window in early_loading_plugins.windows(2) { + if let [from, to] = window { + let from_index = early_loading_plugin_indices.get(from.as_str()); + let to_index = early_loading_plugin_indices.get(to.as_str()); + + if to_index.is_some() { + last_early_loading_plugin_index = to_index; + } else if from_index.is_some() { + last_early_loading_plugin_index = from_index; + } + + if let (Some(from_index), Some(to_index)) = (from_index, to_index) { + self.add_edge(*from_index, *to_index, EdgeType::Hardcoded); + } + } + } + + if let Some(from_index) = last_early_loading_plugin_index { + for to_index in other_plugin_indices { + self.add_edge(*from_index, to_index, EdgeType::Hardcoded); + } + } + } + + fn check_for_cycles(&mut self) -> Result<(), CyclicInteractionError> { + if let Some(cycle) = find_cycle(&self.inner, |node| node.name().to_string()) { + Err(CyclicInteractionError::new(cycle)) + } else { + Ok(()) + } + } + + fn add_group_edges(&mut self, groups_graph: &GroupsGraph) -> Result<(), UndefinedGroupError> { + log::trace!("Adding edges based on plugin group memberships..."); + + // First build a map from groups to the plugins in those groups. + let plugins_in_groups = get_plugins_in_groups(&self.inner); + + // Get the default group's vertex because it's needed for the DFSes. + let default_group_node = get_default_group_node(groups_graph)?; + + // Keep a record of which vertices have already been fully explored to avoid + // adding edges from their plugins more than once. + let mut finished_nodes = HashSet::new(); + // Now loop over the vertices in the groups graph. + // The vertex sort order prioritises resolving potential cycles in + // favour of earlier-loading groups. It does not guarantee that the + // longest paths will be walked first, because a root vertex may be in + // more than one path and the vertex sort order here does not influence + // which path the DFS takes. + for group_node in sorted_group_nodes(groups_graph) { + // Run a DFS from each vertex in the group graph, adding edges except from + // plugins in the default group. This could be run only on the root + // vertices, except that the DFS only visits each vertex once, so a branch + // and merge inside a given root's DAG would result in plugins from one of + // the branches not being carried forwards past the point at which the + // branches merge. + let mut visitor = GroupsPathVisitor::new( + self, + groups_graph, + &plugins_in_groups, + &mut finished_nodes, + Some(default_group_node), + ); + + depth_first_search(groups_graph, &mut HashMap::new(), group_node, &mut visitor); + } + + // Now do one last DFS starting from the default group and not ignoring its + // plugins. + let mut visitor = GroupsPathVisitor::new( + self, + groups_graph, + &plugins_in_groups, + &mut finished_nodes, + None, + ); + + depth_first_search( + groups_graph, + &mut HashMap::new(), + default_group_node, + &mut visitor, + ); + + Ok(()) + } + + fn add_overlap_edges(&mut self) -> Result<(), GeneralError> { + log::trace!("Adding edges for overlapping plugins..."); + + let mut node_index_iter = self.node_indices(); + while let Some(node_index) = node_index_iter.next() { + let plugin = Rc::clone(&self[node_index]); + let plugin_asset_count = plugin.asset_count(); + + if plugin.override_record_count == 0 && plugin_asset_count == 0 { + log::debug!( + "Skipping vertex for \"{}\": the plugin contains no override records and loads no assets", + plugin.name() + ); + continue; + } + + // This loop should have no effect now that master-flagged and + // non-master-flagged plugins are sorted separately, but is kept + // as a safety net. + for other_node_index in node_index_iter.clone() { + let other_plugin = &self[other_node_index]; + + // Don't add an edge between these two plugins if one already + // exists (only check direct edges and not paths for efficiency). + if self.inner.contains_edge(node_index, other_node_index) + || self.inner.contains_edge(other_node_index, node_index) + { + continue; + } + + // Two plugins can overlap due to overriding the same records, + // or by loading assets from BSAs/BA2s that have the same path. + // If records overlap, the plugin that overrides more records + // should load earlier. + // If assets overlap, the plugin that loads more assets should + // load earlier. + // If two plugins have overlapping records and assets and one + // overrides more records but loads fewer assets than the other, + // the fact it overrides more records should take precedence + // (records are more significant than assets). + // I.e. if two plugins don't have overlapping records, check their + // assets, otherwise only check their assets if their override + // record counts are equal. + + let outer_plugin_loads_first; + let edge_type; + + if plugin.override_record_count == other_plugin.override_record_count + || !plugin.do_records_overlap(other_plugin)? + { + // Records don't overlap, or override the same number of records, + // check assets. + // No records overlap, check assets. + let other_plugin_asset_count = other_plugin.asset_count(); + if plugin_asset_count == other_plugin_asset_count + || !plugin.do_assets_overlap(other_plugin) + { + // Assets don't overlap or both plugins load the same number of + // assets, don't add an edge. + continue; + } else { + outer_plugin_loads_first = plugin_asset_count > other_plugin_asset_count; + edge_type = EdgeType::AssetOverlap; + } + } else { + // Records overlap and override different numbers of records. + // Load this plugin first if it overrides more records. + outer_plugin_loads_first = + plugin.override_record_count > other_plugin.override_record_count; + edge_type = EdgeType::RecordOverlap + } + + let (from_index, to_index) = if outer_plugin_loads_first { + (node_index, other_node_index) + } else { + (other_node_index, node_index) + }; + + if !self.is_path_cached(from_index, to_index) { + if !self.path_exists(to_index, from_index) { + self.add_edge(from_index, to_index, edge_type); + } else { + log::debug!( + "Skipping {} edge from \"{}\" to \"{}\" as it would create a cycle.", + edge_type, + self[from_index].name(), + self[to_index].name() + ); + } + } + } + } + + Ok(()) + } + + fn add_tie_break_edges(&mut self) -> Result<(), SortingLogicError> { + log::trace!("Adding edges to break ties between plugins..."); + + // In order for the sort to be performed stably, there must be only one + // possible result. This can be enforced by adding edges between all vertices + // that aren't already linked. Use existing load order to decide the direction + // of these edges, and only add an edge if it won't cause a cycle. + // + // Brute-forcing this by adding an edge between every pair of vertices + // (unless it would cause a cycle) works but scales terribly, as before each + // edge is added a bidirectional search needs to be done for a path in the + // other direction (to detect a potential cycle). This search takes more time + // as the number of edges involves increases, so adding tie breaks gets slower + // as they get added. + // + // The point of adding these tie breaks is to ensure that there's a + // Hamiltonian path through the graph and therefore only one possible + // topological sort result. + // + // Instead of trying to brute-force this, iterate over the graph's vertices in + // their existing load order (each vertex represents a plugin, so the two + // terms are used interchangeably), and add an edge going from the earlier to + // the later for each consecutive pair of plugins (e.g. for [A, B, C], add + // edges A->B, B->C), unless adding the edge would cause a cycle. If sorting + // has made no changes to the load order, then it'll be possible to add all + // those edges and only N - 1 bidirectional searches will be needed when there + // are N vertices. + // + // If it's not possible to add such an edge for a pair of plugins [A, B], that + // means that LOOT thinks A needs to load after B, i.e. the sorted load order + // will be different. If the existing path between A and B is B -> C -> D -> A + // then walk back through the load order to find a plugin that B will load + // after without causing a cycle, and add an edge going from that plugin to B. + // Then do the same for each subsequent plugin in the path between A and B so + // that every plugin in the existing load order until A has a path to each of + // the plugins in the path from B to A, and that there is only one path that + // will visit all plugins until A. Keep a record of this path, because that's + // the load order that needs to be walked back through whenever the existing + // relative positions of plugins can't be used (if the existing load order was + // used, the process would miss out on plugins introduced in previous backward + // walks, and so you'd end up with multiple paths that don't necessarily touch + // all plugins). + + // Storage for the load order as it evolves. + let mut new_load_order: Vec = Vec::new(); + + // Holds nodes that have already been put into new_load_order. + let mut processed_nodes = HashSet::new(); + + // First get the graph vertices and sort them into the current load order. + let mut nodes: Vec<_> = self.node_indices().collect(); + nodes.sort_by_key(|a| self[*a].load_order_index); + + for window in nodes.windows(2) { + let (current, next) = match window { + [a, b] => (*a, *b), + _ => panic!("Window length should be 2, got {}", window.len()), + }; + + match self.find_path(next, current)? { + None => { + // There's no path from next to current, so it's OK to add + // an edge going in the other direction, meaning that next can + // load after current. + self.add_edge(current, next, EdgeType::TieBreak); + + // next now loads after current. If current hasn't + // already been added to the load order, append it. It might have already + // been added if it was part of a path going from next and + // current in a previous loop (i.e. for different values of + // next and current). + if !processed_nodes.contains(¤t) { + new_load_order.push(current); + processed_nodes.insert(current); + + log::debug!( + "The plugin \"{}\" loads at the end of the new load order so far.", + self[current].name() + ); + } else if new_load_order.last() != Some(¤t) { + log::trace!( + "The plugin \"{}\" has already been processed and is not the last in the new load order, determining where to place \"{}\".", + self[current].name(), + self[next].name() + ); + + // If current was already processed and not the last vertex + // in new_load_order then next also needs to be pinned in place or + // it may not have a defined position relative to all the + // vertices following current in new_load_order undefined, so + // there wouldn't be a unique path through them. + // + // We're using new_load_order.rend() as the last iterator position because + // we don't know current's position. + self.pin_node_position(&mut processed_nodes, &mut new_load_order, next, 0); + } + } + Some(mut path_from_next_node) => { + // Each vertex in pathFromNextVertex (besides the last, which is + // currentVertex) needs to be positioned relative to a vertex that has + // already been iterated over (i.e. in what begins as the old load + // order) so that there is a single path between all vertices. + // + // If currentVertex is the first in the iteration order, then + // nextVertex is simply the earliest known plugin in the new load order + // so far. + if nodes.first() == Some(¤t) { + // Record the path as the start of the new load order. + // Don't need to add any edges because there's nothing for nextVertex + // to load after at this point. + if log_enabled!(log::Level::Debug) { + log::debug!( + "The path ends with the first plugin checked, treating the following path as the start of the load order: {}", + path_to_string(&self.inner, &path_from_next_node) + ); + } + + for node in path_from_next_node { + new_load_order.push(node); + processed_nodes.insert(node); + } + continue; + } + + // Ignore the last vertex in the path because it's currentVertex and + // will just be appended to the load order so doesn't need special + // processing. + path_from_next_node.pop(); + + // This is used to keep track of when to stop searching for a + // vertex to load after, as a minor optimisation. + let mut range_start = 0; + + // Iterate over the path going from nextVertex towards currentVertex + // (which got chopped off the end of the path). + for node in path_from_next_node { + // Update reverseEndIt to reduce the scope of the search in the + // next loop (if there is one). + range_start = self.pin_node_position( + &mut processed_nodes, + &mut new_load_order, + node, + range_start, + ); + } + + // Add current to the end of the new_load_order - do this after processing the other vertices in the path so that involves less work. + if !processed_nodes.contains(¤t) { + new_load_order.push(current); + processed_nodes.insert(current); + } + } + } + } + + Ok(()) + } + + fn pin_node_position( + &mut self, + processed_nodes: &mut HashSet, + new_load_order: &mut Vec, + node_index: NodeIndex, + range_start: usize, + ) -> usize { + // It's possible that this vertex has already been pinned in place, + // e.g. because it was visited earlier in the old load order or + // as part of a path that was processed. In that case just skip it. + if processed_nodes.contains(&node_index) { + log::debug!( + "The plugin \"{}\" has already been processed, skipping it.", + self[node_index].name() + ); + return range_start; + } + + // Otherwise, this vertex needs to be inserted into the path that includes + // all other vertices that have been processed so far. This can be done by + // searching for the last vertex in the "new load order" path for which + // there is not a path going from this vertex to that vertex. I.e. find the + // last plugin that this one can load after. We could instead find the last + // plugin that this one *must* load after, but it turns out that's + // significantly slower because it generally involves going further back + // along the "new load order" path. + let previous_node_position = new_load_order + .get(range_start..) + .expect("last_pos is within the new_load_order vec") + .iter() + .rposition(|ni| !self.path_exists(node_index, *ni)); + + // Add an edge going from the found vertex to this one, in case it + // doesn't exist (we only know there's not a path going the other way). + if let Some(preceding_node_index) = + previous_node_position.and_then(|p| new_load_order.get(p)) + { + self.add_edge(*preceding_node_index, node_index, EdgeType::TieBreak); + } + + // Insert position is just after the found vertex, and a forward iterator + // points to the element one after the element pointed to by the + // corresponding reverse iterator. + let insert_position = previous_node_position.map(|i| i + 1).unwrap_or(0); + + // Add an edge going from this vertex to the next one in the "new load + // order" path, in case there isn't already one. + if let Some(following_node_index) = new_load_order.get(insert_position) { + self.add_edge(node_index, *following_node_index, EdgeType::TieBreak); + } + + // Now update newLoadOrder with the vertex's new position. + new_load_order.insert(insert_position, node_index); + + if log_enabled!(log::Level::Debug) { + if let Some(next_node_index) = new_load_order.get(insert_position + 1) { + log::debug!( + "The plugin \"{}\" loads before \"{}\" in the new load order.", + self[node_index].name(), + self[*next_node_index].name() + ); + } else { + log::debug!( + "The plugin \"{}\" loads at the end of the new load order so far.", + self[node_index].name() + ); + } + } + + // Return a new value for reverseEndIt, pointing to the newly + // inserted vertex, as if it was not the last vertex in a path + // being processed the next vertex in the path by definition + // cannot load before this one, so we can save an unnecessary + // check by using this new reverseEndIt value when pinning the + // next vertex. + insert_position + 1 + } + + fn topological_sort(&self) -> Result, petgraph::algo::Cycle> { + petgraph::algo::toposort(&self.inner, None) + } + + fn is_hamiltonian_path(&mut self, path: &[NodeIndex]) -> Option<(NodeIndex, NodeIndex)> { + log::trace!("Checking uniqueness of path through plugin graph..."); + + path.windows(2).find_map(|slice| match slice { + [a, b] => self.inner.contains_edge(*a, *b).then_some((*a, *b)), + _ => None, + }) + } + + fn cache_path(&mut self, from: NodeIndex, to: NodeIndex) { + self.paths_cache.entry(from).or_default().insert(to); + } + + fn is_path_cached(&self, from: NodeIndex, to: NodeIndex) -> bool { + self.paths_cache + .get(&from) + .map(|s| s.contains(&to)) + .unwrap_or(false) + } + + fn node_index_by_name(&self, name: &str) -> Option { + self.node_indices() + .find(|i| unicase::eq(self[*i].name(), name)) + } + + fn path_exists(&mut self, from: NodeIndex, to: NodeIndex) -> bool { + if self.is_path_cached(from, to) { + return true; + } + + let mut visitor = PathCacher::new(&mut self.paths_cache, from, to); + + bidirectional_bfs(&self.inner, from, to, &mut visitor) + } + + fn find_path( + &mut self, + from: NodeIndex, + to: NodeIndex, + ) -> Result>, SortingLogicError> { + let mut path_finder = PathFinder::new(&self.inner, &mut self.paths_cache, from, to); + + if bidirectional_bfs(&self.inner, from, to, &mut path_finder) { + path_finder.path() + } else { + Ok(None) + } + } +} + +impl<'a> std::ops::Index for PluginsGraph<'a> { + type Output = Rc>; + + fn index(&self, index: NodeIndex) -> &Self::Output { + &self.inner[index] + } +} + +pub fn sort_plugins( + mut plugins_sorting_data: Vec, + groups_graph: &GroupsGraph, + early_loading_plugins: &[String], +) -> Result, GeneralError> { + if plugins_sorting_data.is_empty() { + return Ok(Vec::new()); + } + + validate_plugin_groups(&plugins_sorting_data, groups_graph)?; + + // Sort the plugins according to the lexicographical order of their names. + // This ensures a consistent iteration order for vertices given the same + // input data. The vertex iteration order can affect what edges get added + // and so the final sorting result, so consistency is important. This order + // needs to be independent of any state (e.g. the current load order) so + // that sorting and applying the result doesn't then produce a different + // result if you then sort again. + plugins_sorting_data.sort_by(|a, b| a.name().cmp(b.name())); + + // Some parts of sorting are O(N^2) for N plugins, and master flags cause + // O(M*N) edges to be added for M masters and N non-masters, which can be + // two thirds of all edges added. The cost of each bidirectional search + // scales with the number of edges, so reducing edges makes searches + // faster. + // Similarly, blueprint plugins load after all others. + // As such, sort plugins using three separate graphs for masters, + // non-masters and blueprint plugins. This means that any edges that go from a + // non-master to a master are effectively ignored, so won't cause cyclic + // interaction errors. Edges going the other way will also effectively be + // ignored, but that shouldn't have a noticeable impact. + let (masters, non_masters): (Vec<_>, Vec<_>) = + plugins_sorting_data.into_iter().partition(|p| p.is_master); + + let (masters, blueprint_masters): (Vec<_>, Vec<_>) = + masters.into_iter().partition(|p| !p.is_blueprint_master()); + + validate_specific_and_hardcoded_edges( + &masters, + &blueprint_masters, + &non_masters, + early_loading_plugins, + )?; + + let mut masters_load_order = + sort_plugins_partition(masters, groups_graph, early_loading_plugins)?; + + let blueprint_masters_load_order = + sort_plugins_partition(blueprint_masters, groups_graph, early_loading_plugins)?; + + let non_masters_load_order = + sort_plugins_partition(non_masters, groups_graph, early_loading_plugins)?; + + masters_load_order.extend(non_masters_load_order); + masters_load_order.extend(blueprint_masters_load_order); + + Ok(masters_load_order) +} + +fn sort_plugins_partition( + plugins_sorting_data: Vec, + groups_graph: &GroupsGraph, + early_loading_plugins: &[String], +) -> Result, GeneralError> { + let mut graph = PluginsGraph::new(); + + for plugin in plugins_sorting_data { + graph.add_node(plugin); + } + + graph.add_specific_edges()?; + graph.add_early_loading_plugin_edges(early_loading_plugins); + + // Check for cycles now because from this point on edges are only added if + // they don't cause cycles, and adding overlap and tie-break edges is + // relatively slow, so checking now provides quicker feedback if there is an + // issue. + graph.check_for_cycles()?; + + graph.add_group_edges(groups_graph)?; + graph.add_overlap_edges()?; + graph.add_tie_break_edges()?; + + // Check for cycles again, just in case there's a bug that lets some occur. + // The check doesn't take a significant amount of time. + graph.check_for_cycles()?; + + let sorted_nodes = graph.topological_sort()?; + + if let Some((first, second)) = graph.is_hamiltonian_path(&sorted_nodes) { + log::error!( + "The path is not unique. No edge exists between {} and {}", + graph[first].name(), + graph[second].name() + ); + } + + let sorted_plugin_names = sorted_nodes + .into_iter() + .map(|i| graph[i].name().to_string()) + .collect(); + + Ok(sorted_plugin_names) +} + +fn path_to_string(graph: &InnerPluginsGraph, path: &[NodeIndex]) -> String { + path.iter() + .map(|i| graph[*i].name()) + .collect::>() + .join(", ") +} + +#[derive(Debug)] +struct PathFinder<'a, 'b> { + graph: &'a InnerPluginsGraph<'b>, + cache: &'a mut HashMap>, + from_node_index: NodeIndex, + to_node_index: NodeIndex, + forward_parents: HashMap, + reverse_children: HashMap, + intersection_node: Option, +} + +impl<'a, 'b> PathFinder<'a, 'b> { + fn new( + graph: &'a InnerPluginsGraph<'b>, + cache: &'a mut HashMap>, + from_node_index: NodeIndex, + to_node_index: NodeIndex, + ) -> Self { + Self { + graph, + cache, + from_node_index, + to_node_index, + forward_parents: HashMap::new(), + reverse_children: HashMap::new(), + intersection_node: None, + } + } + + fn cache_path(&mut self, from: NodeIndex, to: NodeIndex) { + self.cache.entry(from).or_default().insert(to); + } + + fn path(&self) -> Result>, SortingLogicError> { + match self.intersection_node { + None => Ok(None), + Some(intersection_node) => { + let mut current_node = intersection_node; + let mut path = vec![current_node]; + + while current_node != self.from_node_index { + if let Some(next) = self.forward_parents.get(¤t_node) { + path.push(*next); + current_node = *next; + } else { + log::error!( + "Could not find parent vertex of {}. Path so far is {}", + self.graph[current_node].name(), + path_to_string(self.graph, &path) + ); + return Err(SortingLogicError::new(format!( + "Could not find parent vertex of {}", + self.graph[current_node].name() + ))); + } + } + + // The path currently runs backwards, so reverse it. + path.reverse(); + + current_node = intersection_node; + + while current_node != self.to_node_index { + if let Some(next) = self.reverse_children.get(¤t_node) { + path.push(*next); + current_node = *next; + } else { + log::error!( + "Could not find child vertex of {}. Path so far is {}", + self.graph[current_node].name(), + path_to_string(self.graph, &path) + ); + return Err(SortingLogicError::new(format!( + "Could not find child vertex of {}", + self.graph[current_node].name() + ))); + } + } + + Ok(Some(path)) + } + } + } +} + +impl BidirBfsVisitor for PathFinder<'_, '_> { + fn visit_forward_bfs_edge(&mut self, source: NodeIndex, target: NodeIndex) { + self.cache_path(self.from_node_index, target); + + self.forward_parents.insert(target, source); + } + + fn visit_reverse_bfs_edge(&mut self, source: NodeIndex, target: NodeIndex) { + self.cache_path(source, self.to_node_index); + + self.reverse_children.insert(source, target); + } + + fn visit_intersection_node(&mut self, node: NodeIndex) { + self.intersection_node = Some(node) + } +} + +#[derive(Debug)] +struct PathCacher<'a> { + cache: &'a mut HashMap>, + from_node_index: NodeIndex, + to_node_index: NodeIndex, +} + +fn get_plugins_in_groups(graph: &InnerPluginsGraph) -> HashMap> { + let mut plugins_in_groups: HashMap> = HashMap::new(); + + for node in graph.node_indices() { + let group_name = graph[node].group.clone(); + + plugins_in_groups.entry(group_name).or_default().push(node); + } + + plugins_in_groups +} + +impl<'a> PathCacher<'a> { + fn new( + cache: &'a mut HashMap>, + from_node_index: NodeIndex, + to_node_index: NodeIndex, + ) -> Self { + Self { + cache, + from_node_index, + to_node_index, + } + } + + fn cache_path(&mut self, from: NodeIndex, to: NodeIndex) { + self.cache.entry(from).or_default().insert(to); + } +} + +impl BidirBfsVisitor for PathCacher<'_> { + fn visit_forward_bfs_edge(&mut self, _: NodeIndex, target: NodeIndex) { + self.cache_path(self.from_node_index, target); + } + + fn visit_reverse_bfs_edge(&mut self, source: NodeIndex, _: NodeIndex) { + self.cache_path(source, self.to_node_index); + } + + fn visit_intersection_node(&mut self, _: NodeIndex) {} +} + +// Use type aliases to make intent clearer without the complications of introducing newtypes. +type PluginNodeIndex = NodeIndex; +type GroupNodeIndex = NodeIndex; + +struct GroupsPathVisitor<'a, 'b, 'c, 'd, 'e> { + plugins_graph: &'a mut PluginsGraph<'b>, + groups_graph: &'e GroupsGraph, + groups_plugins: &'c HashMap>, + finished_group_vertices: &'d mut HashSet, + group_node_to_ignore_as_source: Option, + edge_stack: Vec<(EdgeReference<'e, EdgeType>, &'c [PluginNodeIndex])>, + unfinishable_nodes: HashSet, +} + +impl<'a, 'b, 'c, 'd, 'e> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e> { + fn new( + plugins_graph: &'a mut PluginsGraph<'b>, + groups_graph: &'e GroupsGraph, + groups_plugins: &'c HashMap>, + finished_group_vertices: &'d mut HashSet, + group_node_to_ignore_as_source: Option, + ) -> Self { + Self { + plugins_graph, + groups_graph, + groups_plugins, + finished_group_vertices, + group_node_to_ignore_as_source, + edge_stack: Vec::new(), + unfinishable_nodes: HashSet::new(), + } + } + + fn should_ignore_source_node(&self, node_index: GroupNodeIndex) -> bool { + self.group_node_to_ignore_as_source == Some(node_index) + || self.finished_group_vertices.contains(&node_index) + } + + fn find_plugins_in_group(&self, node_index: GroupNodeIndex) -> &'c [PluginNodeIndex] { + self.groups_plugins + .get(&self.groups_graph[node_index]) + .map(|v| v.as_slice()) + .unwrap_or_default() + } + + fn add_plugin_graph_edges( + &mut self, + edge_stack_index: usize, + target_plugins: &[PluginNodeIndex], + ) { + let from_plugins = self.edge_stack[edge_stack_index].1; + + let path_involves_user_metadata = self.edge_stack[edge_stack_index..] + .iter() + .any(|p| *p.0.weight() == EdgeType::UserLoadAfter); + + for from_plugin in from_plugins { + self.add_edges_from_plugin(*from_plugin, target_plugins, path_involves_user_metadata); + } + } + + fn add_edges_from_plugin( + &mut self, + from_plugin: PluginNodeIndex, + to_plugins: &[PluginNodeIndex], + path_involves_user_metadata: bool, + ) { + if to_plugins.is_empty() { + return; + } + + for to_plugin in to_plugins { + if !self.plugins_graph.is_path_cached(from_plugin, *to_plugin) { + if !self.plugins_graph.path_exists(*to_plugin, from_plugin) { + let involves_user_metadata = path_involves_user_metadata + || self.plugins_graph[from_plugin].group_is_user_metadata + || self.plugins_graph[*to_plugin].group_is_user_metadata; + + let edge_type = if involves_user_metadata { + EdgeType::UserGroup + } else { + EdgeType::MasterlistGroup + }; + + self.plugins_graph + .add_edge(from_plugin, *to_plugin, edge_type); + } else { + log::debug!( + "Skipping group edge from \"{}\" to \"{}\" as it would create a cycle.", + self.plugins_graph[from_plugin].name(), + self.plugins_graph[*to_plugin].name() + ); + } + } + } + } +} + +impl<'e> DfsVisitor<'e> for GroupsPathVisitor<'_, '_, '_, '_, 'e> { + fn visit_tree_edge(&mut self, edge_ref: EdgeReference<'e, EdgeType>) { + let source = edge_ref.source(); + let target = edge_ref.target(); + + // Add the edge to the stack so that its providence can be taken into + // account when adding edges from this source group and previous groups' + // plugins. + // Also record the plugins in the edge's source group, unless the source + // group should be ignored (e.g. because the visitor has been configured + // to ignore the default group's plugins as sources). + let edge_plugins = if self.should_ignore_source_node(source) { + &[] + } else { + self.find_plugins_in_group(source) + }; + self.edge_stack.push((edge_ref, edge_plugins)); + + // Find the plugins in the target group. + let target_plugins = self.find_plugins_in_group(target); + + // Add edges going from all the plugins in the groups in the path being + // currently walked, to the plugins in the current target group's plugins. + for i in 0..self.edge_stack.len() { + self.add_plugin_graph_edges(i, target_plugins); + } + } + + fn visit_forward_or_cross_edge(&mut self, edge_ref: EdgeReference<'e, EdgeType>) { + // Mark the source vertex as unfinishable, because none of the plugins in + // in the path so far can have edges added to plugins past the target + // vertex. + self.unfinishable_nodes.insert(edge_ref.source()); + } + + fn visit_back_edge(&mut self, _: EdgeReference<'e, EdgeType>) {} + + fn discover_node(&mut self, _: GroupNodeIndex) {} + + fn finish_node(&mut self, node_index: GroupNodeIndex) { + // Now that this vertex's DFS-tree has been fully explored, mark it as + // finished so that it won't have edges added from its plugins again in a + // different DFS that uses the same finished vertices set. + if self.group_node_to_ignore_as_source != Some(node_index) + && !self.unfinishable_nodes.contains(&node_index) + { + self.finished_group_vertices.insert(node_index); + } + + // Since this vertex has been fully explored, pop the edge stack to remove + // the edge that has this vertex as its target. + self.edge_stack.pop(); + } +} diff --git a/src/sorting/validate.rs b/src/sorting/validate.rs new file mode 100644 index 00000000..27a04384 --- /dev/null +++ b/src/sorting/validate.rs @@ -0,0 +1,198 @@ +use std::collections::HashSet; + +use unicase::UniCase; + +use crate::{ + EdgeType, Vertex, + error::{CyclicInteractionError, GeneralError, UndefinedGroupError}, +}; + +use super::{groups::GroupsGraph, plugins::PluginSortingData}; + +pub fn validate_plugin_groups( + plugins_sorting_data: &[PluginSortingData<'_>], + groups_graph: &GroupsGraph, +) -> Result<(), UndefinedGroupError> { + let group_names: HashSet<&String> = groups_graph + .node_indices() + .map(|i| &groups_graph[i]) + .collect(); + + for plugin in plugins_sorting_data { + if !group_names.contains(&plugin.group) { + return Err(UndefinedGroupError::new(plugin.group.clone())); + } + } + + Ok(()) +} + +pub fn validate_specific_and_hardcoded_edges( + masters: &[PluginSortingData<'_>], + blueprint_masters: &[PluginSortingData<'_>], + non_masters: &[PluginSortingData<'_>], + early_loading_plugins: &[String], +) -> Result<(), GeneralError> { + log::trace!("Validating specific and early-loading plugin edges..."); + + let non_masters_set: HashSet> = + masters.iter().map(|p| UniCase::new(p.name())).collect(); + let blueprint_masters_set: HashSet> = blueprint_masters + .iter() + .map(|p| UniCase::new(p.name())) + .collect(); + + validate_masters(masters, &non_masters_set, &blueprint_masters_set)?; + + validate_non_masters(non_masters, &blueprint_masters_set)?; + + // There's at least one master, check that there are no hardcoded + // non-masters. + validate_early_loading_plugins(early_loading_plugins, masters, &non_masters_set)?; + + Ok(()) +} + +fn validate_masters( + masters: &[PluginSortingData<'_>], + non_masters: &HashSet>, + blueprint_masters: &HashSet>, +) -> Result<(), GeneralError> { + log::trace!( + "Validating specific and early-loading plugin edges for non-blueprint master files..." + ); + masters + .iter() + .try_for_each(|m| validate_plugin(m, non_masters, blueprint_masters)) +} + +fn validate_non_masters( + non_masters: &[PluginSortingData<'_>], + blueprint_masters: &HashSet>, +) -> Result<(), GeneralError> { + log::trace!("Validating specific and early-loading plugin edges for non-master files..."); + + // Pass an empty set of non-masters so that the non-masters don't get validated against themselves. + let empty_set = HashSet::new(); + + non_masters + .iter() + .try_for_each(|p| validate_plugin(p, &empty_set, blueprint_masters)) +} + +fn validate_plugin( + plugin: &PluginSortingData<'_>, + non_masters: &HashSet>, + blueprint_masters: &HashSet>, +) -> Result<(), GeneralError> { + for master in plugin.masters()? { + let key = UniCase::new(master.as_str()); + if non_masters.contains(&key) { + return Err(CyclicInteractionError::new(vec![ + Vertex::new(master).with_out_edge_type(EdgeType::Master), + Vertex::new(plugin.name().to_string()).with_out_edge_type(EdgeType::MasterFlag), + ]) + .into()); + } + + if blueprint_masters.contains(&key) { + // Log a warning instead of throwing an exception because the game will + // just ignore this master, and the issue can't be fixed without + // editing the plugin and the blueprint master may not actually have + // any of its records overridden. + let plugin_type = if plugin.is_master { + "master" + } else { + "non-master" + }; + log::warn!( + "The {} plugin \"{}\" has the blueprint master \"{}\" as one of its masters", + plugin_type, + plugin.name(), + master + ); + } + } + + validate_files( + &plugin.masterlist_req, + plugin.name(), + non_masters, + blueprint_masters, + EdgeType::MasterlistRequirement, + )?; + + validate_files( + &plugin.user_req, + plugin.name(), + non_masters, + blueprint_masters, + EdgeType::UserRequirement, + )?; + + validate_files( + &plugin.masterlist_load_after, + plugin.name(), + non_masters, + blueprint_masters, + EdgeType::MasterlistLoadAfter, + )?; + + validate_files( + &plugin.user_load_after, + plugin.name(), + non_masters, + blueprint_masters, + EdgeType::UserLoadAfter, + )?; + + Ok(()) +} + +fn validate_files( + files: &[String], + plugin_name: &str, + non_masters: &HashSet>, + blueprint_masters: &HashSet>, + edge_type: EdgeType, +) -> Result<(), CyclicInteractionError> { + for file in files { + let key = UniCase::new(file.as_str()); + if non_masters.contains(&key) { + return Err(CyclicInteractionError::new(vec![ + Vertex::new(file.clone()).with_out_edge_type(edge_type), + Vertex::new(plugin_name.to_string()).with_out_edge_type(EdgeType::MasterFlag), + ])); + } + + if blueprint_masters.contains(&key) { + return Err(CyclicInteractionError::new(vec![ + Vertex::new(file.clone()).with_out_edge_type(edge_type), + Vertex::new(plugin_name.to_string()).with_out_edge_type(EdgeType::BlueprintMaster), + ])); + } + } + + Ok(()) +} + +fn validate_early_loading_plugins( + early_loading_plugins: &[String], + masters: &[PluginSortingData<'_>], + non_masters: &HashSet>, +) -> Result<(), CyclicInteractionError> { + if let Some(master) = masters.first() { + for plugin in early_loading_plugins { + let key = UniCase::new(plugin.as_str()); + if non_masters.contains(&key) { + // Just report the cycle to the first master. + return Err(CyclicInteractionError::new(vec![ + Vertex::new(plugin.to_string()).with_out_edge_type(EdgeType::Hardcoded), + Vertex::new(master.name().to_string()).with_out_edge_type(EdgeType::MasterFlag), + ])); + } + } + } + + Ok(()) +} diff --git a/src/sorting/vertex.rs b/src/sorting/vertex.rs new file mode 100644 index 00000000..0c4b8f38 --- /dev/null +++ b/src/sorting/vertex.rs @@ -0,0 +1,75 @@ +/// An enum representing the different possible types of interactions between +/// plugins or groups. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum EdgeType { + Hardcoded, + MasterFlag, + Master, + MasterlistRequirement, + UserRequirement, + MasterlistLoadAfter, + UserLoadAfter, + MasterlistGroup, + UserGroup, + RecordOverlap, + AssetOverlap, + TieBreak, + BlueprintMaster, +} + +impl std::fmt::Display for EdgeType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EdgeType::Hardcoded => write!(f, "Hardcoded"), + EdgeType::MasterFlag => write!(f, "Master Flag"), + EdgeType::Master => write!(f, "Master"), + EdgeType::MasterlistRequirement => write!(f, "Masterlist Requirement"), + EdgeType::UserRequirement => write!(f, "User Requirement"), + EdgeType::MasterlistLoadAfter => write!(f, "Masterlist Load After"), + EdgeType::UserLoadAfter => write!(f, "User Load After"), + EdgeType::MasterlistGroup => write!(f, "Masterlist Group"), + EdgeType::UserGroup => write!(f, "User Group"), + EdgeType::RecordOverlap => write!(f, "Record Overlap"), + EdgeType::AssetOverlap => write!(f, "Asset Overlap"), + EdgeType::TieBreak => write!(f, "Tie Break"), + EdgeType::BlueprintMaster => write!(f, "Blueprint Master"), + } + } +} + +/// Represents a plugin or group vertex in a path, and the type of the edge to +/// the next vertex in the path if one exists. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Vertex { + name: String, + out_edge_type: Option, +} + +impl Vertex { + /// Construct a Vertex with the given name and no out edge. + #[must_use] + pub fn new(name: String) -> Self { + Self { + name, + ..Default::default() + } + } + + /// Set the type of the edge going from this vertex to the next in the path. + #[must_use] + pub fn with_out_edge_type(mut self, out_edge_type: EdgeType) -> Self { + self.out_edge_type = Some(out_edge_type); + self + } + + /// Get the name of the plugin or group that the vertex represents. + pub fn name(&self) -> &str { + &self.name + } + + /// Get the type of the edge going from this vertex to the next in the path. + pub fn out_edge_type(&self) -> Option { + self.out_edge_type + } +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 00000000..429749de --- /dev/null +++ b/src/version.rs @@ -0,0 +1,47 @@ +/// libloot's major version number. +pub const LIBLOOT_VERSION_MAJOR: u32 = parse_u32(env!("CARGO_PKG_VERSION_MAJOR")); + +/// libloot's minor version number. +pub const LIBLOOT_VERSION_MINOR: u32 = parse_u32(env!("CARGO_PKG_VERSION_MINOR")); + +/// libloot's patch version number. +pub const LIBLOOT_VERSION_PATCH: u32 = parse_u32(env!("CARGO_PKG_VERSION_PATCH")); + +/// Get the library version in the form "major.minor.patch". +pub fn libloot_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +/// Get the ID of the source control revision that libloot was built from. +pub fn libloot_revision() -> String { + libloot_revision_const().to_string() +} + +/// Checks whether the loaded API is compatible with the given version of the +/// API, abstracting API stability policy away from clients. The version +/// numbering used is major.minor.patch. +pub fn is_compatible(major: u32, minor: u32, _patch: u32) -> bool { + if major > 0 { + major == LIBLOOT_VERSION_MAJOR + } else { + minor == LIBLOOT_VERSION_MINOR + } +} + +const fn parse_u32(value: &str) -> u32 { + let mut acc = 0; + let mut i = 0; + while i < value.len() { + acc = acc * 10 + (value.as_bytes()[i] - b'0') as u32; + i += 1; + } + acc +} + +const fn libloot_revision_const() -> &'static str { + if let Some(s) = option_env!("LIBLOOT_REVISION") { + s + } else { + "unknown" + } +} From cc51f8b19a56753378684071c2ea4950a26a590f Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 4 Mar 2025 08:50:17 +0000 Subject: [PATCH 002/206] Rework error handling --- README.md | 3 +- src/archive/ba2.rs | 29 +- src/archive/bsa.rs | 63 ++-- src/archive/error.rs | 95 +++++ src/archive/mod.rs | 1 + src/archive/parse.rs | 33 +- src/database/conditions.rs | 108 ++++++ src/database/error.rs | 57 +++ src/{database.rs => database/mod.rs} | 221 +++-------- src/error.rs | 538 ++++++++++++--------------- src/game.rs | 150 ++++---- src/metadata/error.rs | 399 ++++++++++++++++++++ src/metadata/file.rs | 36 +- src/metadata/group.rs | 6 +- src/metadata/location.rs | 13 +- src/metadata/message.rs | 71 ++-- src/metadata/metadata_document.rs | 99 +++-- src/metadata/mod.rs | 2 + src/metadata/plugin_cleaning_data.rs | 12 +- src/metadata/plugin_metadata.rs | 30 +- src/metadata/tag.rs | 26 +- src/metadata/yaml.rs | 93 +++-- src/plugin/error.rs | 140 +++++++ src/{plugin.rs => plugin/mod.rs} | 72 ++-- src/sorting/error.rs | 293 +++++++++++++++ src/sorting/groups.rs | 42 +-- src/sorting/mod.rs | 1 + src/sorting/plugins.rs | 40 +- src/sorting/validate.rs | 10 +- 29 files changed, 1821 insertions(+), 862 deletions(-) create mode 100644 src/archive/error.rs create mode 100644 src/database/conditions.rs create mode 100644 src/database/error.rs rename src/{database.rs => database/mod.rs} (64%) create mode 100644 src/metadata/error.rs create mode 100644 src/plugin/error.rs rename src/{plugin.rs => plugin/mod.rs} (88%) create mode 100644 src/sorting/error.rs diff --git a/README.md b/README.md index 1e0c4f54..5dea8e08 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,7 @@ This is an **incomplete** and **experimental** reimplementation of [libloot](htt Currently complete: -- [x] Public API types and function declarations (excluding errors) -- [ ] Public API error types +- [x] Public API types and function declarations - [x] Public API doc comments - [x] Library versioning - [ ] Setting a logging callback diff --git a/src/archive/ba2.rs b/src/archive/ba2.rs index e7d575f0..908ee553 100644 --- a/src/archive/ba2.rs +++ b/src/archive/ba2.rs @@ -4,7 +4,7 @@ use std::{ io::{BufRead, Seek}, }; -use crate::error::{GeneralError, InvalidArgumentError}; +use super::error::ArchiveParsingError; use super::parse::{to_u32, to_u64}; @@ -23,7 +23,7 @@ struct Header { } impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { - type Error = InvalidArgumentError; + type Error = ArchiveParsingError; fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result { let header = Self { @@ -37,15 +37,15 @@ impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { // The header version is 1, 7 or 8 for Fallout 4 and 2 or 3 for Starfield. if !matches!(header.version, 1 | 2 | 3 | 7 | 8) { - return Err(InvalidArgumentError { - message: "BA2 file header version is invalid".into(), - }); + return Err(ArchiveParsingError::UnsupportedHeaderVersion( + header.version, + )); } if !matches!(header.archive_type, BA2_GENERAL_TYPE | BA2_TEXTURE_TYPE) { - return Err(InvalidArgumentError { - message: "BA2 file header archive type is invalid".into(), - }); + return Err(ArchiveParsingError::UnsupportedHeaderArchiveType( + header.archive_type, + )); } Ok(header) @@ -54,7 +54,7 @@ impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { pub(super) fn read_assets( mut reader: T, -) -> Result>, GeneralError> { +) -> Result>, ArchiveParsingError> { let mut header_buffer = [0; HEADER_SIZE - TYPE_ID.len()]; reader.read_exact(&mut header_buffer)?; @@ -84,13 +84,10 @@ pub(super) fn read_assets( let file_hashes: &mut BTreeSet = assets.entry(folder_hash).or_default(); if !file_hashes.insert(file_hash) { - return Err(InvalidArgumentError { - message: format!( - "Unexpected collision for file name hash {:x} in set for folder name hash {:x}", - file_hash, folder_hash - ), - } - .into()); + return Err(ArchiveParsingError::HashCollision { + folder_hash, + file_hash, + }); } } diff --git a/src/archive/bsa.rs b/src/archive/bsa.rs index 70910735..b9f84020 100644 --- a/src/archive/bsa.rs +++ b/src/archive/bsa.rs @@ -3,7 +3,7 @@ use std::{ io::BufRead, }; -use crate::error::{GeneralError, InvalidArgumentError}; +use super::error::ArchiveParsingError; use super::parse::{to_u32, to_u64, to_usize}; @@ -25,7 +25,7 @@ struct Header { } impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { - type Error = InvalidArgumentError; + type Error = ArchiveParsingError; fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result { let header = Self { @@ -40,19 +40,18 @@ impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { content_type_flags: to_u32(&value[28..]), }; - if header.records_offset != 36 { - return Err(InvalidArgumentError { - message: format!( - "BSA file has an invalid records offset value: {}", - header.records_offset - ), - }); + if header.records_offset + != HEADER_SIZE + .try_into() + .expect("header size can fit in a u32") + { + return Err(ArchiveParsingError::InvalidRecordsOffset( + header.records_offset, + )); } if (header.archive_flags & 0x40) != 0 { - return Err(InvalidArgumentError { - message: "BSA file uses big-endian numbers".into(), - }); + return Err(ArchiveParsingError::UsesBigEndianNumbers); } Ok(header) @@ -104,7 +103,7 @@ mod v105 { pub(super) fn read_assets( mut reader: T, -) -> Result>, GeneralError> { +) -> Result>, ArchiveParsingError> { let mut header_buffer = [0; HEADER_SIZE - TYPE_ID.len()]; reader.read_exact(&mut header_buffer)?; @@ -122,10 +121,9 @@ pub(super) fn read_assets( &header, v105::read_folder_record, ), - _ => Err(InvalidArgumentError { - message: format!("BSA file has an unrecognised version: {}", header.version), - } - .into()), + _ => Err(ArchiveParsingError::UnsupportedHeaderVersion( + header.version, + )), } } @@ -133,7 +131,7 @@ fn read_assets_with_header( mut reader: T, header: &Header, read_folder_record: impl Fn(&[u8]) -> FolderRecord, -) -> Result>, GeneralError> { +) -> Result>, ArchiveParsingError> { let mut folders_buffer: Vec = vec![0; U * to_usize(header.folder_count)]; reader.read_exact(folders_buffer.as_mut_slice())?; @@ -155,13 +153,9 @@ fn read_assets_with_header( let entry = assets.entry(folder_record.name_hash); if let Entry::Occupied(_) = entry { - return Err(InvalidArgumentError { - message: format!( - "Unexpected collision for folder name hash {:x}", - folder_record.name_hash - ), - } - .into()); + return Err(ArchiveParsingError::FolderHashCollision( + folder_record.name_hash, + )); } let file_records_offset = if (header.archive_flags & 0x1) == 0 { @@ -173,20 +167,18 @@ fn read_assets_with_header( if let Some(folder_name_length) = file_records_buffer.get(folder_name_length_offset) { folder_name_length_offset + 1 + to_usize(u32::from(*folder_name_length)) } else { - return Err(InvalidArgumentError { - message: "BSA file contains an invalid folder name length offset".into(), - } - .into()); + return Err(ArchiveParsingError::InvalidFolderNameLengthOffset( + folder_name_length_offset, + )); } }; let file_records_buffer = match file_records_buffer.get(file_records_offset..) { Some(s) => s, None => { - return Err(InvalidArgumentError { - message: "BSA file contains an invalid file records offset".into(), - } - .into()); + return Err(ArchiveParsingError::InvalidFileRecordsOffset( + file_records_offset, + )); } }; @@ -199,7 +191,10 @@ fn read_assets_with_header( let file_hash = to_u64(file_chunk); if !file_hashes.insert(file_hash) { - return Err(InvalidArgumentError { message: format!("Unexpected collision for file name hash {:x} in set for folder name hash {:x}", file_hash, folder_record.name_hash)}.into()); + return Err(ArchiveParsingError::HashCollision { + folder_hash: folder_record.name_hash, + file_hash, + }); } } } diff --git a/src/archive/error.rs b/src/archive/error.rs new file mode 100644 index 00000000..8dafe31b --- /dev/null +++ b/src/archive/error.rs @@ -0,0 +1,95 @@ +use std::path::PathBuf; + +#[derive(Debug)] +pub(crate) struct ArchivePathParsingError { + path: PathBuf, + error: ArchiveParsingError, +} + +impl ArchivePathParsingError { + pub(crate) fn new(path: PathBuf, error: ArchiveParsingError) -> Self { + Self { path, error } + } + + pub(crate) fn from_io_error(path: PathBuf, error: std::io::Error) -> Self { + Self { + path, + error: ArchiveParsingError::IoError(error), + } + } +} + +impl std::fmt::Display for ArchivePathParsingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "failed to parse the archive at \"{}\"", + self.path.display() + ) + } +} + +impl std::error::Error for ArchivePathParsingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.error) + } +} + +#[derive(Debug)] +pub(crate) enum ArchiveParsingError { + IoError(std::io::Error), + UnsupportedHeaderVersion(u32), + UnsupportedHeaderArchiveType([u8; 4]), + UnsupportedArchiveTypeId([u8; 4]), + InvalidRecordsOffset(u32), + InvalidFolderNameLengthOffset(usize), + InvalidFileRecordsOffset(usize), + UsesBigEndianNumbers, + FolderHashCollision(u64), + HashCollision { folder_hash: u64, file_hash: u64 }, +} + +impl std::fmt::Display for ArchiveParsingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::IoError(_) => write!(f, "an I/O error occurred"), + Self::UnsupportedHeaderVersion(v) => write!(f, "unsupported archive version {}", v), + Self::UnsupportedHeaderArchiveType(a) => write!(f, "unsupported archive type {:?}", a), + Self::UnsupportedArchiveTypeId(t) => write!(f, "unsupported archive type ID {:?}", t), + Self::InvalidRecordsOffset(o) => write!(f, "invalid records offset {}", o), + Self::InvalidFolderNameLengthOffset(o) => { + write!(f, "invalid folder name length offset {}", o) + } + Self::InvalidFileRecordsOffset(o) => write!(f, "invalid file records offset {}", o), + Self::UsesBigEndianNumbers => { + write!(f, "archive uses big-endian numbers, which is unsupported") + } + Self::FolderHashCollision(h) => { + write!(f, "unexpected collision for folder name hash {:x}", h) + } + Self::HashCollision { + folder_hash, + file_hash, + } => write!( + f, + "unexpected collision for file name hash {:x} in set for folder name hash {:x}", + file_hash, folder_hash + ), + } + } +} + +impl std::error::Error for ArchiveParsingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::IoError(e) => Some(e), + _ => None, + } + } +} + +impl From for ArchiveParsingError { + fn from(value: std::io::Error) -> Self { + ArchiveParsingError::IoError(value) + } +} diff --git a/src/archive/mod.rs b/src/archive/mod.rs index cfe6cf56..16582db8 100644 --- a/src/archive/mod.rs +++ b/src/archive/mod.rs @@ -1,5 +1,6 @@ mod ba2; mod bsa; +mod error; mod find; mod parse; diff --git a/src/archive/parse.rs b/src/archive/parse.rs index c0825824..b8e3363c 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -5,10 +5,8 @@ use std::{ path::{Path, PathBuf}, }; -use crate::{ - error::{GeneralError, InvalidArgumentError}, - plugin::has_ascii_extension, -}; +use super::error::{ArchiveParsingError, ArchivePathParsingError}; +use crate::plugin::has_ascii_extension; use super::{ba2, bsa}; @@ -70,22 +68,25 @@ fn should_warn_on_hash_collisions(archive_path: &Path) -> bool { fn get_assets_in_archive( archive_path: &Path, -) -> Result>, GeneralError> { - let mut reader = BufReader::new(File::open(archive_path)?); +) -> Result>, ArchivePathParsingError> { + let file = File::open(archive_path) + .map_err(|e| ArchivePathParsingError::from_io_error(archive_path.into(), e))?; + let mut reader = BufReader::new(file); let mut type_id: [u8; 4] = [0; 4]; - reader.read_exact(&mut type_id)?; + reader + .read_exact(&mut type_id) + .map_err(|e| ArchivePathParsingError::from_io_error(archive_path.into(), e))?; match type_id { - bsa::TYPE_ID => bsa::read_assets(reader), - ba2::TYPE_ID => ba2::read_assets(reader), - _ => Err(InvalidArgumentError { - message: format!( - "Bethesda archive at \"{}\" has an unrecognised type ID", - archive_path.display() - ), - } - .into()), + bsa::TYPE_ID => bsa::read_assets(reader) + .map_err(|e| ArchivePathParsingError::new(archive_path.into(), e)), + ba2::TYPE_ID => ba2::read_assets(reader) + .map_err(|e| ArchivePathParsingError::new(archive_path.into(), e)), + _ => Err(ArchivePathParsingError::new( + archive_path.into(), + ArchiveParsingError::UnsupportedArchiveTypeId(type_id), + )), } } diff --git a/src/database/conditions.rs b/src/database/conditions.rs new file mode 100644 index 00000000..22640623 --- /dev/null +++ b/src/database/conditions.rs @@ -0,0 +1,108 @@ +use std::str::FromStr; + +use loot_condition_interpreter::Expression; + +use crate::metadata::{File, PluginCleaningData, PluginMetadata}; + +pub fn evaluate_all_conditions( + mut metadata: PluginMetadata, + state: &loot_condition_interpreter::State, +) -> Result, loot_condition_interpreter::Error> { + metadata.set_load_after_files(filter_files_on_conditions( + metadata.load_after_files(), + state, + )?); + + metadata.set_requirements(filter_files_on_conditions(metadata.requirements(), state)?); + + metadata.set_incompatibilities(filter_files_on_conditions( + metadata.incompatibilities(), + state, + )?); + + metadata.set_messages( + metadata + .messages() + .iter() + .filter_map(|m| filter_map_on_condition(m, m.condition(), state)) + .collect::, _>>()?, + ); + + metadata.set_tags( + metadata + .tags() + .iter() + .filter_map(|t| filter_map_on_condition(t, t.condition(), state)) + .collect::, _>>()?, + ); + + if !metadata.is_regex_plugin() { + metadata.set_dirty_info(filter_cleaning_data_on_conditions( + metadata.name(), + metadata.dirty_info(), + state, + )?); + + metadata.set_clean_info(filter_cleaning_data_on_conditions( + metadata.name(), + metadata.clean_info(), + state, + )?); + } + + if metadata.has_name_only() { + Ok(None) + } else { + Ok(Some(metadata)) + } +} + +fn evaluate_condition( + condition: Option<&str>, + state: &loot_condition_interpreter::State, +) -> Result { + if let Some(condition) = condition { + Expression::from_str(condition).and_then(|e| e.eval(state)) + } else { + Ok(true) + } +} + +pub fn filter_map_on_condition( + item: &T, + condition: Option<&str>, + state: &loot_condition_interpreter::State, +) -> Option> { + evaluate_condition(condition, state) + .map(|r| r.then(|| item.clone())) + .transpose() +} + +fn filter_files_on_conditions( + files: &[File], + state: &loot_condition_interpreter::State, +) -> Result, loot_condition_interpreter::Error> { + files + .iter() + .filter_map(|file| filter_map_on_condition(file, file.condition(), state)) + .collect::, _>>() +} + +fn filter_cleaning_data_on_conditions( + plugin_name: &str, + cleaning_info: &[PluginCleaningData], + state: &loot_condition_interpreter::State, +) -> Result, loot_condition_interpreter::Error> { + if plugin_name.is_empty() { + return Ok(Vec::new()); + } + + cleaning_info + .iter() + .filter_map(|i| { + let condition = format!("checksum(\"{}\", {:08X})", plugin_name, i.crc()); + + filter_map_on_condition(i, Some(condition.as_str()), state) + }) + .collect::, _>>() +} diff --git a/src/database/error.rs b/src/database/error.rs new file mode 100644 index 00000000..74d1b83f --- /dev/null +++ b/src/database/error.rs @@ -0,0 +1,57 @@ +use crate::metadata::error::RegexError; + +/// Represents an error that occurred while evaluating a metadata condition. +#[derive(Debug)] +pub struct ConditionEvaluationError(Box); + +impl std::fmt::Display for ConditionEvaluationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "failed to evaluate condition") + } +} + +impl std::error::Error for ConditionEvaluationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +impl From for ConditionEvaluationError { + fn from(value: loot_condition_interpreter::Error) -> Self { + ConditionEvaluationError(Box::new(value)) + } +} + +/// Represents an error that occurred while retrieving metadata for a plugin. +#[derive(Debug)] +pub enum MetadataRetrievalError { + ConditionEvaluationError(ConditionEvaluationError), + RegexError(RegexError), +} + +impl std::fmt::Display for MetadataRetrievalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "failed to retrieve metadata") + } +} + +impl std::error::Error for MetadataRetrievalError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ConditionEvaluationError(e) => Some(e), + Self::RegexError(e) => Some(e), + } + } +} + +impl From for MetadataRetrievalError { + fn from(value: loot_condition_interpreter::Error) -> Self { + MetadataRetrievalError::ConditionEvaluationError(value.into()) + } +} + +impl From for MetadataRetrievalError { + fn from(value: RegexError) -> Self { + MetadataRetrievalError::RegexError(value) + } +} diff --git a/src/database.rs b/src/database/mod.rs similarity index 64% rename from src/database.rs rename to src/database/mod.rs index f3c50d21..5ab07747 100644 --- a/src/database.rs +++ b/src/database/mod.rs @@ -1,18 +1,23 @@ -use std::{path::Path, str::FromStr}; +mod conditions; +mod error; -use loot_condition_interpreter::Expression; +use std::{collections::HashMap, path::Path}; + +use conditions::{evaluate_all_conditions, filter_map_on_condition}; use crate::{ - error::{FileAccessError, GeneralError, InvalidArgumentError}, metadata::{ - File, Group, Message, PluginCleaningData, PluginMetadata, + Group, Message, PluginMetadata, + error::{LoadMetadataError, WriteMetadataError, WriteMetadataErrorReason}, metadata_document::MetadataDocument, }, sorting::{ + error::GroupsPathError, groups::{build_groups_graph, find_path}, vertex::Vertex, }, }; +pub use error::{ConditionEvaluationError, MetadataRetrievalError}; /// The interface through which metadata can be accessed. #[derive(Debug)] @@ -38,21 +43,18 @@ impl Database { &mut self.condition_evaluator_state } + pub(crate) fn clear_condition_cache(&mut self) { + if let Err(e) = self.condition_evaluator_state.clear_condition_cache() { + log::error!("The condition cache's lock is poisoned, assigning a new cache"); + *e.into_inner() = HashMap::new(); + } + } + /// Loads the masterlist from the given path. /// /// Replaces any existing data that was previously loaded from a masterlist. - pub fn load_masterlist(&mut self, path: &Path) -> Result<(), GeneralError> { - if path.exists() { - self.masterlist.load(path) - } else { - Err(FileAccessError { - message: format!( - "The given masterlist path does not exist: {}", - path.display() - ), - } - .into()) - } + pub fn load_masterlist(&mut self, path: &Path) -> Result<(), LoadMetadataError> { + self.masterlist.load(path) } /// Loads the masterlist from the given path, using the prelude at the given @@ -63,41 +65,16 @@ impl Database { &mut self, masterlist_path: &Path, prelude_path: &Path, - ) -> Result<(), GeneralError> { - if !masterlist_path.exists() { - Err(FileAccessError { - message: format!( - "The given masterlist path does not exist: {}", - masterlist_path.display() - ), - } - .into()) - } else if !prelude_path.exists() { - Err(FileAccessError { - message: format!( - "The given prelude path does not exist: {}", - prelude_path.display() - ), - } - .into()) - } else { - self.masterlist - .load_with_prelude(masterlist_path, prelude_path) - } + ) -> Result<(), LoadMetadataError> { + self.masterlist + .load_with_prelude(masterlist_path, prelude_path) } /// Loads the userlist from the given path. /// /// Replaces any existing data that was previously loaded from a userlist. - pub fn load_userlist(&mut self, path: &Path) -> Result<(), GeneralError> { - if path.exists() { - self.userlist.load(path) - } else { - Err(FileAccessError { - message: format!("The given userlist path does not exist: {}", path.display()), - } - .into()) - } + pub fn load_userlist(&mut self, path: &Path) -> Result<(), LoadMetadataError> { + self.userlist.load(path) } /// Writes a metadata file containing all loaded user-added metadata. @@ -108,7 +85,7 @@ impl Database { &self, output_path: &Path, overwrite: bool, - ) -> Result<(), GeneralError> { + ) -> Result<(), WriteMetadataError> { validate_write_path(output_path, overwrite)?; self.userlist.save(output_path) @@ -123,13 +100,14 @@ impl Database { &self, output_path: &Path, overwrite: bool, - ) -> Result<(), GeneralError> { + ) -> Result<(), WriteMetadataError> { validate_write_path(output_path, overwrite)?; let mut doc = MetadataDocument::default(); for plugin in self.masterlist.plugins() { - let mut minimal_plugin = PluginMetadata::new(plugin.name())?; + let mut minimal_plugin = PluginMetadata::new(plugin.name()) + .expect("Regex plugin name from existing PluginMetadata object is valid"); minimal_plugin.set_tags(plugin.tags().to_vec()); minimal_plugin.set_dirty_info(plugin.dirty_info().to_vec()); @@ -158,7 +136,11 @@ impl Database { pub fn general_messages( &mut self, evaluate_conditions: bool, - ) -> Result, GeneralError> { + ) -> Result, ConditionEvaluationError> { + if evaluate_conditions { + self.clear_condition_cache(); + } + let messages_iter = self .masterlist .messages() @@ -166,8 +148,6 @@ impl Database { .chain(self.userlist.messages()); if evaluate_conditions { - self.condition_evaluator_state.clear_condition_cache()?; - let messages = messages_iter .filter_map(|m| { filter_map_on_condition(m, m.condition(), &self.condition_evaluator_state) @@ -217,10 +197,12 @@ impl Database { &self, from_group_name: &str, to_group_name: &str, - ) -> Result, GeneralError> { + ) -> Result, GroupsPathError> { let graph = build_groups_graph(self.masterlist.groups(), self.userlist.groups())?; - find_path(&graph, from_group_name, to_group_name) + let path = find_path(&graph, from_group_name, to_group_name)?; + + Ok(path) } /// Get all of a plugin's loaded metadata. @@ -238,7 +220,7 @@ impl Database { plugin_name: &str, include_user_metadata: bool, evaluate_conditions: bool, - ) -> Result, GeneralError> { + ) -> Result, MetadataRetrievalError> { let mut metadata = self.masterlist.find_plugin(plugin_name)?; if include_user_metadata { @@ -270,17 +252,17 @@ impl Database { &self, plugin_name: &str, evaluate_conditions: bool, - ) -> Result, GeneralError> { - let metadata = self.userlist.find_plugin(plugin_name); + ) -> Result, MetadataRetrievalError> { + let metadata = self.userlist.find_plugin(plugin_name)?; if evaluate_conditions { - if let Ok(Some(metadata)) = metadata { + if let Some(metadata) = metadata { return evaluate_all_conditions(metadata, &self.condition_evaluator_state) .map_err(Into::into); } } - metadata + Ok(metadata) } /// Sets a plugin's user metadata, replacing any loaded user metadata for @@ -302,17 +284,17 @@ impl Database { } } -fn validate_write_path(output_path: &Path, overwrite: bool) -> Result<(), GeneralError> { +fn validate_write_path(output_path: &Path, overwrite: bool) -> Result<(), WriteMetadataError> { if !output_path.parent().map(|p| p.exists()).unwrap_or(false) { - Err(InvalidArgumentError { - message: "The output directory does not exist.".into(), - } - .into()) + Err(WriteMetadataError::new( + output_path.into(), + WriteMetadataErrorReason::ParentDirectoryNotFound, + )) } else if !overwrite && output_path.exists() { - Err(FileAccessError { - message: "Output file exists but overwrite is not set to true.".into(), - } - .into()) + Err(WriteMetadataError::new( + output_path.into(), + WriteMetadataErrorReason::PathAlreadyExists, + )) } else { Ok(()) } @@ -350,106 +332,3 @@ fn merge_groups(lhs: &[Group], rhs: &[Group]) -> Vec { groups } - -fn evaluate_all_conditions( - mut metadata: PluginMetadata, - state: &loot_condition_interpreter::State, -) -> Result, loot_condition_interpreter::Error> { - metadata.set_load_after_files(filter_files_on_conditions( - metadata.load_after_files(), - state, - )?); - - metadata.set_requirements(filter_files_on_conditions(metadata.requirements(), state)?); - - metadata.set_incompatibilities(filter_files_on_conditions( - metadata.incompatibilities(), - state, - )?); - - metadata.set_messages( - metadata - .messages() - .iter() - .filter_map(|m| filter_map_on_condition(m, m.condition(), state)) - .collect::, _>>()?, - ); - - metadata.set_tags( - metadata - .tags() - .iter() - .filter_map(|t| filter_map_on_condition(t, t.condition(), state)) - .collect::, _>>()?, - ); - - if !metadata.is_regex_plugin() { - metadata.set_dirty_info(filter_cleaning_data_on_conditions( - metadata.name(), - metadata.dirty_info(), - state, - )?); - - metadata.set_clean_info(filter_cleaning_data_on_conditions( - metadata.name(), - metadata.clean_info(), - state, - )?); - } - - if metadata.has_name_only() { - Ok(None) - } else { - Ok(Some(metadata)) - } -} - -fn evaluate_condition( - condition: Option<&str>, - state: &loot_condition_interpreter::State, -) -> Result { - if let Some(condition) = condition { - Expression::from_str(condition).and_then(|e| e.eval(state)) - } else { - Ok(true) - } -} - -fn filter_map_on_condition( - item: &T, - condition: Option<&str>, - state: &loot_condition_interpreter::State, -) -> Option> { - evaluate_condition(condition, state) - .map(|r| r.then(|| item.clone())) - .transpose() -} - -fn filter_files_on_conditions( - files: &[File], - state: &loot_condition_interpreter::State, -) -> Result, loot_condition_interpreter::Error> { - files - .iter() - .filter_map(|file| filter_map_on_condition(file, file.condition(), state)) - .collect::, _>>() -} - -fn filter_cleaning_data_on_conditions( - plugin_name: &str, - cleaning_info: &[PluginCleaningData], - state: &loot_condition_interpreter::State, -) -> Result, loot_condition_interpreter::Error> { - if plugin_name.is_empty() { - return Ok(Vec::new()); - } - - cleaning_info - .iter() - .filter_map(|i| { - let condition = format!("checksum(\"{}\", {:08X})", plugin_name, i.crc()); - - filter_map_on_condition(i, Some(condition.as_str()), state) - }) - .collect::, _>>() -} diff --git a/src/error.rs b/src/error.rs index 264a6dbd..00d998dd 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,353 +1,275 @@ -use std::fmt::Display; +//! Holds all error types aside from those related to LOOT metadata. +use std::path::PathBuf; -use petgraph::graph::NodeIndex; -use saphyr::Marker; +pub use crate::database::{ConditionEvaluationError, MetadataRetrievalError}; +pub use crate::plugin::error::PluginDataError; +use crate::plugin::error::PluginValidationError; +pub use crate::sorting::error::GroupsPathError; -use crate::{ - metadata::{ - MessageContent, - yaml::{YamlObjectType, to_yaml}, - }, - sorting::vertex::Vertex, +use crate::Vertex; +use crate::sorting::error::{ + BuildGroupsGraphError, PluginGraphValidationError, SortingError, display_cycle, }; +/// Represents an error that occurred while trying to create a [Game][crate::Game]. #[derive(Debug)] -pub struct CyclicInteractionError { - pub cycle: Vec, +#[non_exhaustive] +pub enum GameHandleCreationError { + NotADirectory(PathBuf), + LoadOrderError(LoadOrderError), } -impl CyclicInteractionError { - pub fn new(cycle: Vec) -> Self { - Self { cycle } - } -} - -impl Display for CyclicInteractionError { +impl std::fmt::Display for GameHandleCreationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let cycle: String = self - .cycle - .iter() - .map(|v| { - if let Some(edge_type) = v.out_edge_type() { - format!("{} --[{}]-> ", v.name(), edge_type) - } else { - v.name().to_string() - } - }) - .chain(self.cycle.first().iter().map(|v| v.name().to_string())) - .collect(); - write!(f, "Cyclic interaction detected: {}", cycle) - } -} - -impl std::error::Error for CyclicInteractionError {} - -#[derive(Debug)] -pub struct FileAccessError { - pub message: String, -} - -impl FileAccessError { - pub(crate) fn new(message: String) -> Self { - FileAccessError { message } - } -} - -impl std::fmt::Display for FileAccessError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for FileAccessError {} - -#[derive(Debug)] -pub struct UndefinedGroupError { - pub group_name: String, -} - -impl UndefinedGroupError { - pub fn new(group_name: String) -> Self { - Self { group_name } - } -} - -impl Display for UndefinedGroupError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "The group \"{}\" does not exist", self.group_name) - } -} - -impl std::error::Error for UndefinedGroupError {} - -#[derive(Debug)] -pub struct InvalidArgumentError { - pub message: String, -} - -impl std::fmt::Display for InvalidArgumentError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for InvalidArgumentError {} - -#[derive(Debug)] -pub(crate) struct YamlMergeKeyError { - value: saphyr::MarkedYaml, -} - -impl YamlMergeKeyError { - pub(crate) fn new(value: saphyr::MarkedYaml) -> Self { - YamlMergeKeyError { value } - } -} - -impl std::fmt::Display for YamlMergeKeyError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut output = String::new(); - - let yaml = to_yaml(&self.value); - - if saphyr::YamlEmitter::new(&mut output).dump(&yaml).is_ok() { - write!( + match self { + Self::NotADirectory(p) => write!( f, - "Invalid YAML merge key value at line {} column {}: {}", - self.value.span.start.line(), - self.value.span.start.col(), - output - ) - } else { - write!( - f, - "Invalid YAML merge key value at line {} column {}: {:?}", - self.value.span.start.line(), - self.value.span.start.col(), - self.value - ) + "the path \"{}\" does not resolve to a directory", + p.display() + ), + Self::LoadOrderError(_) => { + write!(f, "failed to initialise the load order game settings") + } } } } -impl std::error::Error for YamlMergeKeyError {} - -#[derive(Debug)] -pub struct YamlParseError { - marker: saphyr::Marker, - message: String, -} - -impl YamlParseError { - pub fn new(marker: Marker, message: String) -> Self { - YamlParseError { marker, message } - } - - pub fn missing_key(marker: Marker, key: &str, yaml_type: YamlObjectType) -> Self { - YamlParseError::new( - marker, - format!("'{}' key missing from '{}' map object", key, yaml_type), - ) - } -} - -impl std::fmt::Display for YamlParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "Encountered a YAML parsing error at line {} column {}: {}", - self.marker.line(), - self.marker.col(), - self.message - ) - } -} - -impl std::error::Error for YamlParseError {} - -#[derive(Debug)] -pub struct InvalidMultilingualMessageContents {} - -impl std::fmt::Display for InvalidMultilingualMessageContents { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "Multilingual messages must contain a content string that uses the {} language code", - MessageContent::DEFAULT_LANGUAGE - ) - } -} - -impl std::error::Error for InvalidMultilingualMessageContents {} - -#[derive(Debug)] -pub struct PoisonedMutexError; - -impl std::fmt::Display for PoisonedMutexError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "A mutex is poisoned",) - } -} - -impl std::error::Error for PoisonedMutexError {} - -#[derive(Debug)] -pub struct PathfindingError { - message: String, -} - -impl PathfindingError { - pub fn new(message: String) -> Self { - Self { message } - } -} - -impl std::fmt::Display for PathfindingError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for PathfindingError {} - -#[derive(Debug)] -pub struct SortingLogicError { - message: String, -} - -impl SortingLogicError { - pub fn new(message: String) -> Self { - Self { message } - } -} - -impl std::fmt::Display for SortingLogicError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for SortingLogicError {} - -#[derive(Debug)] -pub struct GeneralError(Box); - -impl std::fmt::Display for GeneralError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -impl std::error::Error for GeneralError { +impl std::error::Error for GameHandleCreationError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - Some(self.0.as_ref()) + match self { + Self::NotADirectory(_) => None, + Self::LoadOrderError(e) => Some(e), + } } } -impl From for GeneralError { - fn from(value: std::io::Error) -> Self { - GeneralError(Box::new(value)) - } -} - -impl From for GeneralError { - fn from(value: saphyr::ScanError) -> Self { - GeneralError(Box::new(value)) - } -} - -impl From for GeneralError { - fn from(value: YamlMergeKeyError) -> Self { - GeneralError(Box::new(value)) - } -} - -impl From for GeneralError { - fn from(value: FileAccessError) -> Self { - GeneralError(Box::new(value)) - } -} - -impl From for GeneralError { - fn from(value: YamlParseError) -> Self { - GeneralError(Box::new(value)) - } -} - -impl From for GeneralError { - fn from(value: loot_condition_interpreter::Error) -> Self { - GeneralError(Box::new(value)) - } -} - -impl From for GeneralError { - fn from(value: std::num::TryFromIntError) -> Self { - GeneralError(Box::new(value)) - } -} - -impl From for GeneralError { - fn from(value: InvalidMultilingualMessageContents) -> Self { - GeneralError(Box::new(value)) - } -} - -impl From for GeneralError { - fn from(value: InvalidArgumentError) -> Self { - GeneralError(Box::new(value)) - } -} - -impl From for GeneralError { +impl From for GameHandleCreationError { fn from(value: loadorder::Error) -> Self { - GeneralError(Box::new(value)) + GameHandleCreationError::LoadOrderError(value.into()) } } -impl From> for GeneralError { +/// Represents an error that occurred while trying to interact with the load order. +#[derive(Debug)] +pub struct LoadOrderError(Box); + +impl std::fmt::Display for LoadOrderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "load order interaction failed") + } +} + +impl std::error::Error for LoadOrderError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +impl From for LoadOrderError { + fn from(value: loadorder::Error) -> Self { + LoadOrderError(Box::new(value)) + } +} + +/// Indicates that the Database's RwLock wrapper has been poisoned and as such +/// the Database may be in an invalid state. +#[derive(Clone, Copy, Default, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct DatabaseLockPoisonError; + +impl std::fmt::Display for DatabaseLockPoisonError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "the database's lock has been poisoned") + } +} + +impl std::error::Error for DatabaseLockPoisonError {} + +impl From> for DatabaseLockPoisonError { fn from(_: std::sync::PoisonError) -> Self { - GeneralError(Box::new(PoisonedMutexError)) + DatabaseLockPoisonError } } -impl From for GeneralError { - fn from(value: esplugin::Error) -> Self { - GeneralError(Box::new(value)) +/// Represents an error that occurred while loading plugins. +#[derive(Debug)] +#[non_exhaustive] +pub enum LoadPluginsError { + DatabaseLockPoisoned, + IoError(Box), + PluginValidationError(Box), + PluginDataError(PluginDataError), +} + +impl std::fmt::Display for LoadPluginsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DatabaseLockPoisoned => DatabaseLockPoisonError.fmt(f), + Self::IoError(_) => write!(f, "an I/O error occurred"), + Self::PluginValidationError(_) => write!(f, "failed validation of input plugin paths"), + Self::PluginDataError(_) => write!(f, "failed to read loaded plugin data"), + } } } -impl From> for GeneralError { - fn from(value: Box) -> Self { - GeneralError(value) +impl std::error::Error for LoadPluginsError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::DatabaseLockPoisoned => None, + Self::IoError(e) => Some(e), + Self::PluginValidationError(e) => Some(e.as_ref()), + Self::PluginDataError(e) => Some(e), + } } } -impl From for GeneralError { - fn from(value: UndefinedGroupError) -> Self { - GeneralError(Box::new(value)) +impl From> for LoadPluginsError { + fn from(_: std::sync::PoisonError) -> Self { + LoadPluginsError::DatabaseLockPoisoned } } -impl From for GeneralError { - fn from(value: CyclicInteractionError) -> Self { - GeneralError(Box::new(value)) +impl From for LoadPluginsError { + fn from(_: DatabaseLockPoisonError) -> Self { + LoadPluginsError::DatabaseLockPoisoned } } -impl From for GeneralError { - fn from(value: PathfindingError) -> Self { - GeneralError(Box::new(value)) +impl From for LoadPluginsError { + fn from(value: std::io::Error) -> Self { + LoadPluginsError::IoError(Box::new(value)) } } -impl From> for GeneralError { - fn from(_: petgraph::algo::Cycle) -> Self { - GeneralError(Box::new(CyclicInteractionError { cycle: Vec::new() })) +impl From for LoadPluginsError { + fn from(value: PluginValidationError) -> Self { + LoadPluginsError::PluginValidationError(Box::new(value)) } } -impl From for GeneralError { - fn from(value: SortingLogicError) -> Self { - GeneralError(Box::new(value)) +impl From for LoadPluginsError { + fn from(value: PluginDataError) -> Self { + LoadPluginsError::PluginDataError(value) + } +} + +/// Represents an error that occurred while trying to load the current load +/// order state. +#[derive(Debug)] +#[non_exhaustive] +pub enum LoadOrderStateError { + DatabaseLockPoisoned, + LoadOrderError(LoadOrderError), +} + +impl std::fmt::Display for LoadOrderStateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DatabaseLockPoisoned => DatabaseLockPoisonError.fmt(f), + Self::LoadOrderError(_) => write!(f, "failed to load the current load order state"), + } + } +} + +impl std::error::Error for LoadOrderStateError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::DatabaseLockPoisoned => None, + Self::LoadOrderError(e) => Some(e), + } + } +} + +impl From> for LoadOrderStateError { + fn from(_: std::sync::PoisonError) -> Self { + LoadOrderStateError::DatabaseLockPoisoned + } +} + +impl From for LoadOrderStateError { + fn from(value: loadorder::Error) -> Self { + LoadOrderStateError::LoadOrderError(value.into()) + } +} + +/// Represents an error that occurred during sorting. +#[derive(Debug)] +#[non_exhaustive] +pub enum SortPluginsError { + DatabaseLockPoisoned, + PluginNotLoaded(String), + MetadataRetrievalError(MetadataRetrievalError), + UndefinedGroup(String), + CycleFound(Vec), + CycleFoundInvolving(String), + PluginDataError(PluginDataError), + PathfindingError(Box), +} + +impl std::fmt::Display for SortPluginsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DatabaseLockPoisoned => DatabaseLockPoisonError.fmt(f), + Self::PluginNotLoaded(n) => write!(f, "the plugin \"{}\" has not been loaded", n), + Self::UndefinedGroup(g) => write!(f, "the group \"{}\" does not exist", g), + Self::CycleFound(c) => write!(f, "found a cycle: {}", display_cycle(c)), + Self::CycleFoundInvolving(n) => write!(f, "found a cycle involving \"{}\"", n), + Self::PluginDataError(_) => write!(f, "failed to read loaded plugin data"), + Self::MetadataRetrievalError(_) => write!(f, "failed to retrieve plugin metadata"), + Self::PathfindingError(_) => write!(f, "failed to find a path in the plugins graph"), + } + } +} + +impl std::error::Error for SortPluginsError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::MetadataRetrievalError(e) => Some(e), + Self::PluginDataError(e) => Some(e), + Self::PathfindingError(e) => Some(e.as_ref()), + _ => None, + } + } +} + +impl From> for SortPluginsError { + fn from(_: std::sync::PoisonError) -> Self { + SortPluginsError::DatabaseLockPoisoned + } +} + +impl From for SortPluginsError { + fn from(value: SortingError) -> Self { + match value { + SortingError::ValidationError(e) => match e { + PluginGraphValidationError::CycleFound(c) => Self::CycleFound(c.into_cycle()), + PluginGraphValidationError::PluginDataError(e) => Self::PluginDataError(e), + }, + SortingError::UndefinedGroup(g) => Self::UndefinedGroup(g.into_group_name()), + SortingError::CycleFound(c) => Self::CycleFound(c.into_cycle()), + SortingError::CycleInvolving(n) => Self::CycleFoundInvolving(n), + SortingError::PluginDataError(e) => Self::PluginDataError(e), + SortingError::PathfindingError(e) => Self::PathfindingError(Box::new(e)), + } + } +} + +impl From for SortPluginsError { + fn from(value: BuildGroupsGraphError) -> Self { + match value { + BuildGroupsGraphError::UndefinedGroup(g) => Self::UndefinedGroup(g.into_group_name()), + BuildGroupsGraphError::CycleFound(c) => Self::CycleFound(c.into_cycle()), + } + } +} + +impl From for SortPluginsError { + fn from(value: PluginDataError) -> Self { + SortPluginsError::PluginDataError(value) + } +} + +impl From for SortPluginsError { + fn from(value: MetadataRetrievalError) -> Self { + SortPluginsError::MetadataRetrievalError(value) } } diff --git a/src/game.rs b/src/game.rs index ba513080..9fa2f1ea 100644 --- a/src/game.rs +++ b/src/game.rs @@ -10,12 +10,16 @@ use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use crate::{ database::Database, - error::{GeneralError, InvalidArgumentError}, + error::{ + DatabaseLockPoisonError, GameHandleCreationError, LoadOrderError, LoadOrderStateError, + LoadPluginsError, SortPluginsError, + }, metadata::{ Filename, plugin_metadata::{GHOST_FILE_EXTENSION, iends_with_ascii}, }, - plugin::{LoadScope, Plugin, is_valid_plugin, plugins_metadata}, + plugin::error::{InvalidFilenameReason, PluginValidationError}, + plugin::{LoadScope, Plugin, plugins_metadata, validate_plugin_path_and_header}, sorting::{ groups::build_groups_graph, plugins::{PluginSortingData, sort_plugins}, @@ -144,7 +148,7 @@ impl Game { /// may fail in some situations (e.g. when running libloot natively on Linux /// for a game other than Morrowind or OpenMW). [Game::with_local_path] /// can be used to provide the local path instead. - pub fn new(game_type: GameType, game_path: &Path) -> Result { + pub fn new(game_type: GameType, game_path: &Path) -> Result { log::info!( "Attempting to create a game handle for game type {} with game path {:?}", game_type, @@ -153,13 +157,7 @@ impl Game { let resolved_game_path = resolve_path(game_path); if !resolved_game_path.is_dir() { - return Err(InvalidArgumentError { - message: format!( - "Given game path \"{:?}\" does not resolve to a valid directory.", - game_path - ), - } - .into()); + return Err(GameHandleCreationError::NotADirectory(game_path.into())); } let load_order = @@ -192,7 +190,7 @@ impl Game { game_type: GameType, game_path: &Path, game_local_path: &Path, - ) -> Result { + ) -> Result { log::info!( "Attempting to create a game handle for game type {} with game path {:?} and game local path {:?}", game_type, @@ -202,23 +200,14 @@ impl Game { let resolved_game_path = resolve_path(game_path); if !resolved_game_path.is_dir() { - return Err(InvalidArgumentError { - message: format!( - "Given game path \"{:?}\" does not resolve to a valid directory.", - game_path - ), - } - .into()); + return Err(GameHandleCreationError::NotADirectory(game_path.into())); } let resolved_game_local_path = resolve_path(game_local_path); if resolved_game_local_path.exists() && !resolved_game_local_path.is_dir() { - return Err(InvalidArgumentError { - message: format!( - "Given game local path \"{:?}\" resolves to a path that exists but is not a valid directory.", - game_local_path - ), - }.into()); + return Err(GameHandleCreationError::NotADirectory( + game_local_path.into(), + )); } let load_order = loadorder::GameSettings::with_local_path( @@ -272,21 +261,22 @@ impl Game { pub fn set_additional_data_paths( &mut self, additional_data_paths: &[&Path], - ) -> Result<(), GeneralError> { + ) -> Result<(), DatabaseLockPoisonError> { let paths: Vec<_> = additional_data_paths .iter() .map(|p| p.to_path_buf()) .collect(); let mut database = self.database.write()?; - let state = database.condition_evaluator_state_mut(); - state.clear_condition_cache()?; + database.clear_condition_cache(); self.load_order .game_settings_mut() .set_additional_plugins_directories(paths.clone()); - state.set_additional_data_paths(paths); + database + .condition_evaluator_state_mut() + .set_additional_data_paths(paths); Ok(()) } @@ -306,7 +296,7 @@ impl Game { /// relative to the game's plugins directory, while absolute paths are used /// as given. pub fn is_valid_plugin(&self, plugin_path: &Path) -> bool { - is_valid_plugin(self.game_type, plugin_path) + validate_plugin_path_and_header(self.game_type, plugin_path).is_ok() } /// Fully parses plugins and loads their data. @@ -324,7 +314,7 @@ impl Game { /// /// Loading plugins clears the condition cache in this game's database /// object. - pub fn load_plugins(&mut self, plugin_paths: &[&Path]) -> Result<(), GeneralError> { + pub fn load_plugins(&mut self, plugin_paths: &[&Path]) -> Result<(), LoadPluginsError> { let mut plugins = self.load_plugins_common(plugin_paths, LoadScope::WholePlugin)?; if matches!( @@ -338,7 +328,9 @@ impl Game { } } - self.store_plugins(plugins) + self.store_plugins(plugins)?; + + Ok(()) } /// Parses plugin headers and loads their data. @@ -352,17 +344,19 @@ impl Game { /// /// Loading plugins clears the condition cache in this game's database /// object. - pub fn load_plugin_headers(&mut self, plugin_paths: &[&Path]) -> Result<(), GeneralError> { + pub fn load_plugin_headers(&mut self, plugin_paths: &[&Path]) -> Result<(), LoadPluginsError> { let plugins = self.load_plugins_common(plugin_paths, LoadScope::HeaderOnly)?; - self.store_plugins(plugins) + self.store_plugins(plugins)?; + + Ok(()) } fn load_plugins_common( &mut self, plugin_paths: &[&Path], load_scope: LoadScope, - ) -> Result, GeneralError> { + ) -> Result, LoadPluginsError> { validate_plugin_paths(self.game_type, plugin_paths)?; let data_path = data_path(self.game_type, &self.game_path); @@ -384,14 +378,16 @@ impl Game { Ok(plugins) } - fn store_plugins(&mut self, plugins: Vec) -> Result<(), GeneralError> { + fn store_plugins(&mut self, plugins: Vec) -> Result<(), DatabaseLockPoisonError> { self.cache.insert_plugins(plugins); let mut database = self.database.write()?; update_loaded_plugin_state( database.condition_evaluator_state_mut(), self.cache.plugins(), - ) + ); + + Ok(()) } /// Clears the plugins loaded by previous calls to [Game::load_plugins] or @@ -421,13 +417,12 @@ impl Game { /// The order in which plugins are listed in `plugin_filenames` is used as /// their current load order. All given plugins must have been already been /// loaded using [Game::load_plugins] or [Game::load_plugin_headers]. - pub fn sort_plugins(&self, plugin_names: &[&str]) -> Result, GeneralError> { + pub fn sort_plugins(&self, plugin_names: &[&str]) -> Result, SortPluginsError> { let plugins = plugin_names .iter() .map(|n| { - self.plugin(n).ok_or_else(|| InvalidArgumentError { - message: format!("The plugin \"{}\" has not been loaded.", n), - }) + self.plugin(n) + .ok_or_else(|| SortPluginsError::PluginNotLoaded(n.to_string())) }) .collect::, _>>()?; @@ -439,7 +434,13 @@ impl Game { .map(|(i, p)| { let masterlist_metadata = database.plugin_metadata(p.name(), false, true)?; let user_metadata = database.plugin_user_metadata(p.name(), true)?; - PluginSortingData::new(p, masterlist_metadata.as_ref(), user_metadata.as_ref(), i) + let plugin = PluginSortingData::new( + p, + masterlist_metadata.as_ref(), + user_metadata.as_ref(), + i, + )?; + Ok::<_, SortPluginsError>(plugin) }) .collect::, _>>()?; @@ -476,13 +477,14 @@ impl Game { /// /// Loading the current load order state clears the condition cache in this /// game's database object. - pub fn load_current_load_order_state(&mut self) -> Result<(), GeneralError> { + pub fn load_current_load_order_state(&mut self) -> Result<(), LoadOrderStateError> { self.load_order.load()?; let mut database = self.database.write()?; - let state = database.condition_evaluator_state_mut(); - state.clear_condition_cache()?; - state.set_active_plugins(&self.load_order.active_plugin_names()); + database.clear_condition_cache(); + database + .condition_evaluator_state_mut() + .set_active_plugins(&self.load_order.active_plugin_names()); Ok(()) } @@ -492,8 +494,8 @@ impl Game { /// well-defined position in the "on disk" state, and that all data sources /// are consistent. If the load order is ambiguous, different applications /// may read different load orders from the same source data. - pub fn is_load_order_ambiguous(&self) -> Result { - self.load_order.is_ambiguous() + pub fn is_load_order_ambiguous(&self) -> Result { + Ok(self.load_order.is_ambiguous()?) } /// Gets the path to the file that holds the list of active plugins. @@ -519,8 +521,9 @@ impl Game { /// There is no way to persist the load order of inactive OpenMW plugins, so /// setting an OpenMW load order will have no effect if the relative order /// of active plugins is unchanged. - pub fn set_load_order(&mut self, load_order: &[&str]) -> Result<(), loadorder::Error> { - self.load_order.set_load_order(load_order) + pub fn set_load_order(&mut self, load_order: &[&str]) -> Result<(), LoadOrderError> { + self.load_order.set_load_order(load_order)?; + Ok(()) } } @@ -562,35 +565,31 @@ fn new_condition_evaluator_state( fn validate_plugin_paths( game_type: GameType, plugin_paths: &[&Path], -) -> Result<(), InvalidArgumentError> { +) -> Result<(), PluginValidationError> { // Check that all plugin filenames are unique. let mut set = HashSet::new(); for path in plugin_paths { let filename = match path.file_name() { Some(f) => f.to_string_lossy(), None => { - return Err(InvalidArgumentError { - message: format!("The path \"{}\" has no filename.", path.display()), - }); + return Err(PluginValidationError::invalid( + path.into(), + InvalidFilenameReason::Empty, + )); } }; if !set.insert(Filename::new(filename.to_string())) { - return Err(InvalidArgumentError { - message: format!("The filename \"{}\" is not unique.", filename), - }); + return Err(PluginValidationError::invalid( + path.into(), + InvalidFilenameReason::NonUnique, + )); } } - let invalid_path = plugin_paths + plugin_paths .par_iter() - .find_any(|path| !is_valid_plugin(game_type, path)); - if let Some(invalid_path) = invalid_path { - return Err(InvalidArgumentError { - message: format!("\"{}\" is not a valid plugin", invalid_path.display()), - }); - } - - Ok(()) + .map(|path| validate_plugin_path_and_header(game_type, path)) + .collect::>() } fn find_archives( @@ -685,7 +684,7 @@ fn resolve_plugin_path(game_type: GameType, data_path: &Path, plugin_path: &Path fn update_loaded_plugin_state<'a>( state: &mut loot_condition_interpreter::State, plugins: impl Iterator, -) -> Result<(), GeneralError> { +) { let mut plugin_versions = Vec::new(); let mut plugin_crcs = Vec::new(); @@ -699,13 +698,24 @@ fn update_loaded_plugin_state<'a>( } } - state.clear_condition_cache()?; + if let Err(e) = state.clear_condition_cache() { + log::error!("The condition cache's lock is poisoned, assigning a new cache"); + *e.into_inner() = HashMap::new(); + } state.set_plugin_versions(&plugin_versions); - state.set_cached_crcs(&plugin_crcs)?; - - Ok(()) + if let Err(e) = state.set_cached_crcs(&plugin_crcs) { + log::error!( + "The condition interpreter's CRC cache's lock is poisoned, clearing the cache and assigning a new value" + ); + let mut cache = e.into_inner(); + cache.clear(); + *cache = plugin_crcs + .into_iter() + .map(|(n, c)| (n.to_lowercase(), c)) + .collect(); + } } #[derive(Clone, Debug, Default, Eq, PartialEq)] diff --git a/src/metadata/error.rs b/src/metadata/error.rs new file mode 100644 index 00000000..f015190a --- /dev/null +++ b/src/metadata/error.rs @@ -0,0 +1,399 @@ +//! Holds all error types related to LOOT metadata. +use std::path::PathBuf; + +use saphyr::Marker; + +use crate::metadata::MessageContent; + +use super::yaml::{YamlObjectType, to_yaml}; + +/// Represents an error that occurred when validating a collection of +/// [MessageContent] objects. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct MultilingualMessageContentsError; + +impl std::fmt::Display for MultilingualMessageContentsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "multilingual messages must contain a content string that uses the {} language code", + MessageContent::DEFAULT_LANGUAGE + ) + } +} + +impl std::error::Error for MultilingualMessageContentsError {} + +/// Represents an error that occurred while parsing metadata. +#[derive(Debug)] +pub struct ParseMetadataError { + marker: saphyr::Marker, + reason: MetadataParsingErrorReason, +} + +impl ParseMetadataError { + pub(super) fn new(marker: Marker, reason: MetadataParsingErrorReason) -> Self { + Self { marker, reason } + } + + pub(super) fn invalid_condition( + marker: Marker, + condition: String, + cause: loot_condition_interpreter::Error, + ) -> Self { + Self { + marker, + reason: MetadataParsingErrorReason::InvalidCondition(Box::new((condition, cause))), + } + } + + pub(super) fn missing_key( + marker: Marker, + key: &'static str, + yaml_type: YamlObjectType, + ) -> Self { + Self { + marker, + reason: MetadataParsingErrorReason::MissingKey(key, yaml_type), + } + } + + pub(super) fn duplicate_entry(marker: Marker, id: String, yaml_type: YamlObjectType) -> Self { + Self { + marker, + reason: MetadataParsingErrorReason::DuplicateEntry(id, yaml_type), + } + } + + pub(super) fn unexpected_type( + marker: Marker, + yaml_type: YamlObjectType, + expected_type: ExpectedType, + ) -> Self { + Self { + marker, + reason: MetadataParsingErrorReason::UnexpectedType(expected_type, yaml_type), + } + } + + pub(super) fn unexpected_value_type( + marker: Marker, + key: &'static str, + yaml_type: YamlObjectType, + expected_type: ExpectedType, + ) -> Self { + Self { + marker, + reason: MetadataParsingErrorReason::UnexpectedValueType(key, expected_type, yaml_type), + } + } +} + +impl std::fmt::Display for ParseMetadataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "encountered a YAML parsing error at line {} column {}: {}", + self.marker.line(), + self.marker.col(), + self.reason + ) + } +} + +impl std::error::Error for ParseMetadataError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.reason { + MetadataParsingErrorReason::InvalidCondition(b) => Some(&b.1), + MetadataParsingErrorReason::InvalidRegex(b) => Some(b), + _ => None, + } + } +} + +impl From for ParseMetadataError { + fn from(value: saphyr::ScanError) -> Self { + Self { + marker: *value.marker(), + reason: MetadataParsingErrorReason::Other(Box::new(value)), + } + } +} + +#[derive(Debug)] +pub(super) enum MetadataParsingErrorReason { + InvalidCondition(Box<(String, loot_condition_interpreter::Error)>), + MissingKey(&'static str, YamlObjectType), + InvalidRegex(Box), + InvalidMultilingualMessageContents, + UnexpectedType(ExpectedType, YamlObjectType), + UnexpectedValueType(&'static str, ExpectedType, YamlObjectType), + MissingPlaceholder(String, usize), + NonU32Number(i64), + DuplicateEntry(String, YamlObjectType), + Other(Box), +} + +impl std::fmt::Display for MetadataParsingErrorReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidCondition(b) => { + write!(f, "the condition string \"{}\" is invalid", b.0) + } + Self::MissingKey(key, yaml_object_type) => write!( + f, + "\"{}\" key in \"{}\" map is missing", + key, yaml_object_type + ), + Self::InvalidRegex(_) => { + write!(f, "invalid regex in \"name\" key") + } + Self::InvalidMultilingualMessageContents => MultilingualMessageContentsError.fmt(f), + Self::UnexpectedType(expected_type, yaml_object_type) => write!( + f, + "\"{}\" object must be {}", + yaml_object_type, expected_type + ), + Self::UnexpectedValueType(key, expected_type, yaml_object_type) => write!( + f, + "\"{}\" key in \"{}\" map must be {}", + key, yaml_object_type, expected_type + ), + Self::MissingPlaceholder(sub, placeholder_index) => write!( + f, + "failed to substitute \"{}\" into message, no placeholder {{{}}} was found", + sub, placeholder_index + ), + Self::NonU32Number(i) => { + write!(f, "{} is not valid as a 32-bit unsigned integer", i) + } + Self::DuplicateEntry(id, yaml_object_type) => write!( + f, + "more than one entry exists for {} \"{}\"", + yaml_object_type, id + ), + Self::Other(m) => m.fmt(f), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(super) enum ExpectedType { + String, + Number, + Array, + Map, + MapOrString, + ArrayOrString, +} + +impl std::fmt::Display for ExpectedType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExpectedType::String => write!(f, "a string"), + ExpectedType::Number => write!(f, "a number"), + ExpectedType::Array => write!(f, "an array"), + ExpectedType::Map => write!(f, "a map"), + ExpectedType::MapOrString => write!(f, "a map or string"), + ExpectedType::ArrayOrString => write!(f, "an array or string"), + } + } +} + +/// Represents an error encountered while parsing and compiling a regex plugin +/// name. +#[derive(Clone, Debug)] +pub struct RegexError(Box); + +impl std::fmt::Display for RegexError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "encountered a regex error: {}", self.0) + } +} + +impl std::error::Error for RegexError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +impl From> for RegexError { + fn from(value: Box) -> Self { + Self(value) + } +} + +/// Represents an error encountered while loading metadata from a file. +#[derive(Debug)] +pub struct LoadMetadataError { + path: PathBuf, + reason: MetadataDocumentParsingError, +} + +impl LoadMetadataError { + pub(super) fn new(path: PathBuf, reason: MetadataDocumentParsingError) -> Self { + Self { + path: path.to_path_buf(), + reason, + } + } + + pub(super) fn from_io_error(path: PathBuf, error: std::io::Error) -> Self { + Self { + path: path.to_path_buf(), + reason: MetadataDocumentParsingError::IoError(error), + } + } +} + +impl std::fmt::Display for LoadMetadataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "failed to parse the file at \"{}\"", self.path.display()) + } +} + +impl std::error::Error for LoadMetadataError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.reason) + } +} + +#[derive(Debug)] +#[non_exhaustive] +pub(super) enum MetadataDocumentParsingError { + PathNotFound, + NoDocuments, + MoreThanOneDocument(usize), + IoError(std::io::Error), + MetadataParsingError(ParseMetadataError), + YamlMergeKeyError(YamlMergeKeyError), +} + +impl std::fmt::Display for MetadataDocumentParsingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PathNotFound => write!(f, "path not found"), + Self::NoDocuments => write!(f, "no YAML document found"), + Self::MoreThanOneDocument(n) => write!(f, "expected 1 YAML document, found {}", n), + Self::IoError(_) => write!(f, "an I/O error occurred"), + Self::MetadataParsingError(_) => write!(f, "a metadata parsing error occurred"), + Self::YamlMergeKeyError(_) => { + write!(f, "an error occurred while resolving YAML merge keys",) + } + } + } +} + +impl std::error::Error for MetadataDocumentParsingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::PathNotFound => None, + Self::NoDocuments => None, + Self::MoreThanOneDocument(_) => None, + Self::IoError(e) => Some(e), + Self::MetadataParsingError(e) => Some(e), + Self::YamlMergeKeyError(e) => Some(e), + } + } +} + +impl From for MetadataDocumentParsingError { + fn from(value: std::io::Error) -> Self { + MetadataDocumentParsingError::IoError(value) + } +} + +impl From for MetadataDocumentParsingError { + fn from(value: ParseMetadataError) -> Self { + MetadataDocumentParsingError::MetadataParsingError(value) + } +} + +impl From for MetadataDocumentParsingError { + fn from(value: YamlMergeKeyError) -> Self { + MetadataDocumentParsingError::YamlMergeKeyError(value) + } +} + +impl From for MetadataDocumentParsingError { + fn from(value: saphyr::ScanError) -> Self { + Self::MetadataParsingError(value.into()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(super) struct YamlMergeKeyError { + value: Box, +} + +impl YamlMergeKeyError { + pub(super) fn new(value: saphyr::MarkedYaml) -> Self { + YamlMergeKeyError { + value: Box::new(value), + } + } +} + +impl std::fmt::Display for YamlMergeKeyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut output = String::new(); + + let yaml = to_yaml(&self.value); + + if saphyr::YamlEmitter::new(&mut output).dump(&yaml).is_ok() { + write!( + f, + "invalid YAML merge key value at line {} column {}: {}", + self.value.span.start.line(), + self.value.span.start.col(), + output + ) + } else { + write!( + f, + "invalid YAML merge key value at line {} column {}: {:?}", + self.value.span.start.line(), + self.value.span.start.col(), + self.value + ) + } + } +} + +impl std::error::Error for YamlMergeKeyError {} + +/// Represents an error that occurred while trying to write metadata to a file. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct WriteMetadataError { + path: PathBuf, + reason: WriteMetadataErrorReason, +} + +impl WriteMetadataError { + pub(crate) fn new(path: PathBuf, reason: WriteMetadataErrorReason) -> Self { + Self { path, reason } + } +} + +impl std::fmt::Display for WriteMetadataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.reason { + WriteMetadataErrorReason::ParentDirectoryNotFound => write!( + f, + "the parent directory of the path \"{}\" was not found", + self.path.display() + ), + WriteMetadataErrorReason::PathAlreadyExists => { + write!(f, "the path \"{}\" already exists", self.path.display()) + } + } + } +} + +impl std::error::Error for WriteMetadataError {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(crate) enum WriteMetadataErrorReason { + ParentDirectoryNotFound, + PathAlreadyExists, +} diff --git a/src/metadata/file.rs b/src/metadata/file.rs index b157a617..6dce45e8 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -1,14 +1,14 @@ -use std::str::FromStr; - -use loot_condition_interpreter::Expression; use saphyr::{MarkedYaml, YamlData}; use unicase::UniCase; -use crate::error::{GeneralError, InvalidMultilingualMessageContents, YamlParseError}; - use super::{ + error::ExpectedType, + error::{MultilingualMessageContentsError, ParseMetadataError}, message::{MessageContent, parse_message_contents_yaml, validate_message_contents}, - yaml::{YamlObjectType, as_string_node, get_required_string_value, get_string_value}, + yaml::{ + YamlObjectType, as_string_node, get_required_string_value, get_string_value, + parse_condition, + }, }; /// Represents a file in a game's Data folder, including files in @@ -52,7 +52,7 @@ impl File { pub fn with_detail( mut self, detail: Vec, - ) -> Result { + ) -> Result { validate_message_contents(&detail)?; self.detail = detail; Ok(self) @@ -108,12 +108,12 @@ impl AsRef for &Filename { impl std::fmt::Display for Filename { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) + self.0.fmt(f) } } impl TryFrom<&MarkedYaml> for File { - type Error = GeneralError; + type Error = ParseMetadataError; fn try_from(value: &MarkedYaml) -> Result { match &value.data { @@ -138,26 +138,20 @@ impl TryFrom<&MarkedYaml> for File { None => Vec::new(), }; - let condition = match get_string_value(h, "condition", YamlObjectType::File)? { - Some(n) => { - Expression::from_str(n)?; - Some(n.to_string()) - } - None => None, - }; + let condition = parse_condition(h, YamlObjectType::File)?; Ok(File { name: Filename(UniCase::new(name.to_string())), - display_name: display_name.map(|s| s.to_string()), + display_name: display_name.map(|(_, s)| s.to_string()), detail, condition, }) } - _ => Err(YamlParseError::new( + _ => Err(ParseMetadataError::unexpected_type( value.span.start, - "'file' object must be a map or string".into(), - ) - .into()), + YamlObjectType::File, + ExpectedType::MapOrString, + )), } } } diff --git a/src/metadata/group.rs b/src/metadata/group.rs index 2dd1750d..61cb9972 100644 --- a/src/metadata/group.rs +++ b/src/metadata/group.rs @@ -1,9 +1,9 @@ use saphyr::MarkedYaml; +use super::error::ParseMetadataError; use super::yaml::{ YamlObjectType, get_as_hash, get_required_string_value, get_string_value, get_strings_vec_value, }; -use crate::error::YamlParseError; /// Represents a group to which plugin metadata objects can belong. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -70,7 +70,7 @@ impl std::default::Default for Group { } impl TryFrom<&MarkedYaml> for Group { - type Error = YamlParseError; + type Error = ParseMetadataError; fn try_from(value: &MarkedYaml) -> Result { let hash = get_as_hash(value, YamlObjectType::Group)?; @@ -84,7 +84,7 @@ impl TryFrom<&MarkedYaml> for Group { Ok(Group { name: name.to_string(), - description: description.map(|d| d.to_string()), + description: description.map(|d| d.1.to_string()), after_groups: after.iter().map(|a| a.to_string()).collect(), }) } diff --git a/src/metadata/location.rs b/src/metadata/location.rs index 99572e93..b4507a08 100644 --- a/src/metadata/location.rs +++ b/src/metadata/location.rs @@ -1,6 +1,7 @@ use saphyr::{MarkedYaml, YamlData}; -use crate::error::{GeneralError, YamlParseError}; +use super::error::ExpectedType; +use super::error::ParseMetadataError; use super::yaml::{YamlObjectType, get_required_string_value}; @@ -40,7 +41,7 @@ impl Location { } impl TryFrom<&MarkedYaml> for Location { - type Error = GeneralError; + type Error = ParseMetadataError; fn try_from(value: &MarkedYaml) -> Result { match &value.data { @@ -67,11 +68,11 @@ impl TryFrom<&MarkedYaml> for Location { name: Some(name.to_string()), }) } - _ => Err(YamlParseError::new( + _ => Err(ParseMetadataError::unexpected_type( value.span.start, - "'tag' object must be a map or string".into(), - ) - .into()), + YamlObjectType::Location, + ExpectedType::MapOrString, + )), } } } diff --git a/src/metadata/message.rs b/src/metadata/message.rs index e260eaeb..742a5a2f 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -1,13 +1,15 @@ -use std::str::FromStr; - -use loot_condition_interpreter::Expression; use saphyr::{MarkedYaml, YamlData}; -use super::yaml::{ - YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_string_value, - get_strings_vec_value, +use super::{ + error::{ + ExpectedType, MetadataParsingErrorReason, MultilingualMessageContentsError, + ParseMetadataError, + }, + yaml::{ + YamlObjectType, as_string_node, get_as_hash, get_required_string_value, + get_strings_vec_value, parse_condition, + }, }; -use crate::error::{GeneralError, InvalidMultilingualMessageContents, YamlParseError}; /// Codes used to indicate the type of a message. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -163,7 +165,7 @@ impl Message { pub fn multilingual( message_type: MessageType, content: Vec, - ) -> Result { + ) -> Result { validate_message_contents(&content)?; Ok(Self { @@ -198,14 +200,14 @@ impl Message { pub(crate) fn validate_message_contents( contents: &[MessageContent], -) -> Result<(), InvalidMultilingualMessageContents> { +) -> Result<(), MultilingualMessageContentsError> { if contents.len() > 1 { let english_string_exists = contents .iter() .any(|c| c.language == MessageContent::DEFAULT_LANGUAGE); if !english_string_exists { - return Err(InvalidMultilingualMessageContents {}); + return Err(MultilingualMessageContentsError {}); } } @@ -213,7 +215,7 @@ pub(crate) fn validate_message_contents( } impl TryFrom<&MarkedYaml> for MessageContent { - type Error = YamlParseError; + type Error = ParseMetadataError; fn try_from(value: &MarkedYaml) -> Result { let hash = get_as_hash(value, YamlObjectType::MessageContent)?; @@ -233,9 +235,9 @@ impl TryFrom<&MarkedYaml> for MessageContent { pub(crate) fn parse_message_contents_yaml( value: &MarkedYaml, - key: &str, + key: &'static str, parent_yaml_type: YamlObjectType, -) -> Result, GeneralError> { +) -> Result, ParseMetadataError> { let contents = match &value.data { YamlData::String(s) => { vec![MessageContent { @@ -248,24 +250,27 @@ pub(crate) fn parse_message_contents_yaml( .map(MessageContent::try_from) .collect::, _>>()?, _ => { - return Err(YamlParseError::new( + return Err(ParseMetadataError::unexpected_value_type( value.span.start, - format!( - "'{}' key in '{}' map is not a list or string", - key, parent_yaml_type - ), - ) - .into()); + key, + parent_yaml_type, + ExpectedType::ArrayOrString, + )); } }; - validate_message_contents(&contents)?; - - Ok(contents) + if validate_message_contents(&contents).is_err() { + Err(ParseMetadataError::new( + value.span.start, + MetadataParsingErrorReason::InvalidMultilingualMessageContents, + )) + } else { + Ok(contents) + } } impl TryFrom<&MarkedYaml> for Message { - type Error = GeneralError; + type Error = ParseMetadataError; fn try_from(value: &MarkedYaml) -> Result { let hash = get_as_hash(value, YamlObjectType::Message)?; @@ -281,12 +286,11 @@ impl TryFrom<&MarkedYaml> for Message { let mut content = match hash.get(&as_string_node("content")) { Some(n) => parse_message_contents_yaml(n, "content", YamlObjectType::Message)?, None => { - return Err(YamlParseError::missing_key( + return Err(ParseMetadataError::missing_key( value.span.start, "content", YamlObjectType::Message, - ) - .into()); + )); } }; @@ -297,7 +301,10 @@ impl TryFrom<&MarkedYaml> for Message { for (index, sub) in subs.iter().enumerate() { let placeholder = format!("{}", index); if !mc.text.contains(&placeholder) { - return Err(YamlParseError::new(value.span.start, format!("Failed to substitute \"{}\" into message, no placeholder \"{}\" was found", sub, placeholder)).into()); + return Err(ParseMetadataError::new( + value.span.start, + MetadataParsingErrorReason::MissingPlaceholder(sub.to_string(), index), + )); } mc.text = mc.text.replace(&placeholder, sub); @@ -305,13 +312,7 @@ impl TryFrom<&MarkedYaml> for Message { } } - let condition = match get_string_value(hash, "condition", YamlObjectType::Message)? { - Some(c) => { - Expression::from_str(c)?; - Some(c.to_string()) - } - None => None, - }; + let condition = parse_condition(hash, YamlObjectType::Message)?; Ok(Message { message_type, diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index e7bbc5aa..16c855a4 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -6,9 +6,11 @@ use std::{ use saphyr::{MarkedYaml, YamlData}; -use crate::error::{FileAccessError, GeneralError, YamlMergeKeyError, YamlParseError}; - use super::{ + error::{ + ExpectedType, LoadMetadataError, MetadataDocumentParsingError, ParseMetadataError, + RegexError, WriteMetadataError, YamlMergeKeyError, + }, file::Filename, group::Group, message::Message, @@ -28,12 +30,21 @@ pub struct MetadataDocument { } impl MetadataDocument { - pub fn load(&mut self, file_path: &Path) -> Result<(), GeneralError> { + pub fn load(&mut self, file_path: &Path) -> Result<(), LoadMetadataError> { + if !file_path.exists() { + return Err(LoadMetadataError::new( + file_path.into(), + MetadataDocumentParsingError::PathNotFound, + )); + } + log::trace!("Loading file: {:?}", file_path); - let content = std::fs::read_to_string(file_path)?; + let content = std::fs::read_to_string(file_path) + .map_err(|e| LoadMetadataError::from_io_error(file_path.into(), e))?; - self.load_from_str(&content)?; + self.load_from_str(&content) + .map_err(|e| LoadMetadataError::new(file_path.into(), e))?; log::trace!( "Successfully loaded metadata from file at \"{:?}\".", @@ -47,13 +58,31 @@ impl MetadataDocument { &mut self, masterlist_path: &Path, prelude_path: &Path, - ) -> Result<(), GeneralError> { - let masterlist = std::fs::read_to_string(masterlist_path)?; - let prelude = std::fs::read_to_string(prelude_path)?; + ) -> Result<(), LoadMetadataError> { + if !masterlist_path.exists() { + return Err(LoadMetadataError::new( + masterlist_path.into(), + MetadataDocumentParsingError::PathNotFound, + )); + } + + if !prelude_path.exists() { + return Err(LoadMetadataError::new( + prelude_path.into(), + MetadataDocumentParsingError::PathNotFound, + )); + } + + let masterlist = std::fs::read_to_string(masterlist_path) + .map_err(|e| LoadMetadataError::from_io_error(masterlist_path.into(), e))?; + + let prelude = std::fs::read_to_string(prelude_path) + .map_err(|e| LoadMetadataError::from_io_error(masterlist_path.into(), e))?; let masterlist = replace_prelude(masterlist, prelude); - self.load_from_str(&masterlist)?; + self.load_from_str(&masterlist) + .map_err(|e| LoadMetadataError::new(masterlist_path.into(), e))?; log::trace!( "Successfully loaded metadata from file at \"{:?}\".", @@ -63,27 +92,26 @@ impl MetadataDocument { Ok(()) } - fn load_from_str(&mut self, string: &str) -> Result<(), GeneralError> { + fn load_from_str(&mut self, string: &str) -> Result<(), MetadataDocumentParsingError> { let mut docs = MarkedYaml::load_from_str(string)?; let doc = docs .pop() - .ok_or_else(|| FileAccessError::new("No documents in the loaded YAML".into()))?; + .ok_or_else(|| MetadataDocumentParsingError::NoDocuments)?; if !docs.is_empty() { - return Err(FileAccessError::new(format!( - "YAML file contained more than one document, found {}", - docs.len() + 1 - )) - .into()); + return Err(MetadataDocumentParsingError::MoreThanOneDocument( + docs.len() + 1, + )); } let doc = process_merge_keys(doc)?; let doc = match doc.data { YamlData::Hash(h) => h, _ => { - return Err(YamlParseError::new( + return Err(ParseMetadataError::unexpected_type( doc.span.start, - "The root of the YAML document is not a map.".into(), + YamlObjectType::MetadataDocument, + ExpectedType::Map, ) .into()); } @@ -91,17 +119,18 @@ impl MetadataDocument { let mut plugins: HashMap = HashMap::new(); let mut regex_plugins: Vec = Vec::new(); - for plugin in get_as_slice(&doc, "plugins", YamlObjectType::MetadataDocument)? { - let plugin = PluginMetadata::try_from(plugin)?; + for plugin_yaml in get_as_slice(&doc, "plugins", YamlObjectType::MetadataDocument)? { + let plugin = PluginMetadata::try_from(plugin_yaml)?; if plugin.is_regex_plugin() { regex_plugins.push(plugin); } else { let filename = Filename::new(plugin.name().to_string()); if plugins.contains_key(&filename) { - return Err(FileAccessError::new(format!( - "More than one entry exists for plugin \"{}\"", - plugin.name() - )) + return Err(ParseMetadataError::duplicate_entry( + plugin_yaml.span.start, + plugin.name().to_string(), + YamlObjectType::PluginMetadata, + ) .into()); } plugins.insert(filename, plugin); @@ -116,21 +145,23 @@ impl MetadataDocument { let mut bash_tags = Vec::new(); let mut str_set = HashSet::new(); for bash_tag_yaml in get_as_slice(&doc, "bash_tags", YamlObjectType::MetadataDocument)? { - let bash_tag = match bash_tag_yaml.data.as_str() { + let bash_tag: &str = match bash_tag_yaml.data.as_str() { Some(b) => b, None => { - return Err(YamlParseError::new( + return Err(ParseMetadataError::unexpected_type( bash_tag_yaml.span.start, - "Found a non-string Bash Tag.".into(), + YamlObjectType::BashTagsElement, + ExpectedType::String, ) .into()); } }; if str_set.contains(bash_tag) { - return Err(YamlParseError::new( + return Err(ParseMetadataError::duplicate_entry( bash_tag_yaml.span.start, - format!("More than one entry exists for Bash Tag \"{}\"", bash_tag), + bash_tag.to_string(), + YamlObjectType::BashTagsElement, ) .into()); } @@ -146,9 +177,10 @@ impl MetadataDocument { let name = group.name().to_string(); if group_names.contains(&name) { - return Err(YamlParseError::new( + return Err(ParseMetadataError::duplicate_entry( group_yaml.span.start, - format!("More than one entry exists for group \"{}\"", group.name()), + group.name().to_string(), + YamlObjectType::Group, ) .into()); } @@ -170,7 +202,7 @@ impl MetadataDocument { Ok(()) } - pub fn save(&self, file_path: &Path) -> Result<(), GeneralError> { + pub fn save(&self, file_path: &Path) -> Result<(), WriteMetadataError> { // let mut hash = saphyr::Hash::new(); // hash.insert( @@ -208,7 +240,7 @@ impl MetadataDocument { self.plugins.values().chain(self.regex_plugins.iter()) } - pub fn find_plugin(&self, plugin_name: &str) -> Result, GeneralError> { + pub fn find_plugin(&self, plugin_name: &str) -> Result, RegexError> { let mut metadata = match self.plugins.get(&Filename::new(plugin_name.to_string())) { Some(m) => m.clone(), None => PluginMetadata::new(plugin_name)?, @@ -254,7 +286,6 @@ impl MetadataDocument { fn process_merge_keys(mut yaml: MarkedYaml) -> Result { match yaml.data { - YamlData::Alias(_) => panic!("Alias encountered!"), YamlData::Array(a) => { yaml.data = merge_array_elements(a).map(YamlData::Array)?; Ok(yaml) diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index 3143ecd5..98a2e02e 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -1,3 +1,5 @@ +//! Holds all types related to LOOT metadata. +pub mod error; mod file; mod group; mod location; diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index 5443a23a..ac96e13e 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -1,8 +1,7 @@ use saphyr::MarkedYaml; -use crate::error::{GeneralError, InvalidMultilingualMessageContents, YamlParseError}; - use super::{ + error::{MultilingualMessageContentsError, ParseMetadataError}, message::{MessageContent, parse_message_contents_yaml, validate_message_contents}, yaml::{YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_u32_value}, }; @@ -59,7 +58,7 @@ impl PluginCleaningData { pub fn with_detail( mut self, detail: Vec, - ) -> Result { + ) -> Result { validate_message_contents(&detail)?; self.detail = detail; Ok(self) @@ -103,7 +102,7 @@ impl PluginCleaningData { } impl TryFrom<&MarkedYaml> for PluginCleaningData { - type Error = GeneralError; + type Error = ParseMetadataError; fn try_from(value: &MarkedYaml) -> Result { let hash = get_as_hash(value, YamlObjectType::PluginCleaningData)?; @@ -111,12 +110,11 @@ impl TryFrom<&MarkedYaml> for PluginCleaningData { let crc = match get_u32_value(hash, "crc", YamlObjectType::PluginCleaningData)? { Some(n) => n, None => { - return Err(YamlParseError::missing_key( + return Err(ParseMetadataError::missing_key( value.span.start, "crc", YamlObjectType::PluginCleaningData, - ) - .into()); + )); } }; diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index f53a445e..bc7eb83b 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -1,12 +1,10 @@ use fancy_regex::Regex; use saphyr::MarkedYaml; -use crate::{ - error::{GeneralError, YamlParseError}, - regex, -}; +use crate::regex; use super::{ + error::{MetadataParsingErrorReason, ParseMetadataError, RegexError}, file::File, location::Location, message::Message, @@ -37,7 +35,7 @@ pub struct PluginMetadata { impl PluginMetadata { /// Construct a [PluginMetadata] object with no metadata for a plugin with /// the given filename. - pub fn new(name: &str) -> Result> { + pub fn new(name: &str) -> Result { Ok(Self { name: PluginName::new(name)?, ..Default::default() @@ -299,7 +297,7 @@ fn merge_vecs(target: &mut Vec, source: &[T]) { } impl TryFrom<&MarkedYaml> for PluginMetadata { - type Error = GeneralError; + type Error = ParseMetadataError; fn try_from(value: &MarkedYaml) -> Result { let hash = get_as_hash(value, YamlObjectType::PluginMetadata)?; @@ -313,11 +311,10 @@ impl TryFrom<&MarkedYaml> for PluginMetadata { let name = match PluginName::new(name) { Ok(n) => n, Err(e) => { - return Err(YamlParseError::new( + return Err(ParseMetadataError::new( value.span.start, - format!("Invalid regex in \"name\" key: {}", e), - ) - .into()); + MetadataParsingErrorReason::InvalidRegex(e), + )); } }; @@ -334,7 +331,7 @@ impl TryFrom<&MarkedYaml> for PluginMetadata { Ok(PluginMetadata { name, - group: group.map(|g| g.to_string()), + group: group.map(|g| g.1.to_string()), load_after, requirements, incompatibilities, @@ -347,15 +344,12 @@ impl TryFrom<&MarkedYaml> for PluginMetadata { } } -fn get_vec<'a, T: TryFrom<&'a MarkedYaml, Error = GeneralError>>( +fn get_vec<'a, T: TryFrom<&'a MarkedYaml, Error = impl Into>>( hash: &'a saphyr::AnnotatedHash, - key: &str, -) -> Result, GeneralError> -where - GeneralError: From<>::Error>, -{ + key: &'static str, +) -> Result, ParseMetadataError> { get_as_slice(hash, key, YamlObjectType::PluginMetadata)? .iter() - .map(|e| T::try_from(e)) + .map(|e| T::try_from(e).map_err(Into::into)) .collect::, _>>() } diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index 5bde1e38..99d21d70 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -1,10 +1,8 @@ -use std::str::FromStr; - -use loot_condition_interpreter::Expression; use saphyr::YamlData; -use super::yaml::{YamlObjectType, get_required_string_value, get_string_value}; -use crate::error::{GeneralError, YamlParseError}; +use super::error::ExpectedType; +use super::error::ParseMetadataError; +use super::yaml::{YamlObjectType, get_required_string_value, parse_condition}; /// Represents whether a Bash Tag suggestion is for addition or removal. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -57,7 +55,7 @@ impl Tag { } impl TryFrom<&saphyr::MarkedYaml> for Tag { - type Error = GeneralError; + type Error = ParseMetadataError; fn try_from(value: &saphyr::MarkedYaml) -> Result { match &value.data { @@ -73,13 +71,7 @@ impl TryFrom<&saphyr::MarkedYaml> for Tag { let name = get_required_string_value(value.span.start, h, "name", YamlObjectType::Tag)?; - let condition = match get_string_value(h, "condition", YamlObjectType::Tag)? { - Some(n) => { - Expression::from_str(n)?; - Some(n.to_string()) - } - None => None, - }; + let condition = parse_condition(h, YamlObjectType::Tag)?; let (name, suggestion) = name_and_suggestion(name); Ok(Tag { @@ -88,11 +80,11 @@ impl TryFrom<&saphyr::MarkedYaml> for Tag { condition, }) } - _ => Err(YamlParseError::new( + _ => Err(ParseMetadataError::unexpected_type( value.span.start, - "'tag' object must be a map or string".into(), - ) - .into()), + YamlObjectType::Tag, + ExpectedType::MapOrString, + )), } } } diff --git a/src/metadata/yaml.rs b/src/metadata/yaml.rs index e7feca4f..7f3ea765 100644 --- a/src/metadata/yaml.rs +++ b/src/metadata/yaml.rs @@ -1,6 +1,9 @@ +use std::str::FromStr; + +use loot_condition_interpreter::Expression; use saphyr::{AnnotatedArray, AnnotatedHash, MarkedYaml, Marker, Yaml, YamlData}; -use crate::error::{GeneralError, YamlParseError}; +use super::error::{ExpectedType, MetadataParsingErrorReason, ParseMetadataError}; #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub enum YamlObjectType { @@ -13,6 +16,7 @@ pub enum YamlObjectType { PluginMetadata, Tag, MetadataDocument, + BashTagsElement, } impl std::fmt::Display for YamlObjectType { @@ -27,6 +31,7 @@ impl std::fmt::Display for YamlObjectType { YamlObjectType::PluginMetadata => write!(f, "plugin metadata"), YamlObjectType::Tag => write!(f, "tag"), YamlObjectType::MetadataDocument => write!(f, "metadata document"), + YamlObjectType::BashTagsElement => write!(f, "bash tags"), } } } @@ -64,15 +69,17 @@ pub fn as_string_node(value: &str) -> MarkedYaml { pub fn get_string_value<'a>( hash: &'a AnnotatedHash, - key: &str, + key: &'static str, yaml_type: YamlObjectType, -) -> Result, YamlParseError> { +) -> Result, ParseMetadataError> { match hash.get(&as_string_node(key)) { Some(n) => match n.data.as_str() { - Some(n) => Ok(Some(n)), - None => Err(YamlParseError::new( + Some(s) => Ok(Some((n.span.start, s))), + None => Err(ParseMetadataError::unexpected_value_type( n.span.start, - format!("'{}' key in '{}' map is not a string", key, yaml_type), + key, + yaml_type, + ExpectedType::String, )), }, None => Ok(None), @@ -82,35 +89,39 @@ pub fn get_string_value<'a>( pub fn get_required_string_value<'a>( marker: Marker, hash: &'a AnnotatedHash, - key: &str, + key: &'static str, yaml_type: YamlObjectType, -) -> Result<&'a str, YamlParseError> { +) -> Result<&'a str, ParseMetadataError> { match get_string_value(hash, key, yaml_type)? { - Some(n) => Ok(n), - None => Err(YamlParseError::missing_key(marker, key, yaml_type)), + Some(n) => Ok(n.1), + None => Err(ParseMetadataError::missing_key(marker, key, yaml_type)), } } pub fn get_strings_vec_value<'a>( hash: &'a AnnotatedHash, - key: &str, + key: &'static str, yaml_type: YamlObjectType, -) -> Result, YamlParseError> { +) -> Result, ParseMetadataError> { match hash.get(&as_string_node(key)) { Some(n) => match n.data.as_vec() { Some(n) => n .iter() .map(|e| match e.data.as_str() { Some(s) => Ok(s), - None => Err(YamlParseError::new( + None => Err(ParseMetadataError::unexpected_value_type( e.span.start, - "Element in list is not a string".into(), + key, + yaml_type, + ExpectedType::String, )), }) .collect::, _>>(), - None => Err(YamlParseError::new( + None => Err(ParseMetadataError::unexpected_value_type( n.span.start, - format!("'{}' key in '{}' map is not a list", key, yaml_type), + key, + yaml_type, + ExpectedType::Array, )), }, None => Ok(Vec::new()), @@ -120,29 +131,33 @@ pub fn get_strings_vec_value<'a>( pub fn get_as_hash( value: &MarkedYaml, yaml_type: YamlObjectType, -) -> Result<&AnnotatedHash, YamlParseError> { +) -> Result<&AnnotatedHash, ParseMetadataError> { match value.data.as_hash() { Some(h) => Ok(h), - None => Err(YamlParseError::new( + None => Err(ParseMetadataError::unexpected_type( value.span.start, - format!("'{}' object must be a map", yaml_type), + yaml_type, + ExpectedType::Map, )), } } pub fn get_u32_value( hash: &AnnotatedHash, - key: &str, + key: &'static str, yaml_type: YamlObjectType, -) -> Result, GeneralError> { +) -> Result, ParseMetadataError> { match hash.get(&as_string_node(key)) { Some(n) => match n.data.as_i64() { - Some(n) => Ok(Some(n.try_into()?)), - None => Err(YamlParseError::new( + Some(i) => i.try_into().map(Some).map_err(|_| { + ParseMetadataError::new(n.span.start, MetadataParsingErrorReason::NonU32Number(i)) + }), + None => Err(ParseMetadataError::unexpected_value_type( n.span.start, - format!("'{}' key in '{}' map is not a string", key, yaml_type), - ) - .into()), + key, + yaml_type, + ExpectedType::Number, + )), }, None => Ok(None), } @@ -150,18 +165,36 @@ pub fn get_u32_value( pub fn get_as_slice<'a>( hash: &'a saphyr::AnnotatedHash, - key: &str, + key: &'static str, yaml_type: YamlObjectType, -) -> Result<&'a [MarkedYaml], YamlParseError> { +) -> Result<&'a [MarkedYaml], ParseMetadataError> { if let Some(value) = hash.get(&as_string_node(key)) { match value.data.as_vec() { Some(n) => Ok(n.as_slice()), - None => Err(YamlParseError::new( + None => Err(ParseMetadataError::unexpected_value_type( value.span.start, - format!("'{}' key in '{}' map is not an array", key, yaml_type), + key, + yaml_type, + ExpectedType::Array, )), } } else { Ok(&[]) } } + +pub fn parse_condition( + hash: &saphyr::AnnotatedHash, + yaml_type: YamlObjectType, +) -> Result, ParseMetadataError> { + match get_string_value(hash, "condition", yaml_type)? { + Some((marker, s)) => { + let s = s.to_string(); + if let Err(e) = Expression::from_str(&s) { + return Err(ParseMetadataError::invalid_condition(marker, s, e)); + } + Ok(Some(s)) + } + None => Ok(None), + } +} diff --git a/src/plugin/error.rs b/src/plugin/error.rs new file mode 100644 index 00000000..78cee23c --- /dev/null +++ b/src/plugin/error.rs @@ -0,0 +1,140 @@ +use std::path::PathBuf; + +/// Represents an error that occurred while reading a parsed plugin's data. +#[derive(Debug)] +pub struct PluginDataError(esplugin::Error); + +impl std::fmt::Display for PluginDataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "failed to read plugin data") + } +} + +impl std::error::Error for PluginDataError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +impl From for PluginDataError { + fn from(value: esplugin::Error) -> Self { + PluginDataError(value) + } +} + +#[derive(Debug)] +#[non_exhaustive] +pub(crate) enum LoadPluginError { + InvalidFilename(InvalidFilenameReason), + IoError(std::io::Error), + ParsingError(esplugin::Error), + RegexError(Box), +} + +impl std::fmt::Display for LoadPluginError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidFilename(i) => i.fmt(f), + Self::IoError(_) => write!(f, "an I/O error occurred"), + Self::ParsingError(_) => write!(f, "failed to parse plugin data"), + Self::RegexError(_) => write!(f, "failed while using a regex"), + } + } +} + +impl std::error::Error for LoadPluginError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidFilename(_) => None, + Self::IoError(e) => Some(e), + Self::ParsingError(e) => Some(e), + Self::RegexError(e) => Some(e), + } + } +} + +impl From for LoadPluginError { + fn from(value: std::io::Error) -> Self { + LoadPluginError::IoError(value) + } +} + +impl From for LoadPluginError { + fn from(value: esplugin::Error) -> Self { + LoadPluginError::ParsingError(value) + } +} + +impl From> for LoadPluginError { + fn from(value: Box) -> Self { + LoadPluginError::RegexError(value) + } +} + +#[derive(Debug)] +#[non_exhaustive] +pub(crate) enum InvalidFilenameReason { + Empty, + NonUnicode, + NonUnique, + UnsupportedFileExtension, +} + +impl std::fmt::Display for InvalidFilenameReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty => write!(f, "is empty"), + Self::NonUnicode => write!(f, "cannot be represented in UTF-8"), + Self::NonUnique => write!(f, "is not unique"), + Self::UnsupportedFileExtension => { + write!(f, "does not have a supported plugin file extension") + } + } + } +} + +/// Represents an error that occurred when validating plugins before loading them. +#[derive(Debug)] +pub(crate) struct PluginValidationError { + path: PathBuf, + reason: PluginValidationErrorReason, +} + +impl PluginValidationError { + pub(crate) fn new(path: PathBuf, reason: PluginValidationErrorReason) -> Self { + Self { path, reason } + } + + pub(crate) fn invalid(path: PathBuf, reason: InvalidFilenameReason) -> Self { + Self { + path, + reason: PluginValidationErrorReason::InvalidFilename(reason), + } + } +} + +impl std::fmt::Display for PluginValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.reason { + PluginValidationErrorReason::InvalidFilename(i) => write!( + f, + "the path \"{}\" has a filename that {}", + self.path.display(), + i + ), + PluginValidationErrorReason::InvalidPluginHeader => write!( + f, + "the file at \"{}\" does not have a valid plugin header", + self.path.display() + ), + } + } +} + +impl std::error::Error for PluginValidationError {} + +#[derive(Debug)] +pub(crate) enum PluginValidationErrorReason { + InvalidFilename(InvalidFilenameReason), + InvalidPluginHeader, +} diff --git a/src/plugin.rs b/src/plugin/mod.rs similarity index 88% rename from src/plugin.rs rename to src/plugin/mod.rs index 9a8be83c..54e8611c 100644 --- a/src/plugin.rs +++ b/src/plugin/mod.rs @@ -1,3 +1,5 @@ +pub mod error; + use std::{ collections::{BTreeMap, BTreeSet}, fs::File, @@ -13,10 +15,13 @@ use fancy_regex::Regex; use crate::{ GameType, archive::{assets_in_archives, find_associated_archives}, - error::{GeneralError, InvalidArgumentError}, game::GameCache, regex, }; +use error::{ + InvalidFilenameReason, LoadPluginError, PluginDataError, PluginValidationError, + PluginValidationErrorReason, +}; static VERSION_REGEXES: LazyLock> = LazyLock::new(|| { /* The string below matches the range of version strings supported by @@ -77,7 +82,7 @@ impl Plugin { game_cache: &GameCache, plugin_path: &Path, load_scope: LoadScope, - ) -> Result { + ) -> Result { let name = name_string(plugin_path)?; let (parse_options, crc) = if load_scope == LoadScope::HeaderOnly { @@ -152,7 +157,7 @@ impl Plugin { } /// Get the plugin's masters. - pub fn masters(&self) -> Result, GeneralError> { + pub fn masters(&self) -> Result, PluginDataError> { self.plugin .as_ref() .map(|p| p.masters().map_err(Into::into)) @@ -227,7 +232,7 @@ impl Plugin { } /// Check if the plugin is or would be valid as a light plugin. - pub fn is_valid_as_light_plugin(&self) -> Result { + pub fn is_valid_as_light_plugin(&self) -> Result { self.plugin .as_ref() .map(|p| p.is_valid_as_light_plugin().map_err(Into::into)) @@ -235,7 +240,7 @@ impl Plugin { } /// Check if the plugin is or would be valid as a medium plugin. - pub fn is_valid_as_medium_plugin(&self) -> Result { + pub fn is_valid_as_medium_plugin(&self) -> Result { self.plugin .as_ref() .map(|p| p.is_valid_as_medium_plugin().map_err(Into::into)) @@ -243,7 +248,7 @@ impl Plugin { } /// Check if the plugin is or would be valid as an update plugin. - pub fn is_valid_as_update_plugin(&self) -> Result { + pub fn is_valid_as_update_plugin(&self) -> Result { self.plugin .as_ref() .map(|p| p.is_valid_as_update_plugin().map_err(Into::into)) @@ -269,7 +274,7 @@ impl Plugin { /// /// FormIDs are compared for all games apart from Morrowind, which doesn't /// have FormIDs and so has other identifying data compared. - pub fn do_records_overlap(&self, plugin: &Plugin) -> Result { + pub fn do_records_overlap(&self, plugin: &Plugin) -> Result { if let (Some(plugin), Some(other_plugin)) = (&self.plugin, &plugin.plugin) { plugin.overlaps_with(other_plugin).map_err(Into::into) } else { @@ -277,7 +282,7 @@ impl Plugin { } } - pub(crate) fn override_record_count(&self) -> Result { + pub(crate) fn override_record_count(&self) -> Result { self.plugin .as_ref() .map(|p| p.count_override_records().map_err(Into::into)) @@ -317,26 +322,41 @@ impl Plugin { pub(crate) fn resolve_record_ids( &mut self, plugins_metadata: &[esplugin::PluginMetadata], - ) -> Result<(), esplugin::Error> { + ) -> Result<(), PluginDataError> { if let Some(plugin) = &mut self.plugin { - plugin.resolve_record_ids(plugins_metadata) - } else { - Ok(()) + plugin.resolve_record_ids(plugins_metadata)?; } + Ok(()) } } -pub(crate) fn is_valid_plugin(game_type: GameType, plugin_path: &Path) -> bool { +pub(crate) fn validate_plugin_path_and_header( + game_type: GameType, + plugin_path: &Path, +) -> Result<(), PluginValidationError> { if game_type == GameType::OpenMW && has_ascii_extension(plugin_path, "omwscripts") { - true - } else if has_plugin_file_extension(game_type, plugin_path) { - esplugin::Plugin::is_valid(game_type.into(), plugin_path, ParseOptions::header_only()) + Ok(()) + } else if !has_plugin_file_extension(game_type, plugin_path) { + log::debug!( + "The file \"{}\" is not a valid plugin", + plugin_path.display() + ); + Err(PluginValidationError::invalid( + plugin_path.into(), + InvalidFilenameReason::UnsupportedFileExtension, + )) + } else if esplugin::Plugin::is_valid(game_type.into(), plugin_path, ParseOptions::header_only()) + { + Ok(()) } else { log::debug!( "The file \"{}\" is not a valid plugin", plugin_path.display() ); - false + Err(PluginValidationError::new( + plugin_path.into(), + PluginValidationErrorReason::InvalidPluginHeader, + )) } } @@ -381,22 +401,22 @@ pub(crate) fn has_ascii_extension(path: &Path, extension: &str) -> bool { pub(crate) fn plugins_metadata( plugins: &[Plugin], -) -> Result, esplugin::Error> { +) -> Result, PluginDataError> { let esplugins: Vec<_> = plugins.iter().filter_map(|p| p.plugin.as_ref()).collect(); - esplugin::plugins_metadata(&esplugins) + Ok(esplugin::plugins_metadata(&esplugins)?) } -fn name_string(path: &Path) -> Result { +fn name_string(path: &Path) -> Result { match path.file_name() { Some(f) => match f.to_str() { Some(f) => Ok(f.to_string()), - None => Err(InvalidArgumentError { - message: format!("The path \"{:?}\" has a non-Unicode filename", path), - }), + None => Err(LoadPluginError::InvalidFilename( + InvalidFilenameReason::NonUnicode, + )), }, - None => Err(InvalidArgumentError { - message: format!("The path \"{}\" has no filename", path.display()), - }), + None => Err(LoadPluginError::InvalidFilename( + InvalidFilenameReason::Empty, + )), } } diff --git a/src/sorting/error.rs b/src/sorting/error.rs new file mode 100644 index 00000000..2115c840 --- /dev/null +++ b/src/sorting/error.rs @@ -0,0 +1,293 @@ +use std::fmt::Display; + +use crate::{Vertex, plugin::error::PluginDataError}; + +#[derive(Clone, Default, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct UndefinedGroupError { + group_name: String, +} + +impl UndefinedGroupError { + pub(crate) fn new(group_name: String) -> Self { + Self { group_name } + } + + pub(crate) fn into_group_name(self) -> String { + self.group_name + } +} + +impl Display for UndefinedGroupError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "the group \"{}\" does not exist", self.group_name) + } +} + +impl std::error::Error for UndefinedGroupError {} + +#[derive(Clone, Default, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct CyclicInteractionError { + cycle: Vec, +} + +impl CyclicInteractionError { + pub(crate) fn new(cycle: Vec) -> Self { + Self { cycle } + } + + pub(crate) fn into_cycle(self) -> Vec { + self.cycle + } +} + +impl Display for CyclicInteractionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let cycle = display_cycle(&self.cycle); + write!(f, "cyclic interaction detected: {}", cycle) + } +} + +impl std::error::Error for CyclicInteractionError {} + +pub(crate) fn display_cycle(cycle: &[Vertex]) -> String { + cycle + .iter() + .map(|v| { + if let Some(edge_type) = v.out_edge_type() { + format!("{} --[{}]-> ", v.name(), edge_type) + } else { + v.name().to_string() + } + }) + .chain(cycle.first().iter().map(|v| v.name().to_string())) + .collect() +} + +/// Represents an error that occurred while trying to get the path between two +/// groups across the graph formed from group metadata. +#[derive(Debug)] +pub enum GroupsPathError { + UndefinedGroup(String), + CycleFound(Vec), + PathfindingError(Box), +} + +impl Display for GroupsPathError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UndefinedGroup(g) => write!(f, "the group \"{}\" does not exist", g), + Self::CycleFound(c) => write!(f, "found a cycle: {}", display_cycle(c)), + Self::PathfindingError(_) => write!(f, "failed to find a path in the groups graph"), + } + } +} + +impl std::error::Error for GroupsPathError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::PathfindingError(e) => Some(e.as_ref()), + _ => None, + } + } +} + +impl From for GroupsPathError { + fn from(value: BuildGroupsGraphError) -> Self { + match value { + BuildGroupsGraphError::CycleFound(e) => Self::CycleFound(e.into_cycle()), + BuildGroupsGraphError::UndefinedGroup(e) => Self::UndefinedGroup(e.into_group_name()), + } + } +} + +impl From for GroupsPathError { + fn from(value: UndefinedGroupError) -> Self { + Self::UndefinedGroup(value.into_group_name()) + } +} + +impl From for GroupsPathError { + fn from(value: PathfindingError) -> Self { + Self::PathfindingError(Box::new(value)) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(crate) enum BuildGroupsGraphError { + UndefinedGroup(UndefinedGroupError), + CycleFound(CyclicInteractionError), +} + +impl Display for BuildGroupsGraphError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UndefinedGroup(_) => write!(f, "encountered an undefined group"), + Self::CycleFound(_) => write!(f, "the groups graph is cyclic"), + } + } +} + +impl std::error::Error for BuildGroupsGraphError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::UndefinedGroup(e) => Some(e), + Self::CycleFound(e) => Some(e), + } + } +} + +impl From for BuildGroupsGraphError { + fn from(value: UndefinedGroupError) -> Self { + BuildGroupsGraphError::UndefinedGroup(value) + } +} + +impl From for BuildGroupsGraphError { + fn from(value: CyclicInteractionError) -> Self { + BuildGroupsGraphError::CycleFound(value) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(crate) enum PathfindingError { + NegativeCycle, + PrecedingNodeNotFound(String), + FollowingNodeNotFound(String), + EdgeNotFound { + from_group: String, + to_group: String, + }, +} + +impl Display for PathfindingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NegativeCycle => write!( + f, + "encountered a cycle of user-defined \"load after\" relationships" + ), + Self::PrecedingNodeNotFound(n) => write!( + f, + "unexpectedly could not find the node before \"{}\" in the path that was found", + n + ), + Self::FollowingNodeNotFound(n) => write!( + f, + "unexpectedly could not find the node after \"{}\" in the path that was found", + n + ), + Self::EdgeNotFound { + from_group, + to_group, + } => write!( + f, + "unexpectedly could not find the edge going from \"{}\" to \"{}\"", + from_group, to_group + ), + } + } +} + +impl std::error::Error for PathfindingError {} + +#[derive(Debug)] +pub(crate) enum PluginGraphValidationError { + CycleFound(CyclicInteractionError), + PluginDataError(PluginDataError), +} + +impl Display for PluginGraphValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CycleFound(_) => write!(f, "found a cycle in the plugin graph"), + Self::PluginDataError(_) => write!(f, "failed to read plugin data"), + } + } +} + +impl std::error::Error for PluginGraphValidationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::CycleFound(e) => Some(e), + Self::PluginDataError(e) => Some(e), + } + } +} + +impl From for PluginGraphValidationError { + fn from(value: CyclicInteractionError) -> Self { + PluginGraphValidationError::CycleFound(value) + } +} + +impl From for PluginGraphValidationError { + fn from(value: PluginDataError) -> Self { + PluginGraphValidationError::PluginDataError(value) + } +} + +#[derive(Debug)] +pub(crate) enum SortingError { + ValidationError(PluginGraphValidationError), + UndefinedGroup(UndefinedGroupError), + CycleFound(CyclicInteractionError), + CycleInvolving(String), + PluginDataError(PluginDataError), + PathfindingError(PathfindingError), +} + +impl Display for SortingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ValidationError(_) => write!(f, "plugin graph validation failed"), + Self::UndefinedGroup(_) => write!(f, "found an undefined group"), + Self::CycleFound(_) => write!(f, "found a cycle"), + Self::CycleInvolving(n) => write!(f, "found a cycle involving \"{}\"", n), + Self::PluginDataError(_) => write!(f, "failed to read plugin data"), + Self::PathfindingError(_) => write!(f, "failed to find a path in the plugins graph"), + } + } +} + +impl std::error::Error for SortingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ValidationError(e) => Some(e), + Self::UndefinedGroup(e) => Some(e), + Self::CycleFound(e) => Some(e), + Self::CycleInvolving(_) => None, + Self::PluginDataError(e) => Some(e), + Self::PathfindingError(e) => Some(e), + } + } +} + +impl From for SortingError { + fn from(value: PluginGraphValidationError) -> Self { + SortingError::ValidationError(value) + } +} + +impl From for SortingError { + fn from(value: UndefinedGroupError) -> Self { + SortingError::UndefinedGroup(value) + } +} + +impl From for SortingError { + fn from(value: CyclicInteractionError) -> Self { + SortingError::CycleFound(value) + } +} + +impl From for SortingError { + fn from(value: PluginDataError) -> Self { + SortingError::PluginDataError(value) + } +} + +impl From for SortingError { + fn from(value: PathfindingError) -> Self { + SortingError::PathfindingError(value) + } +} diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs index 9c7e0a66..8d2bf053 100644 --- a/src/sorting/groups.rs +++ b/src/sorting/groups.rs @@ -4,22 +4,24 @@ use petgraph::{Graph, algo::bellman_ford, graph::NodeIndex}; use crate::{ EdgeType, Vertex, - error::{ - CyclicInteractionError, GeneralError, InvalidArgumentError, PathfindingError, - UndefinedGroupError, - }, metadata::Group, sorting::dfs::find_cycle, + sorting::error::{ + BuildGroupsGraphError, CyclicInteractionError, PathfindingError, UndefinedGroupError, + }, }; -use super::dfs::{DfsVisitor, depth_first_search}; +use super::{ + dfs::{DfsVisitor, depth_first_search}, + error::GroupsPathError, +}; pub type GroupsGraph = Graph; pub fn build_groups_graph( masterlist_groups: &[Group], userlist_groups: &[Group], -) -> Result { +) -> Result { let masterlist_groups = sorted_by_name(masterlist_groups); let userlist_groups = sorted_by_name(userlist_groups); @@ -43,7 +45,7 @@ pub fn build_groups_graph( )?; if let Some(cycle) = find_cycle(&graph, |node| node.clone()) { - Err(CyclicInteractionError { cycle }.into()) + Err(CyclicInteractionError::new(cycle).into()) } else { Ok(graph) } @@ -106,7 +108,7 @@ pub fn find_path( graph: &GroupsGraph, from_group_name: &str, to_group_name: &str, -) -> Result, GeneralError> { +) -> Result, GroupsPathError> { let float_graph: Graph<&String, f32> = graph.map( |_, n| n, |_, e| { @@ -122,9 +124,8 @@ pub fn find_path( let from_vertex = find_node_by_weight(graph, from_group_name)?; let to_vertex = find_node_by_weight(graph, to_group_name)?; - let paths = bellman_ford(&float_graph, from_vertex).map_err(|_| InvalidArgumentError { - message: "Groups graph contains a negative cycle".into(), - })?; + let paths = + bellman_ford(&float_graph, from_vertex).map_err(|_| PathfindingError::NegativeCycle)?; let mut path = vec![Vertex::new(graph[to_vertex].clone())]; let mut current = to_vertex; @@ -141,10 +142,7 @@ pub fn find_path( return Ok(Vec::new()); } _ => { - return Err(PathfindingError::new( - "Could not find a node in the graph while pathfinding".into(), - ) - .into()); + return Err(PathfindingError::PrecedingNodeNotFound(graph[current].clone()).into()); } }; @@ -160,10 +158,10 @@ pub fn find_path( let edge = match graph.find_edge(*preceding_vertex, current) { Some(e) => e, None => { - return Err(PathfindingError::new(format!( - "Could not find edge from \"{}\" to \"{}\"", - graph[*preceding_vertex], graph[current] - )) + return Err(PathfindingError::EdgeNotFound { + from_group: graph[*preceding_vertex].clone(), + to_group: graph[current].clone(), + } .into()); } }; @@ -182,7 +180,7 @@ pub fn find_path( fn find_node_by_weight( graph: &Graph, weight: &str, -) -> Result { +) -> Result { match graph .node_indices() .find(|i| graph.node_weight(*i).map(|w| *w == weight).unwrap_or(false)) @@ -190,9 +188,7 @@ fn find_node_by_weight( Some(n) => Ok(n), None => { log::error!("Can't find group with name {}", weight); - Err(InvalidArgumentError { - message: format!("Can't find group with name {}", weight), - }) + Err(UndefinedGroupError::new(weight.to_string())) } } } diff --git a/src/sorting/mod.rs b/src/sorting/mod.rs index e4739c42..682cb2fd 100644 --- a/src/sorting/mod.rs +++ b/src/sorting/mod.rs @@ -1,4 +1,5 @@ mod dfs; +pub mod error; pub mod groups; pub mod plugins; mod validate; diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 6e8f1b82..fe96c092 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -12,8 +12,9 @@ use petgraph::{ use crate::{ EdgeType, Plugin, - error::{CyclicInteractionError, GeneralError, SortingLogicError, UndefinedGroupError}, metadata::{File, Group, PluginMetadata}, + plugin::error::PluginDataError, + sorting::error::{CyclicInteractionError, PathfindingError, SortingError, UndefinedGroupError}, sorting::groups::{get_default_group_node, sorted_group_nodes}, }; @@ -45,7 +46,7 @@ impl<'a> PluginSortingData<'a> { masterlist_metadata: Option<&PluginMetadata>, user_metadata: Option<&PluginMetadata>, load_order_index: usize, - ) -> Result { + ) -> Result { let override_record_count = plugin.override_record_count()?; Ok(Self { @@ -86,11 +87,11 @@ impl<'a> PluginSortingData<'a> { self.plugin.asset_count() } - pub(super) fn masters(&self) -> Result, GeneralError> { + pub(super) fn masters(&self) -> Result, PluginDataError> { self.plugin.masters() } - fn do_records_overlap(&self, other: &PluginSortingData) -> Result { + fn do_records_overlap(&self, other: &PluginSortingData) -> Result { self.plugin.do_records_overlap(other.plugin) } @@ -145,7 +146,7 @@ impl<'a> PluginsGraph<'a> { self.inner.node_indices() } - fn add_specific_edges(&mut self) -> Result<(), GeneralError> { + fn add_specific_edges(&mut self) -> Result<(), SortingError> { log::trace!("Adding edges based on plugin data and non-group metadata..."); let mut node_index_iter = self.node_indices(); @@ -323,7 +324,7 @@ impl<'a> PluginsGraph<'a> { Ok(()) } - fn add_overlap_edges(&mut self) -> Result<(), GeneralError> { + fn add_overlap_edges(&mut self) -> Result<(), SortingError> { log::trace!("Adding edges for overlapping plugins..."); let mut node_index_iter = self.node_indices(); @@ -419,7 +420,7 @@ impl<'a> PluginsGraph<'a> { Ok(()) } - fn add_tie_break_edges(&mut self) -> Result<(), SortingLogicError> { + fn add_tie_break_edges(&mut self) -> Result<(), PathfindingError> { log::trace!("Adding edges to break ties between plugins..."); // In order for the sort to be performed stably, there must be only one @@ -655,8 +656,9 @@ impl<'a> PluginsGraph<'a> { insert_position + 1 } - fn topological_sort(&self) -> Result, petgraph::algo::Cycle> { + fn topological_sort(&self) -> Result, SortingError> { petgraph::algo::toposort(&self.inner, None) + .map_err(|e| SortingError::CycleInvolving(self[e.node_id()].name().to_string())) } fn is_hamiltonian_path(&mut self, path: &[NodeIndex]) -> Option<(NodeIndex, NodeIndex)> { @@ -698,7 +700,7 @@ impl<'a> PluginsGraph<'a> { &mut self, from: NodeIndex, to: NodeIndex, - ) -> Result>, SortingLogicError> { + ) -> Result>, PathfindingError> { let mut path_finder = PathFinder::new(&self.inner, &mut self.paths_cache, from, to); if bidirectional_bfs(&self.inner, from, to, &mut path_finder) { @@ -721,7 +723,7 @@ pub fn sort_plugins( mut plugins_sorting_data: Vec, groups_graph: &GroupsGraph, early_loading_plugins: &[String], -) -> Result, GeneralError> { +) -> Result, SortingError> { if plugins_sorting_data.is_empty() { return Ok(Vec::new()); } @@ -780,7 +782,7 @@ fn sort_plugins_partition( plugins_sorting_data: Vec, groups_graph: &GroupsGraph, early_loading_plugins: &[String], -) -> Result, GeneralError> { +) -> Result, SortingError> { let mut graph = PluginsGraph::new(); for plugin in plugins_sorting_data { @@ -862,7 +864,7 @@ impl<'a, 'b> PathFinder<'a, 'b> { self.cache.entry(from).or_default().insert(to); } - fn path(&self) -> Result>, SortingLogicError> { + fn path(&self) -> Result>, PathfindingError> { match self.intersection_node { None => Ok(None), Some(intersection_node) => { @@ -879,10 +881,9 @@ impl<'a, 'b> PathFinder<'a, 'b> { self.graph[current_node].name(), path_to_string(self.graph, &path) ); - return Err(SortingLogicError::new(format!( - "Could not find parent vertex of {}", - self.graph[current_node].name() - ))); + return Err(PathfindingError::PrecedingNodeNotFound( + self.graph[current_node].name().to_string(), + )); } } @@ -901,10 +902,9 @@ impl<'a, 'b> PathFinder<'a, 'b> { self.graph[current_node].name(), path_to_string(self.graph, &path) ); - return Err(SortingLogicError::new(format!( - "Could not find child vertex of {}", - self.graph[current_node].name() - ))); + return Err(PathfindingError::FollowingNodeNotFound( + self.graph[current_node].name().to_string(), + )); } } diff --git a/src/sorting/validate.rs b/src/sorting/validate.rs index 27a04384..2182acae 100644 --- a/src/sorting/validate.rs +++ b/src/sorting/validate.rs @@ -4,7 +4,7 @@ use unicase::UniCase; use crate::{ EdgeType, Vertex, - error::{CyclicInteractionError, GeneralError, UndefinedGroupError}, + sorting::error::{CyclicInteractionError, PluginGraphValidationError, UndefinedGroupError}, }; use super::{groups::GroupsGraph, plugins::PluginSortingData}; @@ -32,7 +32,7 @@ pub fn validate_specific_and_hardcoded_edges( blueprint_masters: &[PluginSortingData<'_>], non_masters: &[PluginSortingData<'_>], early_loading_plugins: &[String], -) -> Result<(), GeneralError> { +) -> Result<(), PluginGraphValidationError> { log::trace!("Validating specific and early-loading plugin edges..."); let non_masters_set: HashSet> = @@ -57,7 +57,7 @@ fn validate_masters( masters: &[PluginSortingData<'_>], non_masters: &HashSet>, blueprint_masters: &HashSet>, -) -> Result<(), GeneralError> { +) -> Result<(), PluginGraphValidationError> { log::trace!( "Validating specific and early-loading plugin edges for non-blueprint master files..." ); @@ -69,7 +69,7 @@ fn validate_masters( fn validate_non_masters( non_masters: &[PluginSortingData<'_>], blueprint_masters: &HashSet>, -) -> Result<(), GeneralError> { +) -> Result<(), PluginGraphValidationError> { log::trace!("Validating specific and early-loading plugin edges for non-master files..."); // Pass an empty set of non-masters so that the non-masters don't get validated against themselves. @@ -84,7 +84,7 @@ fn validate_plugin( plugin: &PluginSortingData<'_>, non_masters: &HashSet>, blueprint_masters: &HashSet>, -) -> Result<(), GeneralError> { +) -> Result<(), PluginGraphValidationError> { for master in plugin.masters()? { let key = UniCase::new(master.as_str()); if non_masters.contains(&key) { From 9aebc75027329d181627469843ee8dc6b41afe4a Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 4 Mar 2025 17:43:19 +0000 Subject: [PATCH 003/206] Add a few simple unit tests --- src/logging.rs | 77 +++++++++++++++++++++++++++++++++++++++++ src/metadata/message.rs | 24 +++++++++++++ src/version.rs | 65 ++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) diff --git a/src/logging.rs b/src/logging.rs index eaa096d7..e2503ebc 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -72,3 +72,80 @@ impl log::Log for CallbackLogger { fn flush(&self) {} } + +#[cfg(test)] +mod tests { + use super::*; + + mod set_logging_callback { + use super::*; + + use std::sync::{Arc, LazyLock, Mutex}; + + #[test] + #[ignore] + fn set_logging_callback_should_be_callable_multiple_times() { + let callback = |_, _: &str| {}; + set_logging_callback(callback); + + let messages = Arc::new(Mutex::new(Vec::<(LogLevel, String)>::new())); + let cloned_messages = messages.clone(); + let callback = move |level, message: &str| { + if let Ok(mut messages) = cloned_messages.lock() { + messages.push((level, message.to_string())); + } + }; + + set_logging_callback(callback); + + log::error!("Test message"); + + assert_eq!( + vec![(LogLevel::Error, "Test message".into())], + *messages.lock().unwrap() + ); + } + + #[test] + fn should_support_a_closure_with_captured_state() { + let messages = Arc::new(Mutex::new(Vec::<(LogLevel, String)>::new())); + let cloned_messages = messages.clone(); + let callback = move |level, message: &str| { + if let Ok(mut messages) = cloned_messages.lock() { + messages.push((level, message.to_string())); + } + }; + + set_logging_callback(callback); + + log::error!("Test message"); + + assert_eq!( + vec![(LogLevel::Error, "Test message".into())], + *messages.lock().unwrap() + ); + } + + #[test] + #[ignore] + fn should_support_a_function() { + static MESSAGES: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + + fn callback(level: LogLevel, message: &str) { + if let Ok(mut messages) = MESSAGES.lock() { + messages.push((level, message.to_string())); + } + } + + set_logging_callback(callback); + + log::error!("Test message"); + + assert_eq!( + vec![(LogLevel::Error, "Test message".into())], + *MESSAGES.lock().unwrap() + ); + } + } +} diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 742a5a2f..87a8386b 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -321,3 +321,27 @@ impl TryFrom<&MarkedYaml> for Message { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + mod select_message_content { + use super::*; + + #[test] + fn should_return_none_if_the_slice_is_empty() { + let content = select_message_content(&[], MessageContent::DEFAULT_LANGUAGE); + + assert!(content.is_none()); + } + + #[test] + fn should_return_the_only_element_of_a_single_element_slice() { + let slice = &[MessageContent::new("test".into()).with_language("de".into())]; + let content = select_message_content(slice, "fr"); + + assert_eq!(&slice[0], content.unwrap()); + } + } +} diff --git a/src/version.rs b/src/version.rs index 429749de..0e95aafe 100644 --- a/src/version.rs +++ b/src/version.rs @@ -45,3 +45,68 @@ const fn libloot_revision_const() -> &'static str { "unknown" } } + +#[cfg(test)] +mod tests { + use super::*; + + mod is_compatible { + use super::*; + + #[test] + fn should_return_true_if_given_the_current_version() { + assert!(is_compatible( + LIBLOOT_VERSION_MAJOR, + LIBLOOT_VERSION_MINOR, + LIBLOOT_VERSION_PATCH + )); + } + + #[test] + fn should_return_true_if_given_a_different_patch_version() { + assert!(is_compatible( + LIBLOOT_VERSION_MAJOR, + LIBLOOT_VERSION_MINOR, + LIBLOOT_VERSION_PATCH + 1 + )); + } + + #[test] + fn should_return_false_if_given_a_different_major_version() { + assert!(!is_compatible( + LIBLOOT_VERSION_MAJOR + 1, + LIBLOOT_VERSION_MINOR, + LIBLOOT_VERSION_PATCH + )); + } + + #[test] + fn should_return_false_if_given_a_different_minor_version() { + assert!(!is_compatible( + LIBLOOT_VERSION_MAJOR, + LIBLOOT_VERSION_MINOR + 1, + LIBLOOT_VERSION_PATCH + )); + } + } + mod libloot_version { + use super::*; + + #[test] + fn should_be_version_numbers_separated_by_periods() { + let expected = + format!("{LIBLOOT_VERSION_MAJOR}.{LIBLOOT_VERSION_MINOR}.{LIBLOOT_VERSION_PATCH}",); + + assert_eq!(expected, libloot_version()); + } + } + + mod libloot_revision { + use super::*; + + #[test] + fn should_not_be_empty() { + assert!(!libloot_revision().is_empty()); + } + } +} From 649560fa09f1e680cfff85e3e9cd1bb8ed37ec66 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 5 Mar 2025 07:12:07 +0000 Subject: [PATCH 004/206] Decouple PluginSortingData and Plugin The SortingPlugin trait will make testing the sorting code easier, as it won't need to load plugin files. --- src/sorting/plugins.rs | 113 +++++++++++++++++++++++++++++----------- src/sorting/validate.rs | 33 ++++++------ 2 files changed, 101 insertions(+), 45 deletions(-) diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index fe96c092..1b590b49 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -25,8 +25,8 @@ use super::{ }; #[derive(Debug)] -pub struct PluginSortingData<'a> { - plugin: &'a Plugin, +pub struct PluginSortingData<'a, T: SortingPlugin> { + plugin: &'a T, pub(super) is_master: bool, override_record_count: usize, @@ -40,9 +40,9 @@ pub struct PluginSortingData<'a> { pub(super) user_req: Vec, } -impl<'a> PluginSortingData<'a> { +impl<'a, T: SortingPlugin> PluginSortingData<'a, T> { pub fn new( - plugin: &'a Plugin, + plugin: &'a T, masterlist_metadata: Option<&PluginMetadata>, user_metadata: Option<&PluginMetadata>, load_order_index: usize, @@ -91,15 +91,56 @@ impl<'a> PluginSortingData<'a> { self.plugin.masters() } - fn do_records_overlap(&self, other: &PluginSortingData) -> Result { + fn do_records_overlap(&self, other: &Self) -> Result { self.plugin.do_records_overlap(other.plugin) } - fn do_assets_overlap(&self, other: &PluginSortingData) -> bool { + fn do_assets_overlap(&self, other: &Self) -> bool { self.plugin.do_assets_overlap(other.plugin) } } +pub trait SortingPlugin { + fn name(&self) -> &str; + fn is_master(&self) -> bool; + fn is_blueprint_plugin(&self) -> bool; + fn masters(&self) -> Result, PluginDataError>; + fn override_record_count(&self) -> Result; + fn asset_count(&self) -> usize; + fn do_records_overlap(&self, other: &Self) -> Result; + fn do_assets_overlap(&self, other: &Self) -> bool; +} + +impl SortingPlugin for Plugin { + fn name(&self) -> &str { + self.name() + } + fn is_master(&self) -> bool { + self.is_master() + } + + fn is_blueprint_plugin(&self) -> bool { + self.is_blueprint_plugin() + } + + fn masters(&self) -> Result, PluginDataError> { + self.masters() + } + fn override_record_count(&self) -> Result { + self.override_record_count() + } + fn asset_count(&self) -> usize { + self.asset_count() + } + + fn do_records_overlap(&self, other: &Self) -> Result { + self.do_records_overlap(other) + } + fn do_assets_overlap(&self, other: &Self) -> bool { + self.do_assets_overlap(other) + } +} + fn to_filenames(files: &[File]) -> Vec { files .iter() @@ -107,21 +148,21 @@ fn to_filenames(files: &[File]) -> Vec { .collect() } -type InnerPluginsGraph<'a> = Graph>, EdgeType>; +type InnerPluginsGraph<'a, T> = Graph>, EdgeType>; -#[derive(Debug, Default)] -struct PluginsGraph<'a> { +#[derive(Debug)] +struct PluginsGraph<'a, T: SortingPlugin> { // Put the sorting data in Rc so that it can be held onto while mutating the graph. - inner: InnerPluginsGraph<'a>, + inner: InnerPluginsGraph<'a, T>, paths_cache: HashMap>, } -impl<'a> PluginsGraph<'a> { +impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { fn new() -> Self { PluginsGraph::default() } - fn add_node(&mut self, plugin: PluginSortingData<'a>) { + fn add_node(&mut self, plugin: PluginSortingData<'a, T>) { self.inner.add_node(Rc::new(plugin)); } @@ -711,16 +752,26 @@ impl<'a> PluginsGraph<'a> { } } -impl<'a> std::ops::Index for PluginsGraph<'a> { - type Output = Rc>; +// The derive macro for Default requires T: Default, but it's not actually necessary. +impl std::default::Default for PluginsGraph<'_, T> { + fn default() -> Self { + Self { + inner: Default::default(), + paths_cache: Default::default(), + } + } +} + +impl<'a, T: SortingPlugin> std::ops::Index for PluginsGraph<'a, T> { + type Output = Rc>; fn index(&self, index: NodeIndex) -> &Self::Output { &self.inner[index] } } -pub fn sort_plugins( - mut plugins_sorting_data: Vec, +pub fn sort_plugins( + mut plugins_sorting_data: Vec>, groups_graph: &GroupsGraph, early_loading_plugins: &[String], ) -> Result, SortingError> { @@ -778,8 +829,8 @@ pub fn sort_plugins( Ok(masters_load_order) } -fn sort_plugins_partition( - plugins_sorting_data: Vec, +fn sort_plugins_partition( + plugins_sorting_data: Vec>, groups_graph: &GroupsGraph, early_loading_plugins: &[String], ) -> Result, SortingError> { @@ -824,7 +875,7 @@ fn sort_plugins_partition( Ok(sorted_plugin_names) } -fn path_to_string(graph: &InnerPluginsGraph, path: &[NodeIndex]) -> String { +fn path_to_string(graph: &InnerPluginsGraph, path: &[NodeIndex]) -> String { path.iter() .map(|i| graph[*i].name()) .collect::>() @@ -832,8 +883,8 @@ fn path_to_string(graph: &InnerPluginsGraph, path: &[NodeIndex]) -> String { } #[derive(Debug)] -struct PathFinder<'a, 'b> { - graph: &'a InnerPluginsGraph<'b>, +struct PathFinder<'a, 'b, T: SortingPlugin> { + graph: &'a InnerPluginsGraph<'b, T>, cache: &'a mut HashMap>, from_node_index: NodeIndex, to_node_index: NodeIndex, @@ -842,9 +893,9 @@ struct PathFinder<'a, 'b> { intersection_node: Option, } -impl<'a, 'b> PathFinder<'a, 'b> { +impl<'a, 'b, T: SortingPlugin> PathFinder<'a, 'b, T> { fn new( - graph: &'a InnerPluginsGraph<'b>, + graph: &'a InnerPluginsGraph<'b, T>, cache: &'a mut HashMap>, from_node_index: NodeIndex, to_node_index: NodeIndex, @@ -914,7 +965,7 @@ impl<'a, 'b> PathFinder<'a, 'b> { } } -impl BidirBfsVisitor for PathFinder<'_, '_> { +impl BidirBfsVisitor for PathFinder<'_, '_, T> { fn visit_forward_bfs_edge(&mut self, source: NodeIndex, target: NodeIndex) { self.cache_path(self.from_node_index, target); @@ -939,7 +990,9 @@ struct PathCacher<'a> { to_node_index: NodeIndex, } -fn get_plugins_in_groups(graph: &InnerPluginsGraph) -> HashMap> { +fn get_plugins_in_groups( + graph: &InnerPluginsGraph, +) -> HashMap> { let mut plugins_in_groups: HashMap> = HashMap::new(); for node in graph.node_indices() { @@ -985,8 +1038,8 @@ impl BidirBfsVisitor for PathCacher<'_> { type PluginNodeIndex = NodeIndex; type GroupNodeIndex = NodeIndex; -struct GroupsPathVisitor<'a, 'b, 'c, 'd, 'e> { - plugins_graph: &'a mut PluginsGraph<'b>, +struct GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> { + plugins_graph: &'a mut PluginsGraph<'b, T>, groups_graph: &'e GroupsGraph, groups_plugins: &'c HashMap>, finished_group_vertices: &'d mut HashSet, @@ -995,9 +1048,9 @@ struct GroupsPathVisitor<'a, 'b, 'c, 'd, 'e> { unfinishable_nodes: HashSet, } -impl<'a, 'b, 'c, 'd, 'e> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e> { +impl<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, T> { fn new( - plugins_graph: &'a mut PluginsGraph<'b>, + plugins_graph: &'a mut PluginsGraph<'b, T>, groups_graph: &'e GroupsGraph, groups_plugins: &'c HashMap>, finished_group_vertices: &'d mut HashSet, @@ -1079,7 +1132,7 @@ impl<'a, 'b, 'c, 'd, 'e> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e> { } } -impl<'e> DfsVisitor<'e> for GroupsPathVisitor<'_, '_, '_, '_, 'e> { +impl<'e, T: SortingPlugin> DfsVisitor<'e> for GroupsPathVisitor<'_, '_, '_, '_, 'e, T> { fn visit_tree_edge(&mut self, edge_ref: EdgeReference<'e, EdgeType>) { let source = edge_ref.source(); let target = edge_ref.target(); diff --git a/src/sorting/validate.rs b/src/sorting/validate.rs index 2182acae..0a16de9b 100644 --- a/src/sorting/validate.rs +++ b/src/sorting/validate.rs @@ -7,10 +7,13 @@ use crate::{ sorting::error::{CyclicInteractionError, PluginGraphValidationError, UndefinedGroupError}, }; -use super::{groups::GroupsGraph, plugins::PluginSortingData}; +use super::{ + groups::GroupsGraph, + plugins::{PluginSortingData, SortingPlugin}, +}; -pub fn validate_plugin_groups( - plugins_sorting_data: &[PluginSortingData<'_>], +pub fn validate_plugin_groups( + plugins_sorting_data: &[PluginSortingData<'_, T>], groups_graph: &GroupsGraph, ) -> Result<(), UndefinedGroupError> { let group_names: HashSet<&String> = groups_graph @@ -27,10 +30,10 @@ pub fn validate_plugin_groups( Ok(()) } -pub fn validate_specific_and_hardcoded_edges( - masters: &[PluginSortingData<'_>], - blueprint_masters: &[PluginSortingData<'_>], - non_masters: &[PluginSortingData<'_>], +pub fn validate_specific_and_hardcoded_edges( + masters: &[PluginSortingData<'_, T>], + blueprint_masters: &[PluginSortingData<'_, T>], + non_masters: &[PluginSortingData<'_, T>], early_loading_plugins: &[String], ) -> Result<(), PluginGraphValidationError> { log::trace!("Validating specific and early-loading plugin edges..."); @@ -53,8 +56,8 @@ pub fn validate_specific_and_hardcoded_edges( Ok(()) } -fn validate_masters( - masters: &[PluginSortingData<'_>], +fn validate_masters( + masters: &[PluginSortingData<'_, T>], non_masters: &HashSet>, blueprint_masters: &HashSet>, ) -> Result<(), PluginGraphValidationError> { @@ -66,8 +69,8 @@ fn validate_masters( .try_for_each(|m| validate_plugin(m, non_masters, blueprint_masters)) } -fn validate_non_masters( - non_masters: &[PluginSortingData<'_>], +fn validate_non_masters( + non_masters: &[PluginSortingData<'_, T>], blueprint_masters: &HashSet>, ) -> Result<(), PluginGraphValidationError> { log::trace!("Validating specific and early-loading plugin edges for non-master files..."); @@ -80,8 +83,8 @@ fn validate_non_masters( .try_for_each(|p| validate_plugin(p, &empty_set, blueprint_masters)) } -fn validate_plugin( - plugin: &PluginSortingData<'_>, +fn validate_plugin( + plugin: &PluginSortingData<'_, T>, non_masters: &HashSet>, blueprint_masters: &HashSet>, ) -> Result<(), PluginGraphValidationError> { @@ -176,9 +179,9 @@ fn validate_files( Ok(()) } -fn validate_early_loading_plugins( +fn validate_early_loading_plugins( early_loading_plugins: &[String], - masters: &[PluginSortingData<'_>], + masters: &[PluginSortingData<'_, T>], non_masters: &HashSet>, ) -> Result<(), CyclicInteractionError> { if let Some(master) = masters.first() { From ce1742ef54313bfe5dc472763d6ef3bb20b86db2 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 5 Mar 2025 07:19:13 +0000 Subject: [PATCH 005/206] Add a couple of parameterised fixture tests --- .gitignore | 1 + Cargo.lock | 221 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 + README.md | 2 +- src/game.rs | 57 +++++++++++ src/lib.rs | 2 + src/tests.rs | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 src/tests.rs diff --git a/.gitignore b/.gitignore index ea8c4bf7..1ca09fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/testing-plugins diff --git a/Cargo.lock b/Cargo.lock index a2812b6d..3ca6a175 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + [[package]] name = "bit-set" version = "0.8.0" @@ -65,6 +71,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "cfg-if" version = "1.0.0" @@ -271,6 +283,49 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -304,6 +359,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "hashbrown" version = "0.14.5" @@ -384,6 +445,8 @@ dependencies = [ "loot-condition-interpreter", "petgraph", "rayon", + "rstest", + "rstest_reuse", "saphyr", "saphyr-parser", "tempfile", @@ -558,6 +621,36 @@ dependencies = [ "indexmap", ] +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.93" @@ -576,6 +669,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + [[package]] name = "rayon" version = "1.10.0" @@ -636,6 +759,53 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rstest_reuse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a8fb4672e840a587a66fc577a5491375df51ddb88f2a2c2a792598c326fe14" +dependencies = [ + "quote", + "rand", + "syn", +] + [[package]] name = "rust-ini" version = "0.21.1" @@ -648,6 +818,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -683,6 +862,12 @@ dependencies = [ "hashlink", ] +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + [[package]] name = "sha2" version = "0.10.8" @@ -694,6 +879,15 @@ dependencies = [ "digest", ] +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + [[package]] name = "syn" version = "2.0.98" @@ -768,6 +962,23 @@ dependencies = [ "crunchy", ] +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + [[package]] name = "trim-in-place" version = "0.1.7" @@ -1015,6 +1226,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.33.0" @@ -1030,6 +1250,7 @@ version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ + "byteorder", "zerocopy-derive", ] diff --git a/Cargo.toml b/Cargo.toml index 4b43d1bf..a47617d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,4 +18,6 @@ unicase = "2.8.1" rayon = "1.10.0" [dev-dependencies] +rstest = "0.25.0" +rstest_reuse = "0.7.0" tempfile = "3.17.1" diff --git a/README.md b/README.md index 5dea8e08..21d13c18 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ $env:LIBLOOT_REVISION = git rev-parse --short HEAD cargo build --release ``` -There aren't many tests, but those that exist can be run using: +There aren't many tests, but those that exist can be run by first extracting the [testing-plugins](https://github.com/Ortham/testing-plugins) archive to this readme's directory (so that there's a `testing-plugins` directory there), then running: ``` cargo test diff --git a/src/game.rs b/src/game.rs index 9fa2f1ea..b0670879 100644 --- a/src/game.rs +++ b/src/game.rs @@ -753,3 +753,60 @@ impl GameCache { self.archive_paths.iter() } } + +#[cfg(test)] +mod tests { + use super::*; + + use rstest::rstest; + use rstest_reuse::{apply, template}; + + use crate::tests::Fixture; + + #[template] + #[rstest] + fn all_game_types( + #[values( + GameType::TES4, + GameType::TES5, + GameType::FO3, + GameType::FONV, + GameType::FO4, + GameType::TES5SE, + GameType::FO4VR, + GameType::TES5VR, + GameType::TES3, + GameType::Starfield, + GameType::OpenMW + )] + game_type: GameType, + ) { + } + + mod new { + use super::*; + + #[apply(all_game_types)] + fn should_succeed_if_given_valid_game_path(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::new(fixture.game_type, &fixture.game_path); + + assert!(game.is_ok()); + } + } + + mod with_local_path { + use super::*; + + #[apply(all_game_types)] + fn should_succeed_if_given_valid_paths(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = + Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path); + + assert!(game.is_ok()); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 4abe3ac8..1c3e31da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,8 @@ mod logging; pub mod metadata; mod plugin; mod sorting; +#[cfg(test)] +mod tests; mod version; pub use database::Database; diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 00000000..c4ae348d --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,285 @@ +use std::{ + fs::{File, copy, create_dir_all}, + path::{Path, PathBuf, absolute}, + time::{Duration, SystemTime}, +}; + +use crate::GameType; +use tempfile::TempDir; + +const BLANK_ESM: &str = "Blank.esm"; +const BLANK_DIFFERENT_ESM: &str = "Blank - Different.esm"; +const BLANK_MASTER_DEPENDENT_ESM: &str = "Blank - Master Dependent.esm"; +const BLANK_DIFFERENT_MASTER_DEPENDENT_ESM: &str = "Blank - Different Master Dependent.esm"; +const BLANK_ESP: &str = "Blank.esp"; +const BLANK_DIFFERENT_ESP: &str = "Blank - Different.esp"; +const BLANK_MASTER_DEPENDENT_ESP: &str = "Blank - Master Dependent.esp"; +const BLANK_DIFFERENT_MASTER_DEPENDENT_ESP: &str = "Blank - Different Master Dependent.esp"; +const BLANK_PLUGIN_DEPENDENT_ESP: &str = "Blank - Plugin Dependent.esp"; +const BLANK_DIFFERENT_PLUGIN_DEPENDENT_ESP: &str = "Blank - Different Plugin Dependent.esp"; + +const BLANK_FULL_ESM: &str = "Blank.full.esm"; +const BLANK_MEDIUM_ESM: &str = "Blank.medium.esm"; +const BLANK_ESL: &str = "Blank.esl"; +const NON_PLUGIN_FILE: &str = "NotAPlugin.esm"; + +fn source_plugins_path(game_type: GameType) -> PathBuf { + match game_type { + GameType::TES3 | GameType::OpenMW => absolute("./testing-plugins/Morrowind/Data Files"), + GameType::TES4 => absolute("./testing-plugins/Oblivion/Data"), + GameType::Starfield => absolute("./testing-plugins/Starfield/Data"), + GameType::FO3 | GameType::FONV | GameType::TES5 => { + absolute("./testing-plugins/Skyrim/Data") + } + _ => absolute("./testing-plugins/SkyrimSE/Data"), + } + .unwrap() +} + +fn master_file(game_type: GameType) -> &'static str { + match game_type { + GameType::TES3 | GameType::OpenMW => "Morrowind.esm", + GameType::TES4 => "Oblivion.esm", + GameType::TES5 | GameType::TES5SE | GameType::TES5VR => "Skyrim.esm", + GameType::FO3 => "Fallout3.esm", + GameType::FONV => "FalloutNV.esm", + GameType::FO4 | GameType::FO4VR => "Fallout4.esm", + GameType::Starfield => "Starfield.esm", + } +} + +fn copy_file(source_dir: &Path, dest_dir: &Path, filename: &str) { + copy(source_dir.join(filename), dest_dir.join(filename)).unwrap(); +} + +fn touch(file_path: &Path) { + std::fs::File::create(file_path).unwrap(); +} + +fn supports_light_plugins(game_type: GameType) -> bool { + matches!( + game_type, + GameType::TES5SE | GameType::TES5VR | GameType::FO4 | GameType::FO4VR | GameType::Starfield + ) +} + +fn is_load_order_timestamp_based(game_type: GameType) -> bool { + matches!( + game_type, + GameType::TES3 | GameType::TES4 | GameType::FO3 | GameType::FONV + ) +} + +fn initial_load_order(game_type: GameType) -> Vec<(&'static str, bool)> { + if game_type == GameType::Starfield { + vec![ + (master_file(game_type), true), + (BLANK_ESM, true), + (BLANK_DIFFERENT_ESM, false), + (BLANK_FULL_ESM, false), + (BLANK_MASTER_DEPENDENT_ESM, false), + (BLANK_MEDIUM_ESM, false), + (BLANK_ESL, false), + (BLANK_ESP, false), + (BLANK_DIFFERENT_ESP, false), + (BLANK_MASTER_DEPENDENT_ESP, false), + ] + } else { + let mut load_order = vec![ + (master_file(game_type), true), + (BLANK_ESM, true), + (BLANK_DIFFERENT_ESM, false), + (BLANK_MASTER_DEPENDENT_ESM, false), + (BLANK_DIFFERENT_MASTER_DEPENDENT_ESM, false), + (BLANK_ESP, false), + (BLANK_DIFFERENT_ESP, false), + (BLANK_MASTER_DEPENDENT_ESP, false), + (BLANK_DIFFERENT_MASTER_DEPENDENT_ESP, true), + (BLANK_PLUGIN_DEPENDENT_ESP, false), + (BLANK_DIFFERENT_PLUGIN_DEPENDENT_ESP, false), + ]; + + if supports_light_plugins(game_type) { + load_order.insert(5, (BLANK_ESL, false)); + } + + load_order + } +} + +fn set_load_order( + game_type: GameType, + data_path: &Path, + local_path: &Path, + load_order: &[(&'static str, bool)], +) { + use std::io::Write; + + match game_type { + GameType::TES3 => {} + GameType::OpenMW => {} + _ => { + let mut file = File::create(local_path.join("Plugins.txt")).unwrap(); + for (plugin, is_active) in load_order { + if supports_light_plugins(game_type) { + if *is_active { + write!(file, "*").unwrap(); + } + } else if !is_active { + continue; + } + + writeln!(file, "{plugin}").unwrap(); + } + } + } + + if is_load_order_timestamp_based(game_type) { + let mut mod_time = SystemTime::now(); + for (plugin, _) in load_order { + let ghosted_path = data_path.join(plugin.to_string() + ".ghost"); + let file = if ghosted_path.exists() { + File::options().write(true).open(ghosted_path) + } else { + File::options().write(true).open(data_path.join(plugin)) + }; + file.unwrap().set_modified(mod_time).unwrap(); + + mod_time += Duration::from_secs(60); + } + } else if game_type == GameType::TES5 { + let mut file = File::create(local_path.join("loadorder.txt")).unwrap(); + for (plugin, _) in load_order { + writeln!(file, "{plugin}").unwrap(); + } + } +} + +pub struct Fixture { + _temp_dir: TempDir, + pub game_type: GameType, + pub game_path: PathBuf, + pub local_path: PathBuf, +} + +impl Fixture { + pub fn new(game_type: GameType) -> Self { + let temp_dir = tempfile::Builder::new() + .prefix("libloot-t\u{00E9}st-") + .tempdir() + .unwrap(); + let root_path = temp_dir.path(); + let game_path = root_path.join("games/game"); + let local_path = root_path.join("local/game"); + let data_path = match game_type { + GameType::OpenMW => game_path.join("resources/vfs"), + GameType::TES3 => game_path.join("Data Files"), + _ => game_path.join("Data"), + }; + + create_dir_all(&data_path).unwrap(); + create_dir_all(&local_path).unwrap(); + + let source_plugins_path = source_plugins_path(game_type); + + if game_type == GameType::Starfield { + copy_file(&source_plugins_path, &data_path, BLANK_FULL_ESM); + copy_file(&source_plugins_path, &data_path, BLANK_MEDIUM_ESM); + + copy( + source_plugins_path.join(BLANK_FULL_ESM), + data_path.join(BLANK_ESM), + ) + .unwrap(); + copy( + source_plugins_path.join(BLANK_FULL_ESM), + data_path.join(BLANK_DIFFERENT_ESM), + ) + .unwrap(); + copy( + source_plugins_path.join("Blank - Override.full.esm"), + data_path.join(BLANK_MASTER_DEPENDENT_ESM), + ) + .unwrap(); + copy_file(&source_plugins_path, &data_path, BLANK_ESP); + copy( + source_plugins_path.join(BLANK_ESP), + data_path.join(BLANK_DIFFERENT_ESP), + ) + .unwrap(); + copy( + source_plugins_path.join("Blank - Override.esp"), + data_path.join(BLANK_MASTER_DEPENDENT_ESP), + ) + .unwrap(); + } else { + copy_file(&source_plugins_path, &data_path, BLANK_ESM); + copy_file(&source_plugins_path, &data_path, BLANK_DIFFERENT_ESM); + copy_file(&source_plugins_path, &data_path, BLANK_MASTER_DEPENDENT_ESM); + copy_file( + &source_plugins_path, + &data_path, + BLANK_DIFFERENT_MASTER_DEPENDENT_ESM, + ); + copy_file(&source_plugins_path, &data_path, BLANK_ESP); + copy_file(&source_plugins_path, &data_path, BLANK_DIFFERENT_ESP); + copy_file(&source_plugins_path, &data_path, BLANK_MASTER_DEPENDENT_ESP); + copy_file( + &source_plugins_path, + &data_path, + BLANK_DIFFERENT_MASTER_DEPENDENT_ESP, + ); + copy_file(&source_plugins_path, &data_path, BLANK_PLUGIN_DEPENDENT_ESP); + copy_file( + &source_plugins_path, + &data_path, + BLANK_DIFFERENT_PLUGIN_DEPENDENT_ESP, + ); + } + + if supports_light_plugins(game_type) { + if game_type == GameType::Starfield { + copy( + source_plugins_path.join("Blank.small.esm"), + data_path.join(BLANK_ESL), + ) + .unwrap(); + } else { + copy_file(&source_plugins_path, &data_path, BLANK_ESL); + } + } + + let master_file = master_file(game_type); + copy(data_path.join(BLANK_ESM), data_path.join(master_file)).unwrap(); + + set_load_order( + game_type, + &data_path, + &local_path, + &initial_load_order(game_type), + ); + + if game_type == GameType::OpenMW { + touch(&game_path.join("openmw.cfg")); + } else { + std::fs::rename( + data_path.join(BLANK_MASTER_DEPENDENT_ESM), + data_path.join(BLANK_MASTER_DEPENDENT_ESM.to_string() + ".ghost"), + ) + .unwrap(); + } + + std::fs::write( + data_path.join(NON_PLUGIN_FILE), + "This isn't a valid plugin file.", + ) + .unwrap(); + + Self { + _temp_dir: temp_dir, + game_type, + game_path, + local_path, + } + } +} From 051372318db13b551380a35ae346ea59b9c7cef7 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 5 Mar 2025 18:01:59 +0000 Subject: [PATCH 006/206] Implement support for writing YAML --- README.md | 2 +- src/metadata/error.rs | 21 +- src/metadata/file.rs | 168 +++++++++++++- src/metadata/group.rs | 101 +++++++++ src/metadata/location.rs | 59 +++++ src/metadata/message.rs | 177 +++++++++++++++ src/metadata/metadata_document.rs | 75 +++++-- src/metadata/mod.rs | 11 +- src/metadata/plugin_cleaning_data.rs | 156 ++++++++++++- src/metadata/plugin_metadata.rs | 294 ++++++++++++++++++++++++- src/metadata/tag.rs | 58 +++++ src/metadata/yaml_emit.rs | 315 +++++++++++++++++++++++++++ 12 files changed, 1412 insertions(+), 25 deletions(-) create mode 100644 src/metadata/yaml_emit.rs diff --git a/README.md b/README.md index 21d13c18..e8e51e70 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Currently complete: - [x] Library versioning - [ ] Setting a logging callback - [x] Parsing metadata from YAML -- [ ] Serialising metadata to YAML +- [x] Serialising metadata to YAML - [x] Game-related functionality - [x] Plugin-related functionality - [x] Archive-related functionality diff --git a/src/metadata/error.rs b/src/metadata/error.rs index f015190a..871cf381 100644 --- a/src/metadata/error.rs +++ b/src/metadata/error.rs @@ -363,7 +363,7 @@ impl std::fmt::Display for YamlMergeKeyError { impl std::error::Error for YamlMergeKeyError {} /// Represents an error that occurred while trying to write metadata to a file. -#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Debug)] pub struct WriteMetadataError { path: PathBuf, reason: WriteMetadataErrorReason, @@ -386,14 +386,29 @@ impl std::fmt::Display for WriteMetadataError { WriteMetadataErrorReason::PathAlreadyExists => { write!(f, "the path \"{}\" already exists", self.path.display()) } + WriteMetadataErrorReason::IoError(_) => write!(f, "an I/O error occurred"), } } } -impl std::error::Error for WriteMetadataError {} +impl std::error::Error for WriteMetadataError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.reason { + WriteMetadataErrorReason::IoError(e) => Some(e), + _ => None, + } + } +} -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Debug)] pub(crate) enum WriteMetadataErrorReason { ParentDirectoryNotFound, PathAlreadyExists, + IoError(std::io::Error), +} + +impl From for WriteMetadataErrorReason { + fn from(value: std::io::Error) -> Self { + WriteMetadataErrorReason::IoError(value) + } } diff --git a/src/metadata/file.rs b/src/metadata/file.rs index 6dce45e8..dea1bd84 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -2,13 +2,16 @@ use saphyr::{MarkedYaml, YamlData}; use unicase::UniCase; use super::{ - error::ExpectedType, - error::{MultilingualMessageContentsError, ParseMetadataError}, - message::{MessageContent, parse_message_contents_yaml, validate_message_contents}, + error::{ExpectedType, MultilingualMessageContentsError, ParseMetadataError}, + message::{ + MessageContent, emit_message_contents, parse_message_contents_yaml, + validate_message_contents, + }, yaml::{ YamlObjectType, as_string_node, get_required_string_value, get_string_value, parse_condition, }, + yaml_emit::{EmitYaml, YamlEmitter}, }; /// Represents a file in a game's Data folder, including files in @@ -155,3 +158,162 @@ impl TryFrom<&MarkedYaml> for File { } } } + +impl EmitYaml for File { + fn is_scalar(&self) -> bool { + self.condition.is_none() && self.detail.is_empty() && self.display_name.is_none() + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + if self.is_scalar() { + emitter.single_quoted_str(self.name.as_str()); + } else { + emitter.begin_map(); + + emitter.map_key("name"); + emitter.single_quoted_str(self.name.as_str()); + + if let Some(display_name) = &self.display_name { + emitter.map_key("display"); + emitter.single_quoted_str(display_name); + } + + if let Some(condition) = &self.condition { + emitter.map_key("condition"); + emitter.single_quoted_str(condition); + } + + emit_message_contents(&self.detail, emitter, "detail"); + + emitter.end_map(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod emit_yaml { + use crate::metadata::emit; + + use super::*; + + #[test] + fn should_emit_only_name_scalar_if_other_fields_are_empty() { + let file = File::new("filename".into()); + let yaml = emit(&file); + + assert_eq!(format!("'{}'", file.name.as_str()), yaml); + } + + #[test] + fn should_emit_map_with_display_if_display_name_is_not_empty() { + let file = File::new("filename".into()).with_display_name("display1".into()); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}'\ndisplay: '{}'", + file.name.as_str(), + file.display_name.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_condition_if_it_is_not_empty() { + let file = File::new("filename".into()).with_condition("condition1".into()); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}'\ncondition: '{}'", + file.name.as_str(), + file.condition.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_a_detail_string_if_detail_is_monolingual() { + let file = File::new("filename".into()) + .with_detail(vec![MessageContent::new("message".into())]) + .unwrap(); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}'\ndetail: '{}'", + file.name.as_str(), + file.detail[0].text() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_a_detail_array_if_detail_is_multilingual() { + let file = File::new("filename".into()) + .with_detail(vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ]) + .unwrap(); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}' +detail: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + file.name.as_str(), + file.detail[0].language(), + file.detail[0].text(), + file.detail[1].language(), + file.detail[1].text() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_all_fields_set() { + let file = File::new("filename".into()) + .with_display_name("display1".into()) + .with_condition("condition1".into()) + .with_detail(vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ]) + .unwrap(); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}' +display: '{}' +condition: '{}' +detail: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + file.name.as_str(), + file.display_name.unwrap(), + file.condition.unwrap(), + file.detail[0].language(), + file.detail[0].text(), + file.detail[1].language(), + file.detail[1].text() + ), + yaml + ); + } + } +} diff --git a/src/metadata/group.rs b/src/metadata/group.rs index 61cb9972..e5124676 100644 --- a/src/metadata/group.rs +++ b/src/metadata/group.rs @@ -4,6 +4,7 @@ use super::error::ParseMetadataError; use super::yaml::{ YamlObjectType, get_as_hash, get_required_string_value, get_string_value, get_strings_vec_value, }; +use super::yaml_emit::{EmitYaml, YamlEmitter}; /// Represents a group to which plugin metadata objects can belong. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -89,3 +90,103 @@ impl TryFrom<&MarkedYaml> for Group { }) } } + +impl EmitYaml for Group { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("name"); + emitter.single_quoted_str(&self.name); + + if let Some(description) = &self.description { + emitter.map_key("description"); + emitter.single_quoted_str(description); + } + + if !self.after_groups.is_empty() { + emitter.map_key("after"); + emitter.begin_array(); + + for after in &self.after_groups { + emitter.unquoted_str(after); + } + + emitter.end_array(); + } + + emitter.end_map(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod emit_yaml { + use super::*; + use crate::metadata::emit; + + #[test] + fn should_omit_description_and_after_keys_if_their_fields_are_empty() { + let group = Group::new("name".into()); + let yaml = emit(&group); + + assert_eq!(format!("name: '{}'", group.name), yaml); + } + + #[test] + fn should_include_description_key_if_a_description_is_set() { + let group = Group::new("name".into()).with_description("desc".into()); + let yaml = emit(&group); + + assert_eq!( + format!( + "name: '{}'\ndescription: '{}'", + group.name, + group.description.unwrap() + ), + yaml + ); + } + + #[test] + fn should_include_after_key_if_after_groups_is_not_empty() { + let group = + Group::new("name".into()).with_after_groups(vec!["after1".into(), "after2".into()]); + + let yaml = emit(&group); + + assert_eq!( + format!( + "name: '{}'\nafter:\n - {}\n - {}", + group.name, group.after_groups[0], group.after_groups[1] + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_all_fields_set() { + let group = Group::new("name".into()) + .with_description("desc".into()) + .with_after_groups(vec!["after1".into(), "after2".into()]); + + let yaml = emit(&group); + + assert_eq!( + format!( + "name: '{}'\ndescription: '{}'\nafter:\n - {}\n - {}", + group.name, + group.description.unwrap(), + group.after_groups[0], + group.after_groups[1] + ), + yaml + ); + } + } +} diff --git a/src/metadata/location.rs b/src/metadata/location.rs index b4507a08..bb79fc0b 100644 --- a/src/metadata/location.rs +++ b/src/metadata/location.rs @@ -4,6 +4,8 @@ use super::error::ExpectedType; use super::error::ParseMetadataError; use super::yaml::{YamlObjectType, get_required_string_value}; +use super::yaml_emit::EmitYaml; +use super::yaml_emit::YamlEmitter; /// Represents a URL at which the parent plugin can be found. #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -76,3 +78,60 @@ impl TryFrom<&MarkedYaml> for Location { } } } + +impl EmitYaml for Location { + fn is_scalar(&self) -> bool { + self.name.is_none() + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + if let Some(name) = &self.name { + emitter.begin_map(); + + emitter.map_key("link"); + emitter.single_quoted_str(&self.url); + + emitter.map_key("name"); + emitter.single_quoted_str(name); + + emitter.end_map(); + } else { + emitter.single_quoted_str(&self.url); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod emit_yaml { + use crate::metadata::emit; + + use super::*; + + #[test] + fn should_emit_url_only_if_there_is_no_name() { + let location = Location::new("http://www.example.com".into()); + let yaml = emit(&location); + + assert_eq!(format!("'{}'", location.url), yaml); + } + + #[test] + fn should_emit_map_if_there_is_a_name() { + let location = + Location::new("http://www.example.com".into()).with_name("example".into()); + let yaml = emit(&location); + + assert_eq!( + format!( + "link: '{}'\nname: '{}'", + location.url, + location.name.unwrap() + ), + yaml + ); + } + } +} diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 87a8386b..ee9ebf91 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -9,6 +9,7 @@ use super::{ YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_strings_vec_value, parse_condition, }, + yaml_emit::{EmitYaml, YamlEmitter}, }; /// Codes used to indicate the type of a message. @@ -25,6 +26,16 @@ pub enum MessageType { Error, } +impl std::fmt::Display for MessageType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MessageType::Say => write!(f, "say"), + MessageType::Warn => write!(f, "warn"), + MessageType::Error => write!(f, "error"), + } + } +} + /// Represents a message's localised text content. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct MessageContent { @@ -322,8 +333,69 @@ impl TryFrom<&MarkedYaml> for Message { } } +impl EmitYaml for MessageContent { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("lang"); + emitter.unquoted_str(&self.language); + + emitter.map_key("text"); + emitter.single_quoted_str(&self.text); + + emitter.end_map(); + } +} + +pub(super) fn emit_message_contents( + slice: &[MessageContent], + emitter: &mut YamlEmitter, + key: &'static str, +) { + match slice { + [] => {} + [detail] => { + emitter.map_key(key); + emitter.single_quoted_str(detail.text()); + } + details => { + emitter.map_key(key); + + details.emit_yaml(emitter); + } + } +} + +impl EmitYaml for Message { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("type"); + emitter.unquoted_str(&self.message_type.to_string()); + + emit_message_contents(&self.content, emitter, "content"); + + if let Some(condition) = &self.condition { + emitter.map_key("condition"); + emitter.single_quoted_str(condition); + } + + emitter.end_map(); + } +} + #[cfg(test)] mod tests { + use crate::metadata::emit; + use super::*; mod select_message_content { @@ -344,4 +416,109 @@ mod tests { assert_eq!(&slice[0], content.unwrap()); } } + + mod message_content { + use super::*; + mod emit_yaml { + use super::*; + + #[test] + fn should_emit_map() { + let content = MessageContent::new("message".into()).with_language("fr".into()); + let yaml = emit(&content); + + assert_eq!( + format!("lang: {}\ntext: '{}'", content.language, content.text), + yaml + ); + } + } + } + + mod message { + use super::*; + mod emit_yaml { + use super::*; + + #[test] + fn should_emit_say_message_type_correctly() { + let message = Message::new(MessageType::Say, "message".into()); + let yaml = emit(&message); + + assert_eq!( + format!("type: say\ncontent: '{}'", message.content[0].text), + yaml + ); + } + + #[test] + fn should_emit_warn_message_type_correctly() { + let message = Message::new(MessageType::Warn, "message".into()); + let yaml = emit(&message); + + assert_eq!( + format!("type: warn\ncontent: '{}'", message.content[0].text), + yaml + ); + } + + #[test] + fn should_emit_error_message_type_correctly() { + let message = Message::new(MessageType::Error, "message".into()); + let yaml = emit(&message); + + assert_eq!( + format!("type: error\ncontent: '{}'", message.content[0].text), + yaml + ); + } + + #[test] + fn should_emit_condition_if_it_is_not_empty() { + let message = Message::new(MessageType::Say, "message".into()) + .with_condition("condition1".into()); + let yaml = emit(&message); + + assert_eq!( + format!( + "type: {}\ncontent: '{}'\ncondition: '{}'", + message.message_type, + message.content[0].text, + message.condition.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_a_content_array_if_content_is_multilingual() { + let message = Message::multilingual( + MessageType::Say, + vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ], + ) + .unwrap(); + let yaml = emit(&message); + + assert_eq!( + format!( + "type: {} +content: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + message.message_type, + message.content[0].language(), + message.content[0].text(), + message.content[1].language(), + message.content[1].text() + ), + yaml + ); + } + } + } } diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index 16c855a4..6611cc35 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -6,6 +6,8 @@ use std::{ use saphyr::{MarkedYaml, YamlData}; +use crate::logging; + use super::{ error::{ ExpectedType, LoadMetadataError, MetadataDocumentParsingError, ParseMetadataError, @@ -16,6 +18,7 @@ use super::{ message::Message, plugin_metadata::PluginMetadata, yaml::{YamlObjectType, as_string_node, get_as_slice}, + yaml_emit::{EmitYaml, YamlEmitter}, }; static MERGE_KEY: LazyLock = LazyLock::new(|| as_string_node("<<")); @@ -203,25 +206,48 @@ impl MetadataDocument { } pub fn save(&self, file_path: &Path) -> Result<(), WriteMetadataError> { - // let mut hash = saphyr::Hash::new(); + logging::trace!("Saving metadata list to: {}", file_path.display()); - // hash.insert( - // Yaml::String("bash_tags".into()), - // Yaml::Array( - // self.bash_tags - // .iter() - // .map(|b| Yaml::String(b.to_string())) - // .collect(), - // ), - // ); + let mut emitter = YamlEmitter::new(); - // let mut yaml = Yaml::Hash(hash); + if !self.bash_tags.is_empty() { + emitter.map_key("bash_tags"); - // let mut output = String::new(); - // FIXME: Can't handle the error because it's a type that's not exported by saphyr. - // let emitter = YamlEmitter::new(&mut output).dump(&yaml)?; + emitter.begin_array(); - todo!() + for tag in &self.bash_tags { + emitter.unquoted_str(tag); + } + + emitter.end_array(); + } + + if !self.groups.is_empty() { + emitter.map_key("groups"); + self.groups.emit_yaml(&mut emitter); + } + + if !self.messages.is_empty() { + emitter.map_key("globals"); + self.messages.emit_yaml(&mut emitter); + } + + if !self.plugins.is_empty() || !self.regex_plugins.is_empty() { + emitter.map_key("plugins"); + + emitter.begin_array(); + + for plugin in self.plugins() { + plugin.emit_yaml(&mut emitter); + } + + emitter.end_array(); + } + + std::fs::write(file_path, emitter.into_string()) + .map_err(|e| WriteMetadataError::new(file_path.into(), e.into()))?; + + Ok(()) } pub fn bash_tags(&self) -> &[String] { @@ -497,4 +523,23 @@ plugins: let mut metadata_list = MetadataDocument::default(); metadata_list.load(&path).unwrap(); } + + #[test] + fn save_should_write_the_loaded_metadata() { + let tmp_dir = tempdir().unwrap(); + + let path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&path, METADATA_LIST_YAML).unwrap(); + + let mut metadata = MetadataDocument::default(); + metadata.load(&path).unwrap(); + + let other_path = tmp_dir.path().join("other.yaml"); + metadata.save(&other_path).unwrap(); + + let mut other_metadata = MetadataDocument::default(); + other_metadata.load(&other_path).unwrap(); + + assert_eq!(metadata, other_metadata); + } } diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index 98a2e02e..a0245cd0 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -8,7 +8,8 @@ pub(crate) mod metadata_document; mod plugin_cleaning_data; pub(crate) mod plugin_metadata; mod tag; -pub(crate) mod yaml; +mod yaml; +mod yaml_emit; pub use file::{File, Filename}; pub use group::Group; @@ -17,3 +18,11 @@ pub use message::{Message, MessageContent, MessageType, select_message_content}; pub use plugin_cleaning_data::PluginCleaningData; pub use plugin_metadata::PluginMetadata; pub use tag::{Tag, TagSuggestion}; + +#[cfg(test)] +fn emit(metadata: &T) -> String { + let mut emitter = yaml_emit::YamlEmitter::new(); + metadata.emit_yaml(&mut emitter); + + emitter.into_string() +} diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index ac96e13e..caae3508 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -2,8 +2,12 @@ use saphyr::MarkedYaml; use super::{ error::{MultilingualMessageContentsError, ParseMetadataError}, - message::{MessageContent, parse_message_contents_yaml, validate_message_contents}, + message::{ + MessageContent, emit_message_contents, parse_message_contents_yaml, + validate_message_contents, + }, yaml::{YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_u32_value}, + yaml_emit::{EmitYaml, YamlEmitter}, }; /// Represents data identifying the plugin under which it is stored as dirty or @@ -146,3 +150,153 @@ impl TryFrom<&MarkedYaml> for PluginCleaningData { }) } } + +impl EmitYaml for PluginCleaningData { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("crc"); + emitter.unquoted_str(&format!("0x{:08X}", self.crc)); + + emitter.map_key("util"); + emitter.single_quoted_str(&self.cleaning_utility); + + if self.itm_count > 0 { + emitter.map_key("itm"); + emitter.u32(self.itm_count); + } + + if self.deleted_reference_count > 0 { + emitter.map_key("udr"); + emitter.u32(self.deleted_reference_count); + } + + if self.deleted_navmesh_count > 0 { + emitter.map_key("nav"); + emitter.u32(self.deleted_navmesh_count); + } + + emit_message_contents(&self.detail, emitter, "detail"); + + emitter.end_map(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod emit_yaml { + use crate::metadata::emit; + + use super::*; + + #[test] + fn should_omit_zero_counts() { + let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()); + let yaml = emit(&data); + + assert_eq!("crc: 0xDEADBEEF\nutil: 'TES5Edit'", yaml); + } + + #[test] + fn should_emit_non_zero_counts() { + let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()) + .with_itm_count(1) + .with_deleted_reference_count(2) + .with_deleted_navmesh_count(3); + let yaml = emit(&data); + + assert_eq!( + "crc: 0xDEADBEEF\nutil: 'TES5Edit'\nitm: 1\nudr: 2\nnav: 3", + yaml + ); + } + + #[test] + fn should_emit_map_with_a_detail_string_if_detail_is_monolingual() { + let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()) + .with_detail(vec![MessageContent::new("message".into())]) + .unwrap(); + let yaml = emit(&data); + + assert_eq!( + format!( + "crc: 0xDEADBEEF\nutil: 'TES5Edit'\ndetail: '{}'", + data.detail[0].text() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_a_detail_array_if_detail_is_multilingual() { + let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()) + .with_detail(vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ]) + .unwrap(); + let yaml = emit(&data); + + assert_eq!( + format!( + "crc: 0xDEADBEEF +util: 'TES5Edit' +detail: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + data.detail[0].language(), + data.detail[0].text(), + data.detail[1].language(), + data.detail[1].text() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_all_fields_set() { + let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()) + .with_itm_count(1) + .with_deleted_reference_count(2) + .with_deleted_navmesh_count(3) + .with_detail(vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ]) + .unwrap(); + let yaml = emit(&data); + + assert_eq!( + format!( + "crc: 0xDEADBEEF +util: '{}' +itm: {} +udr: {} +nav: {} +detail: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + data.cleaning_utility, + data.itm_count, + data.deleted_reference_count, + data.deleted_navmesh_count, + data.detail[0].language(), + data.detail[0].text(), + data.detail[1].language(), + data.detail[1].text() + ), + yaml + ); + } + } +} diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index bc7eb83b..7fe9a9a2 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -13,6 +13,7 @@ use super::{ yaml::{ YamlObjectType, get_as_hash, get_as_slice, get_required_string_value, get_string_value, }, + yaml_emit::{EmitYaml, YamlEmitter}, }; pub(crate) const GHOST_FILE_EXTENSION: &str = ".ghost"; @@ -209,7 +210,9 @@ impl PluginMetadata { /// Serialises the plugin metadata as YAML. pub fn as_yaml(&self) -> String { - todo!() + let mut emitter = YamlEmitter::new(); + self.emit_yaml(&mut emitter); + emitter.into_string() } } @@ -353,3 +356,292 @@ fn get_vec<'a, T: TryFrom<&'a MarkedYaml, Error = impl Into> .map(|e| T::try_from(e).map_err(Into::into)) .collect::, _>>() } + +impl EmitYaml for PluginMetadata { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("name"); + emitter.single_quoted_str(self.name()); + + if !self.locations.is_empty() { + emitter.map_key("url"); + self.locations.emit_yaml(emitter); + } + + if let Some(group) = &self.group { + emitter.map_key("group"); + emitter.single_quoted_str(group); + } + + if !self.load_after.is_empty() { + emitter.map_key("after"); + self.load_after.emit_yaml(emitter); + } + + if !self.requirements.is_empty() { + emitter.map_key("req"); + self.requirements.emit_yaml(emitter); + } + + if !self.incompatibilities.is_empty() { + emitter.map_key("inc"); + self.incompatibilities.emit_yaml(emitter); + } + + if !self.messages.is_empty() { + emitter.map_key("msg"); + self.messages.emit_yaml(emitter); + } + + if !self.tags.is_empty() { + emitter.map_key("tag"); + self.tags.emit_yaml(emitter); + } + + if !self.dirty_info.is_empty() { + emitter.map_key("dirty"); + self.dirty_info.emit_yaml(emitter); + } + + if !self.clean_info.is_empty() { + emitter.map_key("clean"); + self.clean_info.emit_yaml(emitter); + } + + emitter.end_map(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod as_yaml { + use super::*; + + #[test] + fn should_return_a_yaml_string_representation() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_load_after_files(vec![File::new("other.esp".into())]); + let yaml = plugin.as_yaml(); + + assert_eq!( + format!( + "name: '{}'\nafter: ['{}']", + plugin.name.string, + plugin.load_after[0].name() + ), + yaml + ); + } + } + + mod emit_yaml { + use super::*; + use crate::metadata::{MessageType, TagSuggestion, emit}; + + #[test] + fn should_omit_group_if_not_set() { + let plugin = PluginMetadata::new("test.esp").unwrap(); + let yaml = emit(&plugin); + + assert_eq!(format!("name: '{}'", plugin.name.string), yaml); + } + + #[test] + fn should_emit_group_if_set() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_group("group1"); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\ngroup: '{}'", + plugin.name.string, + plugin.group.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_load_after_file_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_load_after_files(vec![File::new("other.esp".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nafter: ['{}']", + plugin.name.string, + plugin.load_after[0].name() + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_non_scalar_load_after_file_in_block_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_load_after_files(vec![ + File::new("other.esp".into()).with_condition("condition1".into()), + ]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nafter:\n - name: '{}'\n condition: '{}'", + plugin.name.string, + plugin.load_after[0].name(), + plugin.load_after[0].condition().unwrap(), + ), + yaml + ); + } + + #[test] + fn should_emit_multiple_load_after_files_in_block_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_load_after_files(vec![ + File::new("other1.esp".into()), + File::new("other2.esp".into()), + ]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nafter:\n - '{}'\n - '{}'", + plugin.name.string, + plugin.load_after[0].name(), + plugin.load_after[1].name(), + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_requirements_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_requirements(vec![File::new("other.esp".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nreq: ['{}']", + plugin.name.string, + plugin.requirements[0].name() + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_incompatibility_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_incompatibilities(vec![File::new("other.esp".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\ninc: ['{}']", + plugin.name.string, + plugin.incompatibilities[0].name() + ), + yaml + ); + } + + #[test] + fn should_emit_messages() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_messages(vec![ + Message::new(MessageType::Say, "content1".into()), + Message::new(MessageType::Say, "content2".into()), + ]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nmsg:\n - type: {}\n content: '{}'\n - type: {}\n content: '{}'", + plugin.name.string, + plugin.messages[0].message_type(), + plugin.messages[0].content()[0].text(), + plugin.messages[1].message_type(), + plugin.messages[1].content()[0].text(), + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_tag_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_tags(vec![Tag::new("Relev".into(), TagSuggestion::Addition)]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\ntag: [{}]", + plugin.name.string, + plugin.tags[0].name() + ), + yaml + ); + } + + #[test] + fn should_emit_dirty_info() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_dirty_info(vec![PluginCleaningData::new(0xDEADBEEF, "utility".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\ndirty:\n - crc: 0x{:8X}\n util: '{}'", + plugin.name(), + plugin.dirty_info[0].crc(), + plugin.dirty_info[0].cleaning_utility() + ), + yaml + ); + } + + #[test] + fn should_emit_clean_info() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_clean_info(vec![PluginCleaningData::new(0xDEADBEEF, "utility".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nclean:\n - crc: 0x{:8X}\n util: '{}'", + plugin.name(), + plugin.clean_info[0].crc(), + plugin.clean_info[0].cleaning_utility() + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_location_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_locations(vec![Location::new("https://www.example.com".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nurl: ['{}']", + plugin.name(), + plugin.locations[0].url() + ), + yaml + ); + } + } +} diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index 99d21d70..a689337d 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -3,6 +3,8 @@ use saphyr::YamlData; use super::error::ExpectedType; use super::error::ParseMetadataError; use super::yaml::{YamlObjectType, get_required_string_value, parse_condition}; +use super::yaml_emit::EmitYaml; +use super::yaml_emit::YamlEmitter; /// Represents whether a Bash Tag suggestion is for addition or removal. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -96,3 +98,59 @@ fn name_and_suggestion(value: &str) -> (String, TagSuggestion) { (value.to_string(), TagSuggestion::Addition) } } + +impl EmitYaml for Tag { + fn is_scalar(&self) -> bool { + self.condition.is_none() + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + if let Some(condition) = &self.condition { + emitter.begin_map(); + + emitter.map_key("name"); + if self.is_addition() { + emitter.unquoted_str(&self.name); + } else { + emitter.unquoted_str(&format!("-{}", self.name)); + } + + emitter.map_key("condition"); + emitter.single_quoted_str(condition); + + emitter.end_map(); + } else if self.is_addition() { + emitter.unquoted_str(&self.name); + } else { + emitter.unquoted_str(&format!("-{}", self.name)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod emit_yaml { + use crate::metadata::emit; + + use super::*; + + #[test] + fn should_emit_name_only_if_unconditional_addition() { + let tag = Tag::new("name1".into(), TagSuggestion::Addition); + let yaml = emit(&tag); + + assert_eq!(tag.name(), yaml); + } + + #[test] + fn should_emit_map_if_there_is_a_condition() { + let tag = + Tag::new("name1".into(), TagSuggestion::Removal).with_condition("condition".into()); + let yaml = emit(&tag); + + assert_eq!("name: -name1\ncondition: 'condition'", yaml); + } + } +} diff --git a/src/metadata/yaml_emit.rs b/src/metadata/yaml_emit.rs new file mode 100644 index 00000000..8f8b2f41 --- /dev/null +++ b/src/metadata/yaml_emit.rs @@ -0,0 +1,315 @@ +pub trait EmitYaml { + fn is_scalar(&self) -> bool; + + fn emit_yaml(&self, emitter: &mut YamlEmitter); +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct YamlEmitter { + buffer: String, + scope: Vec, + style: YamlStyle, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +enum YamlBlock { + Array, + Map, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +enum YamlStyle { + Flow, + Block, +} + +impl YamlEmitter { + const INDENT_UNIT: &str = " "; + const ARRAY_ELEMENT_PREFIX: &str = "- "; + + pub fn new() -> Self { + Self { + buffer: String::new(), + scope: vec![], + style: YamlStyle::Block, + } + } + + pub fn into_string(self) -> String { + self.buffer + } + + pub fn unquoted_str(&mut self, value: &str) { + if self.style == YamlStyle::Block { + self.write_prefix(); + } + + if can_emit_unquoted(value, self.style) { + self.write(value); + } else if can_single_quote(value) { + self.write(&single_quote(value)); + } else { + self.write(&double_quote(value)); + } + } + + pub fn single_quoted_str(&mut self, value: &str) { + if self.style == YamlStyle::Block { + self.write_prefix(); + } + + if can_single_quote(value) { + self.write(&single_quote(value)); + } else { + self.write(&double_quote(value)); + } + } + + pub fn u32(&mut self, value: u32) { + if self.style == YamlStyle::Block { + self.write_prefix(); + } + + self.write(&value.to_string()); + } + + pub fn begin_map(&mut self) { + if self.scope.last() == Some(&YamlBlock::Array) { + self.end_line(); + self.write_indent(); + self.write(Self::ARRAY_ELEMENT_PREFIX); + } + } + + pub fn end_map(&mut self) { + if self.scope.last() == Some(&YamlBlock::Map) { + self.scope.pop(); + } + } + + /// This assumes that the given key is valid to be written as an unquoted string, and expects a string literal so that it's obvious that a given value is valid. + pub fn map_key(&mut self, key: &'static str) { + match self.scope.last() { + Some(&YamlBlock::Map) => { + self.end_line(); + self.write_indent(); + } + _ => self.scope.push(YamlBlock::Map), + } + + self.write(&format!("{}:", key)); + } + + pub fn begin_array(&mut self) { + if self.style == YamlStyle::Flow { + if self.scope.last() == Some(&YamlBlock::Map) { + self.write(" "); + } + self.write("["); + } + + self.scope.push(YamlBlock::Array); + } + + pub fn end_array(&mut self) { + if self.scope.last() == Some(&YamlBlock::Array) { + self.scope.pop(); + } + + if self.style == YamlStyle::Flow { + self.write("]"); + } + } + + pub fn set_flow_style(&mut self) { + self.style = YamlStyle::Flow; + } + + pub fn set_block_style(&mut self) { + self.style = YamlStyle::Block; + } + + fn end_line(&mut self) { + self.write("\n"); + } + + fn write_indent(&mut self) { + // If in a map, no indent is needed, but an array needs an indent, and a map in an array needs an indent. + if !self.scope.is_empty() { + for _ in 0..self.scope.len() - 1 { + self.write(Self::INDENT_UNIT); + } + } + } + + fn write_prefix(&mut self) { + match self.scope.last() { + Some(&YamlBlock::Array) => { + self.end_line(); + self.write_indent(); + self.write(Self::ARRAY_ELEMENT_PREFIX); + } + Some(&YamlBlock::Map) => self.write(" "), + _ => self.write_indent(), + } + } + + fn write(&mut self, value: &str) { + self.buffer += value; + } +} + +fn can_single_quote(value: &str) -> bool { + // Single-quoted strings are restricted to printable characters + // + value.chars().all(is_printable) +} + +fn is_printable(c: char) -> bool { + // + matches!(c, + '\x09' | '\x0A' | '\x0D' | '\x20'..='\x7E' | '\u{0085}' | '\u{00A0}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{010000}'..='\u{10FFFF}' + ) +} + +fn is_yaml_whitespace(c: char) -> bool { + c == ' ' || c == '\t' +} + +fn is_flow_indicator(c: char) -> bool { + matches!(c, '[' | ']' | '{' | '}' | ',') +} + +fn can_emit_unquoted(value: &str, style: YamlStyle) -> bool { + // + if value.is_empty() + || value.starts_with(is_yaml_whitespace) + || value.ends_with(is_yaml_whitespace) + { + return false; + } + + if value.starts_with(|c| { + matches!( + c, + ',' | '[' + | ']' + | '{' + | '}' + | '#' + | '&' + | '*' + | '!' + | '|' + | '>' + | '\'' + | '"' + | '%' + | '@' + | '`' + ) + }) { + return false; + } + + if value.starts_with("? ") + || value.starts_with("?\t") + || value.starts_with("- ") + || value.starts_with("-\t") + { + return false; + } + + if value.contains(": ") + || value.contains(":\t") + || value.contains(" #") + || value.contains("\t#") + { + return false; + } + + if style == YamlStyle::Flow { + !value.contains(is_flow_indicator) + } else { + true + } +} + +fn single_quote(value: &str) -> String { + // Single-quoted strings need single quotes escaped by repeating them. + // + format!("'{}'", value.replace('\'', "''")) +} + +fn double_quote(value: &str) -> String { + // + value + .chars() + .map(|c| { + if is_printable(c) { + c.to_string() + } else { + match c { + '\x00' => "\\0".to_string(), + '\x07' => "\\a".to_string(), + '\x08' => "\\b".to_string(), + '\x09' => "\\t".to_string(), + '\x0A' => "\\n".to_string(), + '\x0B' => "\\v".to_string(), + '\x0C' => "\\f".to_string(), + '\x0D' => "\\r".to_string(), + '\x1B' => "\\e".to_string(), + '\x20' => "\\x20".to_string(), + '"' => "\\\"".to_string(), + '/' => "\\/".to_string(), + '\\' => "\\\\".to_string(), + '\u{0085}' => "\\N".to_string(), + '\u{00A0}' => "\\_".to_string(), + '\u{2028}' => "\\L".to_string(), + '\u{2029}' => "\\P".to_string(), + '\u{00}'..='\u{FF}' => format!("\\x{:2X}", u32::from(c)), + '\u{0100}'..='\u{FFFF}' => format!("\\u{:4X}", u32::from(c)), + c => format!("\\U{:8X}", u32::from(c)), + } + } + }) + .collect() +} + +impl EmitYaml for &[T] { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + match self { + [] => {} + [element] if element.is_scalar() => { + emitter.set_flow_style(); + emitter.begin_array(); + element.emit_yaml(emitter); + emitter.end_array(); + emitter.set_block_style(); + } + elements => { + emitter.begin_array(); + + for element in elements.iter() { + element.emit_yaml(emitter); + } + + emitter.end_array(); + } + } + } +} + +impl EmitYaml for Vec { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + self.as_slice().emit_yaml(emitter); + } +} From 2a19d0cad8ca66a085613293c8b0b9c47f220e08 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 2 Mar 2025 23:07:33 +0000 Subject: [PATCH 007/206] Fix bugs due to translation mistakes --- src/archive/parse.rs | 2 +- src/metadata/metadata_document.rs | 25 +++++++++++++++++++++++-- src/sorting/dfs.rs | 4 ++++ src/sorting/plugins.rs | 9 +++++---- src/sorting/validate.rs | 2 +- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/archive/parse.rs b/src/archive/parse.rs index b8e3363c..639a5632 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -98,7 +98,7 @@ pub(super) fn to_u32(bytes: &[u8]) -> u32 { pub(super) fn to_u64(bytes: &[u8]) -> u64 { let array = - <[u8; 8]>::try_from(&bytes[..4]).expect("Bytes slice is large enough to hold a u64"); + <[u8; 8]>::try_from(&bytes[..8]).expect("Bytes slice is large enough to hold a u64"); u64::from_le_bytes(array) } diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index 6611cc35..16cebb3b 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -23,7 +23,7 @@ use super::{ static MERGE_KEY: LazyLock = LazyLock::new(|| as_string_node("<<")); -#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct MetadataDocument { bash_tags: Vec, groups: Vec, @@ -287,7 +287,16 @@ impl MetadataDocument { } pub fn set_groups(&mut self, groups: Vec) { - self.groups = groups; + // Ensure that the default group is present. + let default_group_exists = groups.iter().any(|g| g.name() == Group::DEFAULT_NAME); + + if !default_group_exists { + self.groups.clear(); + self.groups.push(Group::default()); + self.groups.extend(groups); + } else { + self.groups = groups; + } } pub fn set_plugin_metadata(&mut self, plugin_metadata: PluginMetadata) { @@ -310,6 +319,18 @@ impl MetadataDocument { } } +impl std::default::Default for MetadataDocument { + fn default() -> Self { + Self { + bash_tags: Default::default(), + groups: vec![Group::default()], + messages: Default::default(), + plugins: Default::default(), + regex_plugins: Default::default(), + } + } +} + fn process_merge_keys(mut yaml: MarkedYaml) -> Result { match yaml.data { YamlData::Array(a) => { diff --git a/src/sorting/dfs.rs b/src/sorting/dfs.rs index c5236b09..3b5a8f15 100644 --- a/src/sorting/dfs.rs +++ b/src/sorting/dfs.rs @@ -188,6 +188,10 @@ impl<'a, N, F: FnMut(&N) -> String> DfsVisitor<'a> for CycleDetector<'a, N, F> { fn visit_forward_or_cross_edge(&mut self, _: EdgeReference<'a, EdgeType>) {} fn visit_back_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>) { + if self.found_cycle { + return; + } + self.visit_tree_edge(edge_ref); let target_name = (self.get_node_name)(&self.graph[edge_ref.target()]); diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 1b590b49..17a8b90d 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -646,10 +646,10 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { // significantly slower because it generally involves going further back // along the "new load order" path. let previous_node_position = new_load_order - .get(range_start..) - .expect("last_pos is within the new_load_order vec") .iter() - .rposition(|ni| !self.path_exists(node_index, *ni)); + .skip(range_start) + .rposition(|ni| !self.path_exists(node_index, *ni)) + .map(|p| range_start + p); // Add an edge going from the found vertex to this one, in case it // doesn't exist (we only know there's not a path going the other way). @@ -662,7 +662,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { // Insert position is just after the found vertex, and a forward iterator // points to the element one after the element pointed to by the // corresponding reverse iterator. - let insert_position = previous_node_position.map(|i| i + 1).unwrap_or(0); + let insert_position = previous_node_position.map(|i| i + 1).unwrap_or(range_start); // Add an edge going from this vertex to the next one in the "new load // order" path, in case there isn't already one. @@ -672,6 +672,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { // Now update newLoadOrder with the vertex's new position. new_load_order.insert(insert_position, node_index); + processed_nodes.insert(node_index); if log_enabled!(log::Level::Debug) { if let Some(next_node_index) = new_load_order.get(insert_position + 1) { diff --git a/src/sorting/validate.rs b/src/sorting/validate.rs index 0a16de9b..11068891 100644 --- a/src/sorting/validate.rs +++ b/src/sorting/validate.rs @@ -39,7 +39,7 @@ pub fn validate_specific_and_hardcoded_edges( log::trace!("Validating specific and early-loading plugin edges..."); let non_masters_set: HashSet> = - masters.iter().map(|p| UniCase::new(p.name())).collect(); + non_masters.iter().map(|p| UniCase::new(p.name())).collect(); let blueprint_masters_set: HashSet> = blueprint_masters .iter() .map(|p| UniCase::new(p.name())) From 12e41639ea0f3ce1676ce5ad4096afa575a68c39 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 4 Mar 2025 17:43:38 +0000 Subject: [PATCH 008/206] Fix another translation bug --- src/metadata/metadata_document.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index 16cebb3b..8ff1f4b0 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -424,7 +424,7 @@ fn find_prelude_bounds(masterlist: &str) -> Option<(usize, usize)> { break; } - pos = next_line_break_pos + 1; + pos += next_line_break_pos + 1; if let Some(c) = masterlist.as_bytes().get(pos) { if *c != b' ' && *c != b'#' && *c != b'\n' { From 99f81ca7832cb5264448f4d3a00137a8350fc428 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 4 Mar 2025 21:54:21 +0000 Subject: [PATCH 009/206] Fix calling set_logging_callback multiple times Store the callback as a global, independent of the log crate, and add logging macros that invoke the callback as well as the log crate's log! macro. --- README.md | 2 +- src/archive/parse.rs | 8 +- src/database/mod.rs | 3 +- src/game.rs | 30 +++--- src/logging.rs | 165 +++++++++++++++++++----------- src/metadata/metadata_document.rs | 6 +- src/metadata/plugin_metadata.rs | 4 +- src/plugin/mod.rs | 6 +- src/sorting/dfs.rs | 6 +- src/sorting/groups.rs | 22 ++-- src/sorting/plugins.rs | 46 +++++---- src/sorting/validate.rs | 10 +- 12 files changed, 184 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index e8e51e70..9fbfb6c2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Currently complete: - [x] Public API types and function declarations - [x] Public API doc comments - [x] Library versioning -- [ ] Setting a logging callback +- [x] Setting a logging callback - [x] Parsing metadata from YAML - [x] Serialising metadata to YAML - [x] Game-related functionality diff --git a/src/archive/parse.rs b/src/archive/parse.rs index 639a5632..2976aaa5 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -6,7 +6,7 @@ use std::{ }; use super::error::{ArchiveParsingError, ArchivePathParsingError}; -use crate::plugin::has_ascii_extension; +use crate::{logging, plugin::has_ascii_extension}; use super::{ba2, bsa}; @@ -14,7 +14,7 @@ pub fn assets_in_archives(archive_paths: &[PathBuf]) -> BTreeMap> = BTreeMap::new(); for archive_path in archive_paths { - log::trace!( + logging::trace!( "Getting assets loaded from the Bethesda archive at \"{}\"", archive_path.display() ); @@ -22,7 +22,7 @@ pub fn assets_in_archives(archive_paths: &[PathBuf]) -> BTreeMap a, Err(e) => { - log::error!( + logging::error!( "Encountered an error while trying to read the Bethesda archive at \"{}\": {}", archive_path.display(), e @@ -38,7 +38,7 @@ pub fn assets_in_archives(archive_paths: &[PathBuf]) -> BTreeMap Result { - log::info!( + logging::info!( "Attempting to create a game handle for game type {} with game path {:?}", game_type, game_path @@ -191,7 +195,7 @@ impl Game { game_path: &Path, game_local_path: &Path, ) -> Result { - log::info!( + logging::info!( "Attempting to create a game handle for game type {} with game path {:?} and game local path {:?}", game_type, game_path, @@ -366,7 +370,7 @@ impl Game { self.cache.set_archive_paths(archive_paths); - log::trace!("Starting loading {}s.", load_scope); + logging::trace!("Starting loading {}s.", load_scope); let plugins: Vec<_> = plugin_paths .par_iter() @@ -445,9 +449,9 @@ impl Game { .collect::, _>>()?; if log::log_enabled!(log::Level::Debug) { - log::debug!("Current load order:"); + logging::debug!("Current load order:"); for plugin_name in plugin_names { - log::debug!("\t{}", plugin_name); + logging::debug!("\t{}", plugin_name); } } @@ -460,9 +464,9 @@ impl Game { )?; if log::log_enabled!(log::Level::Debug) { - log::debug!("Sorted load order:"); + logging::debug!("Sorted load order:"); for plugin_name in &new_load_order { - log::debug!("\t{}", plugin_name); + logging::debug!("\t{}", plugin_name); } } @@ -650,7 +654,7 @@ fn try_load_plugin( match Plugin::new(game_type, game_cache, &resolved_path, load_scope) { Ok(p) => Some(p), Err(e) => { - log::error!( + logging::error!( "Caught error while trying to load \"{}\": {}", plugin_path.display(), e @@ -665,7 +669,7 @@ fn resolve_plugin_path(game_type: GameType, data_path: &Path, plugin_path: &Path if game_type != GameType::OpenMW && !plugin_path.exists() { if let Some(filename) = plugin_path.file_name() { - log::debug!( + logging::debug!( "Could not find plugin at {}, adding {} file extension", plugin_path.display(), GHOST_FILE_EXTENSION @@ -699,14 +703,14 @@ fn update_loaded_plugin_state<'a>( } if let Err(e) = state.clear_condition_cache() { - log::error!("The condition cache's lock is poisoned, assigning a new cache"); + logging::error!("The condition cache's lock is poisoned, assigning a new cache"); *e.into_inner() = HashMap::new(); } state.set_plugin_versions(&plugin_versions); if let Err(e) = state.set_cached_crcs(&plugin_crcs) { - log::error!( + logging::error!( "The condition interpreter's CRC cache's lock is poisoned, clearing the cache and assigning a new value" ); let mut cache = e.into_inner(); diff --git a/src/logging.rs b/src/logging.rs index e2503ebc..82be3f04 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,6 +1,9 @@ -use log::{Metadata, Record}; +use std::sync::{LazyLock, RwLock}; -// const LOGGER: OnceCell>> = OnceCell::new(); +type Callback = dyn Fn(LogLevel, &str) + Send + Sync; + +pub(crate) static LOGGER: LazyLock>> = + LazyLock::new(|| RwLock::new(Box::new(|_, _| {}))); /// Set the callback function that is called when logging. /// @@ -10,12 +13,15 @@ pub fn set_logging_callback(callback: T) where T: Fn(LogLevel, &str) + Send + Sync + 'static, { - // FIXME: set_boxed_logger can only be called once, and it's not possible to retrieve and downcast the logger from log once set. - let logger = Box::new(CallbackLogger { callback }); + let boxed = Box::new(callback); - log::set_boxed_logger(logger) - .map(|_| log::set_max_level(log::LevelFilter::Trace)) - .unwrap(); + match LOGGER.write() { + Ok(mut logger) => *logger = boxed, + Err(e) => { + *e.into_inner() = boxed; + LOGGER.clear_poison(); + } + } } /// Codes used to specify different levels of API logging. @@ -42,37 +48,53 @@ impl std::fmt::Display for LogLevel { } } -impl From for LogLevel { - fn from(value: log::Level) -> Self { +impl From for log::Level { + fn from(value: LogLevel) -> Self { match value { - log::Level::Trace => LogLevel::Trace, - log::Level::Debug => LogLevel::Debug, - log::Level::Info => LogLevel::Info, - log::Level::Warn => LogLevel::Warning, - log::Level::Error => LogLevel::Error, + LogLevel::Trace => log::Level::Trace, + LogLevel::Debug => log::Level::Debug, + LogLevel::Info => log::Level::Info, + LogLevel::Warning => log::Level::Warn, + LogLevel::Error => log::Level::Error, + LogLevel::Fatal => log::Level::Error, } } } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] -struct CallbackLogger { - callback: T, -} +macro_rules! log { + ($level:expr, $($arg:tt)+) => { + // Log using the Rust log crate, as it's probably good to support that. + log::log!(log::Level::from($level), $($arg)+); -impl log::Log for CallbackLogger { - fn enabled(&self, _metadata: &Metadata) -> bool { - true - } - - fn log(&self, record: &Record) { - if self.enabled(record.metadata()) { - (self.callback)(record.level().into(), &format!("{}", record.args())); + // Also log using the callback. + if let Ok(logger) = $crate::logging::LOGGER.read() { + logger($level, &std::fmt::format(format_args!($($arg)+))); } - } - - fn flush(&self) {} + }; } +macro_rules! error { + ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Error, $($arg)+) }; +} + +macro_rules! warning { + ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Warning, $($arg)+) }; +} + +macro_rules! info { + ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Info, $($arg)+) }; +} + +macro_rules! debug { + ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Debug, $($arg)+) }; +} + +macro_rules! trace { + ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Trace, $($arg)+) }; +} + +pub(crate) use {debug, error, info, log, trace, warning as warn}; + #[cfg(test)] mod tests { use super::*; @@ -82,9 +104,60 @@ mod tests { use std::sync::{Arc, LazyLock, Mutex}; + // Since the callback is a global object, these tests need to be run in + // series so that one doesn't switch out the callback between another + // doing the same and trying to use it. + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn should_support_a_function() { + let _lock = TEST_LOCK.lock().unwrap(); + + static MESSAGES: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + + fn callback(level: LogLevel, message: &str) { + if let Ok(mut messages) = MESSAGES.lock() { + messages.push((level, message.to_string())); + } + } + + set_logging_callback(callback); + + error!("Test message"); + + assert_eq!( + vec![(LogLevel::Error, "Test message".into())], + *MESSAGES.lock().unwrap() + ); + } + + #[test] + fn should_support_a_closure_with_captured_state() { + let _lock = TEST_LOCK.lock().unwrap(); + + let messages = Arc::new(Mutex::new(Vec::<(LogLevel, String)>::new())); + let cloned_messages = messages.clone(); + let callback = move |level, message: &str| { + if let Ok(mut messages) = cloned_messages.lock() { + messages.push((level, message.to_string())); + } + }; + + set_logging_callback(callback); + + error!("Test message"); + + assert_eq!( + vec![(LogLevel::Error, "Test message".into())], + *messages.lock().unwrap() + ); + } + #[test] - #[ignore] fn set_logging_callback_should_be_callable_multiple_times() { + let _lock = TEST_LOCK.lock().unwrap(); + let callback = |_, _: &str| {}; set_logging_callback(callback); @@ -98,49 +171,25 @@ mod tests { set_logging_callback(callback); - log::error!("Test message"); + error!("Test message"); assert_eq!( vec![(LogLevel::Error, "Test message".into())], *messages.lock().unwrap() ); - } - #[test] - fn should_support_a_closure_with_captured_state() { - let messages = Arc::new(Mutex::new(Vec::<(LogLevel, String)>::new())); - let cloned_messages = messages.clone(); - let callback = move |level, message: &str| { - if let Ok(mut messages) = cloned_messages.lock() { - messages.push((level, message.to_string())); - } - }; - - set_logging_callback(callback); - - log::error!("Test message"); - - assert_eq!( - vec![(LogLevel::Error, "Test message".into())], - *messages.lock().unwrap() - ); - } - - #[test] - #[ignore] - fn should_support_a_function() { static MESSAGES: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); - fn callback(level: LogLevel, message: &str) { + fn callback_fn(level: LogLevel, message: &str) { if let Ok(mut messages) = MESSAGES.lock() { messages.push((level, message.to_string())); } } - set_logging_callback(callback); + set_logging_callback(callback_fn); - log::error!("Test message"); + error!("Test message"); assert_eq!( vec![(LogLevel::Error, "Test message".into())], diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index 8ff1f4b0..cc816c9f 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -41,7 +41,7 @@ impl MetadataDocument { )); } - log::trace!("Loading file: {:?}", file_path); + logging::trace!("Loading file: {:?}", file_path); let content = std::fs::read_to_string(file_path) .map_err(|e| LoadMetadataError::from_io_error(file_path.into(), e))?; @@ -49,7 +49,7 @@ impl MetadataDocument { self.load_from_str(&content) .map_err(|e| LoadMetadataError::new(file_path.into(), e))?; - log::trace!( + logging::trace!( "Successfully loaded metadata from file at \"{:?}\".", file_path ); @@ -87,7 +87,7 @@ impl MetadataDocument { self.load_from_str(&masterlist) .map_err(|e| LoadMetadataError::new(masterlist_path.into(), e))?; - log::trace!( + logging::trace!( "Successfully loaded metadata from file at \"{:?}\".", masterlist_path ); diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 7fe9a9a2..d705e7fe 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -1,7 +1,7 @@ use fancy_regex::Regex; use saphyr::MarkedYaml; -use crate::regex; +use crate::{logging, regex}; use super::{ error::{MetadataParsingErrorReason, ParseMetadataError, RegexError}, @@ -201,7 +201,7 @@ impl PluginMetadata { pub fn name_matches(&self, other_name: &str) -> bool { if let Some(regex) = &self.name.regex { regex.is_match(other_name).inspect_err(|e| { - log::error!("Encountered an error while trying to match the regex {} to the string {}: {}", regex.as_str(), other_name, e); + logging::error!("Encountered an error while trying to match the regex {} to the string {}: {}", regex.as_str(), other_name, e); }).unwrap_or(false) } else { unicase::eq(self.name.string.as_str(), other_name) diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 54e8611c..0955a3e4 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -16,7 +16,7 @@ use crate::{ GameType, archive::{assets_in_archives, find_associated_archives}, game::GameCache, - regex, + logging, regex, }; use error::{ InvalidFilenameReason, LoadPluginError, PluginDataError, PluginValidationError, @@ -337,7 +337,7 @@ pub(crate) fn validate_plugin_path_and_header( if game_type == GameType::OpenMW && has_ascii_extension(plugin_path, "omwscripts") { Ok(()) } else if !has_plugin_file_extension(game_type, plugin_path) { - log::debug!( + logging::debug!( "The file \"{}\" is not a valid plugin", plugin_path.display() ); @@ -349,7 +349,7 @@ pub(crate) fn validate_plugin_path_and_header( { Ok(()) } else { - log::debug!( + logging::debug!( "The file \"{}\" is not a valid plugin", plugin_path.display() ); diff --git a/src/sorting/dfs.rs b/src/sorting/dfs.rs index 3b5a8f15..6f2a8aa3 100644 --- a/src/sorting/dfs.rs +++ b/src/sorting/dfs.rs @@ -6,7 +6,7 @@ use petgraph::{ visit::EdgeRef, }; -use crate::{EdgeType, Vertex}; +use crate::{EdgeType, Vertex, logging}; pub trait DfsVisitor<'a> { fn visit_tree_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>); @@ -200,7 +200,9 @@ impl<'a, N, F: FnMut(&N) -> String> DfsVisitor<'a> for CycleDetector<'a, N, F> { self.trail.drain(..pos); self.found_cycle = true; } else { - log::error!("The target of a back edge cannot be found in the current visitor trail"); + logging::error!( + "The target of a back edge cannot be found in the current visitor trail" + ); } } diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs index 8d2bf053..a4f9d7d5 100644 --- a/src/sorting/groups.rs +++ b/src/sorting/groups.rs @@ -3,11 +3,13 @@ use std::{cmp::Reverse, collections::HashMap}; use petgraph::{Graph, algo::bellman_ford, graph::NodeIndex}; use crate::{ - EdgeType, Vertex, + EdgeType, Vertex, logging, metadata::Group, - sorting::dfs::find_cycle, - sorting::error::{ - BuildGroupsGraphError, CyclicInteractionError, PathfindingError, UndefinedGroupError, + sorting::{ + dfs::find_cycle, + error::{ + BuildGroupsGraphError, CyclicInteractionError, PathfindingError, UndefinedGroupError, + }, }, }; @@ -28,7 +30,7 @@ pub fn build_groups_graph( let mut graph = GroupsGraph::new(); let mut group_nodes: HashMap<&str, NodeIndex> = HashMap::new(); - log::trace!("Adding masterlist groups to groups graph..."); + logging::trace!("Adding masterlist groups to groups graph..."); add_groups( &mut graph, &mut group_nodes, @@ -36,7 +38,7 @@ pub fn build_groups_graph( EdgeType::MasterlistLoadAfter, )?; - log::trace!("Adding user groups to groups graph..."); + logging::trace!("Adding user groups to groups graph..."); add_groups( &mut graph, &mut group_nodes, @@ -74,7 +76,7 @@ fn add_groups<'a>( for group in groups { if log::log_enabled!(log::Level::Trace) { - log::trace!( + logging::trace!( "Group \"{}\" directly loads after groups \"{}\"", group.name(), group.after_groups().join(", ") @@ -133,7 +135,7 @@ pub fn find_path( let preceding_vertex = match paths.predecessors.get(current.index()) { Some(Some(v)) => v, Some(None) => { - log::info!( + logging::info!( "No path found from {} to {} while looking for path to {}", graph[from_vertex], graph[current], @@ -147,7 +149,7 @@ pub fn find_path( }; if *preceding_vertex == current { - log::error!( + logging::error!( "Unreachable vertex {} encountered while looking for vertex {}", graph[current], graph[from_vertex] @@ -187,7 +189,7 @@ fn find_node_by_weight( { Some(n) => Ok(n), None => { - log::error!("Can't find group with name {}", weight); + logging::error!("Can't find group with name {}", weight); Err(UndefinedGroupError::new(weight.to_string())) } } diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 17a8b90d..0caefd49 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -11,11 +11,13 @@ use petgraph::{ }; use crate::{ - EdgeType, Plugin, + EdgeType, Plugin, logging, metadata::{File, Group, PluginMetadata}, plugin::error::PluginDataError, - sorting::error::{CyclicInteractionError, PathfindingError, SortingError, UndefinedGroupError}, - sorting::groups::{get_default_group_node, sorted_group_nodes}, + sorting::{ + error::{CyclicInteractionError, PathfindingError, SortingError, UndefinedGroupError}, + groups::{get_default_group_node, sorted_group_nodes}, + }, }; use super::{ @@ -171,7 +173,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { return; } - log::debug!( + logging::debug!( "Adding {} edge from \"{}\" to \"{}\".", edge_type, self.inner[from].name(), @@ -188,7 +190,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { } fn add_specific_edges(&mut self) -> Result<(), SortingError> { - log::trace!("Adding edges based on plugin data and non-group metadata..."); + logging::trace!("Adding edges based on plugin data and non-group metadata..."); let mut node_index_iter = self.node_indices(); while let Some(node_index) = node_index_iter.next() { @@ -250,7 +252,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { } fn add_early_loading_plugin_edges(&mut self, early_loading_plugins: &[String]) { - log::trace!( + logging::trace!( "Adding edges for implicitly active plugins and plugins with hardcoded positions..." ); @@ -310,7 +312,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { } fn add_group_edges(&mut self, groups_graph: &GroupsGraph) -> Result<(), UndefinedGroupError> { - log::trace!("Adding edges based on plugin group memberships..."); + logging::trace!("Adding edges based on plugin group memberships..."); // First build a map from groups to the plugins in those groups. let plugins_in_groups = get_plugins_in_groups(&self.inner); @@ -366,7 +368,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { } fn add_overlap_edges(&mut self) -> Result<(), SortingError> { - log::trace!("Adding edges for overlapping plugins..."); + logging::trace!("Adding edges for overlapping plugins..."); let mut node_index_iter = self.node_indices(); while let Some(node_index) = node_index_iter.next() { @@ -374,7 +376,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { let plugin_asset_count = plugin.asset_count(); if plugin.override_record_count == 0 && plugin_asset_count == 0 { - log::debug!( + logging::debug!( "Skipping vertex for \"{}\": the plugin contains no override records and loads no assets", plugin.name() ); @@ -447,7 +449,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { if !self.path_exists(to_index, from_index) { self.add_edge(from_index, to_index, edge_type); } else { - log::debug!( + logging::debug!( "Skipping {} edge from \"{}\" to \"{}\" as it would create a cycle.", edge_type, self[from_index].name(), @@ -462,7 +464,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { } fn add_tie_break_edges(&mut self) -> Result<(), PathfindingError> { - log::trace!("Adding edges to break ties between plugins..."); + logging::trace!("Adding edges to break ties between plugins..."); // In order for the sort to be performed stably, there must be only one // possible result. This can be enforced by adding edges between all vertices @@ -536,12 +538,12 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { new_load_order.push(current); processed_nodes.insert(current); - log::debug!( + logging::debug!( "The plugin \"{}\" loads at the end of the new load order so far.", self[current].name() ); } else if new_load_order.last() != Some(¤t) { - log::trace!( + logging::trace!( "The plugin \"{}\" has already been processed and is not the last in the new load order, determining where to place \"{}\".", self[current].name(), self[next].name() @@ -572,7 +574,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { // Don't need to add any edges because there's nothing for nextVertex // to load after at this point. if log_enabled!(log::Level::Debug) { - log::debug!( + logging::debug!( "The path ends with the first plugin checked, treating the following path as the start of the load order: {}", path_to_string(&self.inner, &path_from_next_node) ); @@ -630,7 +632,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { // e.g. because it was visited earlier in the old load order or // as part of a path that was processed. In that case just skip it. if processed_nodes.contains(&node_index) { - log::debug!( + logging::debug!( "The plugin \"{}\" has already been processed, skipping it.", self[node_index].name() ); @@ -676,13 +678,13 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { if log_enabled!(log::Level::Debug) { if let Some(next_node_index) = new_load_order.get(insert_position + 1) { - log::debug!( + logging::debug!( "The plugin \"{}\" loads before \"{}\" in the new load order.", self[node_index].name(), self[*next_node_index].name() ); } else { - log::debug!( + logging::debug!( "The plugin \"{}\" loads at the end of the new load order so far.", self[node_index].name() ); @@ -704,7 +706,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { } fn is_hamiltonian_path(&mut self, path: &[NodeIndex]) -> Option<(NodeIndex, NodeIndex)> { - log::trace!("Checking uniqueness of path through plugin graph..."); + logging::trace!("Checking uniqueness of path through plugin graph..."); path.windows(2).find_map(|slice| match slice { [a, b] => self.inner.contains_edge(*a, *b).then_some((*a, *b)), @@ -861,7 +863,7 @@ fn sort_plugins_partition( let sorted_nodes = graph.topological_sort()?; if let Some((first, second)) = graph.is_hamiltonian_path(&sorted_nodes) { - log::error!( + logging::error!( "The path is not unique. No edge exists between {} and {}", graph[first].name(), graph[second].name() @@ -928,7 +930,7 @@ impl<'a, 'b, T: SortingPlugin> PathFinder<'a, 'b, T> { path.push(*next); current_node = *next; } else { - log::error!( + logging::error!( "Could not find parent vertex of {}. Path so far is {}", self.graph[current_node].name(), path_to_string(self.graph, &path) @@ -949,7 +951,7 @@ impl<'a, 'b, T: SortingPlugin> PathFinder<'a, 'b, T> { path.push(*next); current_node = *next; } else { - log::error!( + logging::error!( "Could not find child vertex of {}. Path so far is {}", self.graph[current_node].name(), path_to_string(self.graph, &path) @@ -1122,7 +1124,7 @@ impl<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, self.plugins_graph .add_edge(from_plugin, *to_plugin, edge_type); } else { - log::debug!( + logging::debug!( "Skipping group edge from \"{}\" to \"{}\" as it would create a cycle.", self.plugins_graph[from_plugin].name(), self.plugins_graph[*to_plugin].name() diff --git a/src/sorting/validate.rs b/src/sorting/validate.rs index 11068891..e2b193f1 100644 --- a/src/sorting/validate.rs +++ b/src/sorting/validate.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use unicase::UniCase; use crate::{ - EdgeType, Vertex, + EdgeType, Vertex, logging, sorting::error::{CyclicInteractionError, PluginGraphValidationError, UndefinedGroupError}, }; @@ -36,7 +36,7 @@ pub fn validate_specific_and_hardcoded_edges( non_masters: &[PluginSortingData<'_, T>], early_loading_plugins: &[String], ) -> Result<(), PluginGraphValidationError> { - log::trace!("Validating specific and early-loading plugin edges..."); + logging::trace!("Validating specific and early-loading plugin edges..."); let non_masters_set: HashSet> = non_masters.iter().map(|p| UniCase::new(p.name())).collect(); @@ -61,7 +61,7 @@ fn validate_masters( non_masters: &HashSet>, blueprint_masters: &HashSet>, ) -> Result<(), PluginGraphValidationError> { - log::trace!( + logging::trace!( "Validating specific and early-loading plugin edges for non-blueprint master files..." ); masters @@ -73,7 +73,7 @@ fn validate_non_masters( non_masters: &[PluginSortingData<'_, T>], blueprint_masters: &HashSet>, ) -> Result<(), PluginGraphValidationError> { - log::trace!("Validating specific and early-loading plugin edges for non-master files..."); + logging::trace!("Validating specific and early-loading plugin edges for non-master files..."); // Pass an empty set of non-masters so that the non-masters don't get validated against themselves. let empty_set = HashSet::new(); @@ -108,7 +108,7 @@ fn validate_plugin( } else { "non-master" }; - log::warn!( + logging::warn!( "The {} plugin \"{}\" has the blueprint master \"{}\" as one of its masters", plugin_type, plugin.name(), From 9dc14da0baf08d359ecfab6f8634ec2a0cdb6924 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 5 Mar 2025 18:04:52 +0000 Subject: [PATCH 010/206] Fix missing details when logging errors --- src/archive/parse.rs | 7 +++++-- src/game.rs | 4 ++-- src/logging.rs | 10 ++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/archive/parse.rs b/src/archive/parse.rs index 2976aaa5..2e3ca660 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -6,7 +6,10 @@ use std::{ }; use super::error::{ArchiveParsingError, ArchivePathParsingError}; -use crate::{logging, plugin::has_ascii_extension}; +use crate::{ + logging::{self, format_details}, + plugin::has_ascii_extension, +}; use super::{ba2, bsa}; @@ -25,7 +28,7 @@ pub fn assets_in_archives(archive_paths: &[PathBuf]) -> BTreeMap(error: &E) -> String { + let mut details = error.to_string(); // The display string. + if let Some(source) = error.source() { + details += ": "; + details += &format_details(&source) + } + + details +} + #[cfg(test)] mod tests { use super::*; From 821e1b46ed7bcfcf6ea0e61f4190f929a7b8cc05 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Fri, 7 Mar 2025 17:25:58 +0000 Subject: [PATCH 011/206] Fix translation bug in loading plugins from relative paths --- src/game.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/game.rs b/src/game.rs index 41492bcb..67b498d1 100644 --- a/src/game.rs +++ b/src/game.rs @@ -361,10 +361,10 @@ impl Game { plugin_paths: &[&Path], load_scope: LoadScope, ) -> Result, LoadPluginsError> { - validate_plugin_paths(self.game_type, plugin_paths)?; - let data_path = data_path(self.game_type, &self.game_path); + validate_plugin_paths(self.game_type, &data_path, plugin_paths)?; + let archive_paths = find_archives(self.game_type, self.additional_data_paths(), &data_path)?; @@ -568,6 +568,7 @@ fn new_condition_evaluator_state( fn validate_plugin_paths( game_type: GameType, + data_path: &Path, plugin_paths: &[&Path], ) -> Result<(), PluginValidationError> { // Check that all plugin filenames are unique. @@ -592,7 +593,10 @@ fn validate_plugin_paths( plugin_paths .par_iter() - .map(|path| validate_plugin_path_and_header(game_type, path)) + .map(|path| { + let resolved_path = resolve_plugin_path(game_type, data_path, path); + validate_plugin_path_and_header(game_type, &resolved_path) + }) .collect::>() } From e28c0f498b6af7f1fb5c928e0f1bb7da592f5273 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Fri, 7 Mar 2025 21:52:13 +0000 Subject: [PATCH 012/206] Fix translation bug in substituting message placeholders --- src/metadata/message.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/metadata/message.rs b/src/metadata/message.rs index ee9ebf91..6f3d4d0f 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -310,7 +310,7 @@ impl TryFrom<&MarkedYaml> for Message { if !subs.is_empty() { for mc in &mut content { for (index, sub) in subs.iter().enumerate() { - let placeholder = format!("{}", index); + let placeholder = format!("{{{}}}", index); if !mc.text.contains(&placeholder) { return Err(ParseMetadataError::new( value.span.start, From 64b14e3770cd9f23793844c876090a77d1bd2737 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 8 Mar 2025 15:18:00 +0000 Subject: [PATCH 013/206] Fix PluginMetadata::set_group arg type --- src/metadata/plugin_metadata.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index d705e7fe..9a8ffc47 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -96,8 +96,8 @@ impl PluginMetadata { } /// Set the plugin's group. - pub fn set_group(&mut self, group: &str) { - self.group = Some(group.to_string()) + pub fn set_group(&mut self, group: String) { + self.group = Some(group) } /// Unsets the plugin's group, so that it is implicitly a member of the @@ -456,7 +456,7 @@ mod tests { #[test] fn should_emit_group_if_set() { let mut plugin = PluginMetadata::new("test.esp").unwrap(); - plugin.set_group("group1"); + plugin.set_group("group1".into()); let yaml = emit(&plugin); assert_eq!( From d6503d5101169440a7145c52bc881adbd1fe9b8d Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 12 Mar 2025 07:59:29 +0000 Subject: [PATCH 014/206] Fix a few more translation bugs --- src/game.rs | 8 +++- src/metadata/metadata_document.rs | 77 ++++++++++++++++++++++++++++--- src/plugin/mod.rs | 7 ++- 3 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/game.rs b/src/game.rs index 67b498d1..bb9549e3 100644 --- a/src/game.rs +++ b/src/game.rs @@ -300,7 +300,12 @@ impl Game { /// relative to the game's plugins directory, while absolute paths are used /// as given. pub fn is_valid_plugin(&self, plugin_path: &Path) -> bool { - validate_plugin_path_and_header(self.game_type, plugin_path).is_ok() + let resolved_path = resolve_plugin_path( + self.game_type, + &data_path(self.game_type, &self.game_path), + plugin_path, + ); + validate_plugin_path_and_header(self.game_type, &resolved_path).is_ok() } /// Fully parses plugins and loads their data. @@ -527,6 +532,7 @@ impl Game { /// of active plugins is unchanged. pub fn set_load_order(&mut self, load_order: &[&str]) -> Result<(), LoadOrderError> { self.load_order.set_load_order(load_order)?; + self.load_order.save()?; Ok(()) } } diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index cc816c9f..a042646b 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -222,7 +222,7 @@ impl MetadataDocument { emitter.end_array(); } - if !self.groups.is_empty() { + if self.groups.len() > 1 { emitter.map_key("groups"); self.groups.emit_yaml(&mut emitter); } @@ -238,13 +238,20 @@ impl MetadataDocument { emitter.begin_array(); for plugin in self.plugins() { - plugin.emit_yaml(&mut emitter); + if !plugin.has_name_only() { + plugin.emit_yaml(&mut emitter); + } } emitter.end_array(); } - std::fs::write(file_path, emitter.into_string()) + let mut contents = emitter.into_string(); + if contents.is_empty() { + contents = "{}".into(); + } + + std::fs::write(file_path, contents) .map_err(|e| WriteMetadataError::new(file_path.into(), e.into()))?; Ok(()) @@ -397,8 +404,9 @@ fn merge_hashes( } fn replace_prelude(masterlist: String, prelude: String) -> String { + let line_ending = detect_line_ending(&masterlist); if let Some((start, end)) = find_prelude_bounds(&masterlist) { - let prelude = indent_prelude(prelude); + let prelude = indent_prelude(prelude, line_ending); masterlist[..start].to_string() + &prelude + &masterlist[end..] } else { @@ -406,6 +414,20 @@ fn replace_prelude(masterlist: String, prelude: String) -> String { } } +fn detect_line_ending(masterlist: &str) -> &'static str { + if let Some(pos) = masterlist.rfind("\n") { + if pos == 0 { + "\n" + } else if masterlist.as_bytes()[pos - 1] == b'\r' { + "\r\n" + } else { + "\n" + } + } else { + "\n" + } +} + fn find_prelude_bounds(masterlist: &str) -> Option<(usize, usize)> { let prelude_on_first_line = "prelude:"; let prelude_on_new_line = "\nprelude:"; @@ -428,7 +450,7 @@ fn find_prelude_bounds(masterlist: &str) -> Option<(usize, usize)> { if let Some(c) = masterlist.as_bytes().get(pos) { if *c != b' ' && *c != b'#' && *c != b'\n' { - return Some((start, next_line_break_pos)); + return Some((start, pos - 1)); } } } @@ -436,8 +458,9 @@ fn find_prelude_bounds(masterlist: &str) -> Option<(usize, usize)> { Some((start, masterlist.len())) } -fn indent_prelude(prelude: String) -> String { - let prelude = ("\n ".to_string() + &prelude.replace("\n", "\n ")).replace(" \n", "\n"); +fn indent_prelude(prelude: String, line_ending: &str) -> String { + let prelude = ("\n ".to_string() + &prelude.replace("\n", "\n ")) + .replace(&format!(" {}", line_ending), line_ending); if prelude.ends_with("\n ") { prelude[..prelude.len() - 2].to_string() @@ -545,6 +568,46 @@ plugins: metadata_list.load(&path).unwrap(); } + #[test] + fn load_with_prelude_should_merge_docs_with_crlf_line_endings() { + let tmp_dir = tempdir().unwrap(); + + let masterlist_path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&masterlist_path, "prelude:\r\n - &ref\r\n type: say\r\n content: Loaded from same file\r\nglobals:\r\n - *ref\r\n").unwrap(); + + let prelude_path = tmp_dir.path().join("prelude.yaml"); + std::fs::write( + &prelude_path, + "common:\r\n - &ref\r\n type: say\r\n content: Loaded from prelude\r\n", + ) + .unwrap(); + + let mut metadata_list = MetadataDocument::default(); + metadata_list + .load_with_prelude(&masterlist_path, &prelude_path) + .unwrap(); + } + + #[test] + fn load_with_prelude_should_merge_docs_with_lf_line_endings() { + let tmp_dir = tempdir().unwrap(); + + let masterlist_path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&masterlist_path, "prelude:\n - &ref\n type: say\n content: Loaded from same file\nglobals:\n - *ref\n").unwrap(); + + let prelude_path = tmp_dir.path().join("prelude.yaml"); + std::fs::write( + &prelude_path, + "common:\n - &ref\n type: say\n content: Loaded from prelude\n", + ) + .unwrap(); + + let mut metadata_list = MetadataDocument::default(); + metadata_list + .load_with_prelude(&masterlist_path, &prelude_path) + .unwrap(); + } + #[test] fn save_should_write_the_loaded_metadata() { let tmp_dir = tempdir().unwrap(); diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 0955a3e4..4a7edd0c 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -16,7 +16,9 @@ use crate::{ GameType, archive::{assets_in_archives, find_associated_archives}, game::GameCache, - logging, regex, + logging, + metadata::plugin_metadata::trim_dot_ghost, + regex, }; use error::{ InvalidFilenameReason, LoadPluginError, PluginDataError, PluginValidationError, @@ -409,7 +411,7 @@ pub(crate) fn plugins_metadata( fn name_string(path: &Path) -> Result { match path.file_name() { Some(f) => match f.to_str() { - Some(f) => Ok(f.to_string()), + Some(f) => Ok(trim_dot_ghost(f).to_string()), None => Err(LoadPluginError::InvalidFilename( InvalidFilenameReason::NonUnicode, )), @@ -460,6 +462,7 @@ fn extract_version(description: &str) -> Result, Box Date: Wed, 12 Mar 2025 17:26:43 +0000 Subject: [PATCH 015/206] Fix another translation bug --- src/game.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/game.rs b/src/game.rs index bb9549e3..60eb671d 100644 --- a/src/game.rs +++ b/src/game.rs @@ -154,7 +154,7 @@ impl Game { /// can be used to provide the local path instead. pub fn new(game_type: GameType, game_path: &Path) -> Result { logging::info!( - "Attempting to create a game handle for game type {} with game path {:?}", + "Attempting to create a game handle for game type \"{}\" with game path {:?}", game_type, game_path ); @@ -196,7 +196,7 @@ impl Game { game_local_path: &Path, ) -> Result { logging::info!( - "Attempting to create a game handle for game type {} with game path {:?} and game local path {:?}", + "Attempting to create a game handle for game type \"{}\" with game path {:?} and game local path {:?}", game_type, game_path, game_local_path From 39e575355b5b974f3632067b3f36dbd25bc17045 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 17 Mar 2025 21:22:29 +0000 Subject: [PATCH 016/206] Fix translation bug loading assets unnecessarily --- src/plugin/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 4a7edd0c..5778d341 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -110,7 +110,9 @@ impl Plugin { archive_paths = find_associated_archives(game_type, game_cache, plugin_path); - archive_assets = assets_in_archives(&archive_paths); + if load_scope == LoadScope::WholePlugin { + archive_assets = assets_in_archives(&archive_paths); + } Some(plugin) } else { From 61a1963d9198478530535ce9c1b1b65648654681 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 18 Mar 2025 20:40:03 +0000 Subject: [PATCH 017/206] Fix another translation bug in BA2 parsing --- src/archive/ba2.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/archive/ba2.rs b/src/archive/ba2.rs index 908ee553..b8ea5d19 100644 --- a/src/archive/ba2.rs +++ b/src/archive/ba2.rs @@ -31,8 +31,8 @@ impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { version: to_u32(&value), archive_type: <[u8; 4]>::try_from(&value[4..8]) .expect("Bytes slice is large enough to hold a 4-byte array"), - file_count: to_u32(&value[12..]), - file_paths_offset: to_u64(&value[16..]), + file_count: to_u32(&value[8..]), + file_paths_offset: to_u64(&value[12..]), }; // The header version is 1, 7 or 8 for Fallout 4 and 2 or 3 for Starfield. From 2001fb45830f849fa1805ba09332ba886336bba0 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 18 Mar 2025 21:43:37 +0000 Subject: [PATCH 018/206] Fix another translation bug --- src/metadata/metadata_document.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index a042646b..3317b98b 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -307,10 +307,14 @@ impl MetadataDocument { } pub fn set_plugin_metadata(&mut self, plugin_metadata: PluginMetadata) { - self.plugins.insert( - Filename::new(plugin_metadata.name().to_string()), - plugin_metadata, - ); + if plugin_metadata.is_regex_plugin() { + self.regex_plugins.push(plugin_metadata); + } else { + self.plugins.insert( + Filename::new(plugin_metadata.name().to_string()), + plugin_metadata, + ); + } } pub fn remove_plugin_metadata(&mut self, plugin_name: &str) { From db60e4524a0f76ea4e9455abcf74cb9e6829d59b Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 19 Mar 2025 18:05:45 +0000 Subject: [PATCH 019/206] Fix another translation bug --- src/archive/find.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/archive/find.rs b/src/archive/find.rs index 5f855feb..ec535751 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -122,6 +122,10 @@ fn find_associated_archives_with_arbitrary_suffixes( #[cfg(windows)] fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { + if lhs == rhs { + return true; + } + // See // Or windows::Win32::Foundation::ERROR_SHARING_VIOLATION in the "windows" crate. const ERROR_SHARING_VIOLATION: i32 = 32; @@ -133,8 +137,7 @@ fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { }; let result = match OpenOptions::new().read(true).share_mode(0).open(rhs) { - Ok(f) => { - dbg!(f); + Ok(_) => { false } Err(e) => { @@ -153,6 +156,10 @@ fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { #[cfg(not(windows))] fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { + if lhs == rhs { + return true; + } + use std::fs::unix::fs::MetadataExt; let lhs_metadata = match lhs.metadata() { From 4e417644347a54879ea58e884aaa0deabba3cdaa Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 20 Mar 2025 22:19:42 +0000 Subject: [PATCH 020/206] Fix a few more translation bugs --- src/archive/find.rs | 16 +++++++++++++--- src/plugin/mod.rs | 5 +++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/archive/find.rs b/src/archive/find.rs index ec535751..7896224e 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -29,9 +29,9 @@ pub fn find_associated_archives( // basename. GameType::TES4 => { if has_ascii_extension(plugin_path, "esp") { - Vec::new() - } else { find_associated_archives_with_arbitrary_suffixes(plugin_path, game_cache) + } else { + Vec::new() } }, @@ -92,6 +92,10 @@ fn find_associated_archives_with_arbitrary_suffixes( Some(s) => s.len(), None => return Vec::new(), }; + let plugin_extension = match plugin_path.extension() { + Some(e) => e, + None => return Vec::new(), + }; game_cache .archives() @@ -106,13 +110,19 @@ fn find_associated_archives_with_arbitrary_suffixes( }; // Can't just slice the archive filename to the same length as the plugin file stem directly because that might not slice on a character boundary, so truncate the byte slice and then check it's still valid UTF-8. + if archive_filename.len() < plugin_stem_len { + return false; + } + let filename = match std::str::from_utf8(&archive_filename.as_bytes()[..plugin_stem_len]) { Ok(f) => f, Err(_) => return false, }; - let archive_plugin_path = plugin_path.with_file_name(filename); + let archive_plugin_path = plugin_path + .with_file_name(filename) + .with_extension(plugin_extension); are_file_paths_equivalent(&archive_plugin_path, plugin_path) }) diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 5778d341..c77ac90c 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -85,7 +85,7 @@ impl Plugin { plugin_path: &Path, load_scope: LoadScope, ) -> Result { - let name = name_string(plugin_path)?; + let name = name_string(game_type, plugin_path)?; let (parse_options, crc) = if load_scope == LoadScope::HeaderOnly { (ParseOptions::header_only(), None) @@ -410,9 +410,10 @@ pub(crate) fn plugins_metadata( Ok(esplugin::plugins_metadata(&esplugins)?) } -fn name_string(path: &Path) -> Result { +fn name_string(game_type: GameType, path: &Path) -> Result { match path.file_name() { Some(f) => match f.to_str() { + Some(f) if game_type == GameType::OpenMW => Ok(f.to_string()), Some(f) => Ok(trim_dot_ghost(f).to_string()), None => Err(LoadPluginError::InvalidFilename( InvalidFilenameReason::NonUnicode, From b47e4389c31029b153ed509887b85755c90b942a Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 20 Mar 2025 22:21:01 +0000 Subject: [PATCH 021/206] Workaround RegexBuilder::case_insensitive() not working --- src/lib.rs | 5 ----- src/metadata/plugin_metadata.rs | 4 ++-- src/plugin/mod.rs | 21 ++++++++++++--------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 1c3e31da..f63797af 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,6 @@ mod tests; mod version; pub use database::Database; -use fancy_regex::{Regex, RegexBuilder}; pub use game::{Game, GameType}; pub use logging::{LogLevel, set_logging_callback}; pub use plugin::Plugin; @@ -20,7 +19,3 @@ pub use version::{ LIBLOOT_VERSION_MAJOR, LIBLOOT_VERSION_MINOR, LIBLOOT_VERSION_PATCH, is_compatible, libloot_revision, libloot_version, }; - -fn regex(name: &str) -> Result> { - Ok(RegexBuilder::new(name).case_insensitive(true).build()?) -} diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 9a8ffc47..f599eb23 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -1,7 +1,7 @@ use fancy_regex::Regex; use saphyr::MarkedYaml; -use crate::{logging, regex}; +use crate::logging; use super::{ error::{MetadataParsingErrorReason, ParseMetadataError, RegexError}, @@ -227,7 +227,7 @@ impl PluginName { let name = trim_dot_ghost(name).to_string(); if is_regex_name(&name) { - let regex = regex(&format!("^{}$", &name))?; + let regex = Regex::new(&format!("(?i)^{}$", &name))?; Ok(Self { string: name, regex: Some(regex), diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index c77ac90c..b50847ea 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -18,7 +18,6 @@ use crate::{ game::GameCache, logging, metadata::plugin_metadata::trim_dot_ghost, - regex, }; use error::{ InvalidFilenameReason, LoadPluginError, PluginDataError, PluginValidationError, @@ -26,26 +25,30 @@ use error::{ }; static VERSION_REGEXES: LazyLock> = LazyLock::new(|| { - /* The string below matches the range of version strings supported by - Pseudosem v1.0.1, excluding space separators, as they make version - extraction from inside sentences very tricky and have not been - seen "in the wild". */ + // The string below matches the range of version strings supported by + // Pseudosem v1.0.1, excluding space separators, as they make version + // extraction from inside sentences very tricky and have not been seen "in + // the wild". The second non-capturing group prevents version numbers + // followed by a comma from matching. let pseudosem_regex_str = r"(\d+(?:\.\d+)+(?:[-._:]?[A-Za-z0-9]+)*)(?!,)"; + /* There are a few different version formats that can appear in strings + together, and in order to extract the correct one, they must be searched + for in order of priority. */ Box::new([ /* The string below matches timestamps that use forwardslashes for date separators. However, Pseudosem v1.0.1 will only compare the first two digits as it does not recognise forwardslashes as separators. */ - regex(r"(\d{1,2}/\d{1,2}/\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})") + Regex::new(r"(?i)(\d{1,2}/\d{1,2}/\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})") .expect("Hardcoded version timestamp regex should be valid"), - regex(&(String::from(r"version:?\s") + pseudosem_regex_str)) + Regex::new(&format!(r"(?i)version:?\s{}", pseudosem_regex_str)) .expect("Hardcoded version-prefixed pseudosem version regex should be valid"), - regex(&(String::from(r"(?:^|v|\s)") + pseudosem_regex_str)) + Regex::new(&format!(r"(?i)(?:^|v|\s){}", pseudosem_regex_str)) .expect("Hardcoded pseudosem version regex should be valid"), /* The string below matches a number containing one or more digits found at the start of the search string or preceded by 'v' or 'version:. */ - regex(r"(?:^|v|version:\s*)(\d+)") + Regex::new(r"(?i)(?:^|v|version:\s*)(\d+)") .expect("Hardcoded prefixed version number regex should be valid"), ]) }); From 567998c159a66bc8c2c3104e65b105b92234d869 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 20 Mar 2025 22:51:43 +0000 Subject: [PATCH 022/206] Fix are_file_paths_equivalent on Windows Trying to get an exclusive lock on the files was too flaky and also prone to false positives. --- Cargo.lock | 1 + Cargo.toml | 3 +++ src/archive/find.rs | 48 ++++++++++++++++++++++++--------------------- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ca6a175..3c7e598d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -451,6 +451,7 @@ dependencies = [ "saphyr-parser", "tempfile", "unicase", + "windows", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a47617d1..24ab2fcf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,9 @@ saphyr-parser = "0.0.3" unicase = "2.8.1" rayon = "1.10.0" +[target.'cfg(windows)'.dependencies] +windows = { version = "0.60.0", features = ["Win32_Storage_FileSystem"] } + [dev-dependencies] rstest = "0.25.0" rstest_reuse = "0.7.0" diff --git a/src/archive/find.rs b/src/archive/find.rs index 7896224e..ae9fb1ac 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -1,7 +1,4 @@ -use std::{ - fs::OpenOptions, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; use crate::{GameType, game::GameCache, plugin::has_ascii_extension}; @@ -136,32 +133,39 @@ fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { return true; } - // See - // Or windows::Win32::Foundation::ERROR_SHARING_VIOLATION in the "windows" crate. - const ERROR_SHARING_VIOLATION: i32 = 32; - use std::os::windows::fs::OpenOptionsExt; + use std::fs::File; + use std::os::windows::io::AsRawHandle; + use windows::Win32::{ + Foundation::HANDLE, + Storage::FileSystem::{BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle}, + }; - let lhs_file = match OpenOptions::new().read(true).share_mode(0).open(lhs) { + let lhs_file = match File::open(lhs) { Ok(f) => f, Err(_) => return false, }; - let result = match OpenOptions::new().read(true).share_mode(0).open(rhs) { - Ok(_) => { - false - } - Err(e) => { - if let Some(error_code) = e.raw_os_error() { - error_code == ERROR_SHARING_VIOLATION - } else { - false - } - } + let rhs_file = match File::open(rhs) { + Ok(f) => f, + Err(_) => return false, }; - drop(lhs_file); + let mut lhs_info = BY_HANDLE_FILE_INFORMATION::default(); + let mut rhs_info = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: This is safe because the file handles and the info struct pointers are all valid until this function exits. + unsafe { + if GetFileInformationByHandle(HANDLE(lhs_file.as_raw_handle()), &mut lhs_info).is_err() { + return false; + } - result + if GetFileInformationByHandle(HANDLE(rhs_file.as_raw_handle()), &mut rhs_info).is_err() { + return false; + } + } + + lhs_info.dwVolumeSerialNumber == rhs_info.dwVolumeSerialNumber + && lhs_info.nFileIndexHigh == rhs_info.nFileIndexHigh + && lhs_info.nFileIndexLow == rhs_info.nFileIndexLow } #[cfg(not(windows))] From 5e47e77ac5848bdd8fa8fcef8ac57bf5a81c449b Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 22 Mar 2025 08:48:39 +0000 Subject: [PATCH 023/206] Fix some more translation bugs --- src/metadata/error.rs | 6 +++++ src/metadata/message.rs | 42 +++++++++++++++++++++++++++++ src/plugin/mod.rs | 58 ++++++++++++++++++++--------------------- src/sorting/plugins.rs | 32 +++++++---------------- 4 files changed, 86 insertions(+), 52 deletions(-) diff --git a/src/metadata/error.rs b/src/metadata/error.rs index 871cf381..50b33752 100644 --- a/src/metadata/error.rs +++ b/src/metadata/error.rs @@ -129,6 +129,7 @@ pub(super) enum MetadataParsingErrorReason { UnexpectedType(ExpectedType, YamlObjectType), UnexpectedValueType(&'static str, ExpectedType, YamlObjectType), MissingPlaceholder(String, usize), + MissingSubstitution(String), NonU32Number(i64), DuplicateEntry(String, YamlObjectType), Other(Box), @@ -164,6 +165,11 @@ impl std::fmt::Display for MetadataParsingErrorReason { "failed to substitute \"{}\" into message, no placeholder {{{}}} was found", sub, placeholder_index ), + Self::MissingSubstitution(placeholder) => write!( + f, + "failed to substitute a value into message, no substitution was given for the placeholder \"{}\"", + placeholder + ), Self::NonU32Number(i) => { write!(f, "{} is not valid as a 32-bit unsigned integer", i) } diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 6f3d4d0f..dedda515 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -1,5 +1,10 @@ +use std::{borrow::Cow, sync::LazyLock}; + +use fancy_regex::{Captures, Regex}; use saphyr::{MarkedYaml, YamlData}; +use crate::logging; + use super::{ error::{ ExpectedType, MetadataParsingErrorReason, MultilingualMessageContentsError, @@ -308,9 +313,39 @@ impl TryFrom<&MarkedYaml> for Message { let subs = get_strings_vec_value(hash, "subs", YamlObjectType::Message)?; if !subs.is_empty() { + static FMT_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"{(\d+)}").expect("hardcoded fmt placeholder regex should be valid") + }); + for mc in &mut content { + if mc.text.contains("%1%") { + static BOOST_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"%(\d+)%") + .expect("hardcoded Boost placeholder regex should be valid") + }); + + let result = BOOST_REGEX.replace_all(&mc.text, |captures: &Captures| { + match captures[1].parse::() { + Ok(i) if i > 0 => format!("{{{}}}", i - 1), + Ok(_) => { + logging::warn!("Found zero-indexed placeholder using Boost syntax in string \"{}\"", mc.text); + captures[0].to_string() + }, + Err(e) => { + logging::error!("Unexpected failure to parse Boost placeholder index \"{}\": {}", &captures[1], e); + captures[0].to_string() + } + } + }); + + if let Cow::Owned(text) = result { + mc.text = text; + } + } + for (index, sub) in subs.iter().enumerate() { let placeholder = format!("{{{}}}", index); + if !mc.text.contains(&placeholder) { return Err(ParseMetadataError::new( value.span.start, @@ -320,6 +355,13 @@ impl TryFrom<&MarkedYaml> for Message { mc.text = mc.text.replace(&placeholder, sub); } + + if let Ok(Some(m)) = FMT_REGEX.find(&mc.text) { + return Err(ParseMetadataError::new( + value.span.start, + MetadataParsingErrorReason::MissingSubstitution(m.as_str().to_string()), + )); + } } } diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index b50847ea..4ecfbc4d 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -24,35 +24,6 @@ use error::{ PluginValidationErrorReason, }; -static VERSION_REGEXES: LazyLock> = LazyLock::new(|| { - // The string below matches the range of version strings supported by - // Pseudosem v1.0.1, excluding space separators, as they make version - // extraction from inside sentences very tricky and have not been seen "in - // the wild". The second non-capturing group prevents version numbers - // followed by a comma from matching. - let pseudosem_regex_str = r"(\d+(?:\.\d+)+(?:[-._:]?[A-Za-z0-9]+)*)(?!,)"; - - /* There are a few different version formats that can appear in strings - together, and in order to extract the correct one, they must be searched - for in order of priority. */ - Box::new([ - /* The string below matches timestamps that use forwardslashes for date - separators. However, Pseudosem v1.0.1 will only compare the first - two digits as it does not recognise forwardslashes as separators. */ - Regex::new(r"(?i)(\d{1,2}/\d{1,2}/\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})") - .expect("Hardcoded version timestamp regex should be valid"), - Regex::new(&format!(r"(?i)version:?\s{}", pseudosem_regex_str)) - .expect("Hardcoded version-prefixed pseudosem version regex should be valid"), - Regex::new(&format!(r"(?i)(?:^|v|\s){}", pseudosem_regex_str)) - .expect("Hardcoded pseudosem version regex should be valid"), - /* The string below matches a number containing one or more - digits found at the start of the search string or preceded by - 'v' or 'version:. */ - Regex::new(r"(?i)(?:^|v|version:\s*)(\d+)") - .expect("Hardcoded prefixed version number regex should be valid"), - ]) -}); - #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub(crate) enum LoadScope { HeaderOnly, @@ -462,6 +433,35 @@ fn extract_bash_tags(description: &str) -> Vec { } fn extract_version(description: &str) -> Result, Box> { + static VERSION_REGEXES: LazyLock> = LazyLock::new(|| { + // The string below matches the range of version strings supported by + // Pseudosem v1.0.1, excluding space separators, as they make version + // extraction from inside sentences very tricky and have not been seen "in + // the wild". The second non-capturing group prevents version numbers + // followed by a comma from matching. + let pseudosem_regex_str = r"(\d+(?:\.\d+)+(?:[-._:]?[A-Za-z0-9]+)*)(?!,)"; + + /* There are a few different version formats that can appear in strings + together, and in order to extract the correct one, they must be searched + for in order of priority. */ + Box::new([ + /* The string below matches timestamps that use forwardslashes for date + separators. However, Pseudosem v1.0.1 will only compare the first + two digits as it does not recognise forwardslashes as separators. */ + Regex::new(r"(?i)(\d{1,2}/\d{1,2}/\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})") + .expect("Hardcoded version timestamp regex should be valid"), + Regex::new(&format!(r"(?i)version:?\s{}", pseudosem_regex_str)) + .expect("Hardcoded version-prefixed pseudosem version regex should be valid"), + Regex::new(&format!(r"(?i)(?:^|v|\s){}", pseudosem_regex_str)) + .expect("Hardcoded pseudosem version regex should be valid"), + /* The string below matches a number containing one or more + digits found at the start of the search string or preceded by + 'v' or 'version:. */ + Regex::new(r"(?i)(?:^|v|version:\s*)(\d+)") + .expect("Hardcoded prefixed version number regex should be valid"), + ]) + }); + for regex in &*VERSION_REGEXES { let version = regex .captures(description)? diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 0caefd49..3f4a8439 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -260,43 +260,29 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { return; } - let mut early_loading_plugin_indices: HashMap<&str, NodeIndex> = HashMap::new(); + let mut early_loader_indices = Vec::new(); let mut other_plugin_indices = Vec::new(); for node_index in self.node_indices() { let plugin = &self[node_index]; - if let Some(p) = early_loading_plugins + if let Some(i) = early_loading_plugins .iter() - .find(|e| unicase::eq(e.as_str(), plugin.name())) + .position(|e| unicase::eq(e.as_str(), plugin.name())) { - early_loading_plugin_indices.insert(p.as_str(), node_index); + early_loader_indices.push((i, node_index)); } else { other_plugin_indices.push(node_index); } } - if early_loading_plugin_indices.is_empty() { - return; - } + early_loader_indices.sort_by_key(|e| e.0); - let mut last_early_loading_plugin_index = None; - for window in early_loading_plugins.windows(2) { - if let [from, to] = window { - let from_index = early_loading_plugin_indices.get(from.as_str()); - let to_index = early_loading_plugin_indices.get(to.as_str()); - - if to_index.is_some() { - last_early_loading_plugin_index = to_index; - } else if from_index.is_some() { - last_early_loading_plugin_index = from_index; - } - - if let (Some(from_index), Some(to_index)) = (from_index, to_index) { - self.add_edge(*from_index, *to_index, EdgeType::Hardcoded); - } + for window in early_loader_indices.windows(2) { + if let [(_, from_index), (_, to_index)] = window { + self.add_edge(*from_index, *to_index, EdgeType::Hardcoded); } } - if let Some(from_index) = last_early_loading_plugin_index { + if let Some((_, from_index)) = early_loader_indices.last() { for to_index in other_plugin_indices { self.add_edge(*from_index, to_index, EdgeType::Hardcoded); } From e19f4bc45d7bc180cf403ae23606dd59da2ca73c Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 22 Mar 2025 11:46:23 +0000 Subject: [PATCH 024/206] Fix another translation bug Though this is due to an incorrect assumption of common behaviour in dependencies rather than a mistake I made. --- src/sorting/dfs.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/sorting/dfs.rs b/src/sorting/dfs.rs index 6f2a8aa3..3476a74b 100644 --- a/src/sorting/dfs.rs +++ b/src/sorting/dfs.rs @@ -109,7 +109,7 @@ pub fn depth_first_search<'a, N>( start_node_index: NodeIndex, visitor: &mut impl DfsVisitor<'a>, ) { - let mut stack = vec![(start_node_index, graph.edges(start_node_index))]; + let mut stack = vec![(start_node_index, edges(graph, start_node_index))]; colour_map.insert(start_node_index, Colour::Grey); visitor.discover_node(start_node_index); @@ -125,7 +125,7 @@ pub fn depth_first_search<'a, N>( colour_map.insert(target, Colour::Grey); visitor.discover_node(target); - stack.push((target, graph.edges(target))); + stack.push((target, edges(graph, target))); } Colour::Grey => visitor.visit_back_edge(edge), Colour::Black => visitor.visit_forward_or_cross_edge(edge), @@ -139,6 +139,20 @@ pub fn depth_first_search<'a, N>( } } +fn edges( + graph: &Graph, + node_index: NodeIndex, +) -> impl Iterator> { + // Petgraph produces edges in the reverse of the order that neighbouring + // nodes were added to the graph, but for backwards compatibility we want + // the opposite order. + // Unfortunately Petgraph's Edges iterator doesn't impl DoubleEndedIterator + // (though it probably could), so this needs to buffer the edges. + let mut edges: Vec<_> = graph.edges(node_index).collect(); + edges.reverse(); + edges.into_iter() +} + #[derive(Clone, Debug)] struct CycleDetector<'a, N, F: FnMut(&N) -> String> { graph: &'a Graph, From 597ae75c3c00ac5a23ea8167917c65cb4bcd2b9e Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 22 Mar 2025 12:34:55 +0000 Subject: [PATCH 025/206] Fix another translation bug --- src/sorting/plugins.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 3f4a8439..34459f6f 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -691,11 +691,14 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { .map_err(|e| SortingError::CycleInvolving(self[e.node_id()].name().to_string())) } - fn is_hamiltonian_path(&mut self, path: &[NodeIndex]) -> Option<(NodeIndex, NodeIndex)> { + /// Returns the first pair of consecutive nodes that don't have an edge joining them. + fn check_path_is_hamiltonian(&mut self, path: &[NodeIndex]) -> Option<(NodeIndex, NodeIndex)> { + use std::ops::Not; + logging::trace!("Checking uniqueness of path through plugin graph..."); path.windows(2).find_map(|slice| match slice { - [a, b] => self.inner.contains_edge(*a, *b).then_some((*a, *b)), + [a, b] => self.inner.contains_edge(*a, *b).not().then_some((*a, *b)), _ => None, }) } @@ -848,7 +851,7 @@ fn sort_plugins_partition( let sorted_nodes = graph.topological_sort()?; - if let Some((first, second)) = graph.is_hamiltonian_path(&sorted_nodes) { + if let Some((first, second)) = graph.check_path_is_hamiltonian(&sorted_nodes) { logging::error!( "The path is not unique. No edge exists between {} and {}", graph[first].name(), From 7f62d494e9cf6f3225eb809c06b8b991273b7e74 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 19 Mar 2025 18:07:14 +0000 Subject: [PATCH 026/206] Port unit tests from the C++ libloot implementation This gives function/line/region coverage of 88%/91%/84%. Most of the translation bugs fixed in earlier commits were found while writing these tests: the tests were kept separate to not drown out the fixes in the commit diffs. --- README.md | 4 +- src/archive/find.rs | 295 ++- src/archive/mod.rs | 61 + src/archive/parse.rs | 192 ++ src/database/conditions.rs | 75 + src/database/mod.rs | 917 ++++++++- src/game.rs | 1337 +++++++++++++- src/metadata/file.rs | 125 ++ src/metadata/group.rs | 42 + src/metadata/location.rs | 51 +- src/metadata/message.rs | 294 ++- src/metadata/metadata_document.rs | 589 +++++- src/metadata/mod.rs | 8 + src/metadata/plugin_cleaning_data.rs | 102 + src/metadata/plugin_metadata.rs | 485 +++++ src/metadata/tag.rs | 71 + src/plugin/mod.rs | 918 ++++++++- src/sorting/groups.rs | 210 +++ src/sorting/mod.rs | 75 + src/sorting/plugins.rs | 2568 +++++++++++++++++++++++++- src/tests.rs | 109 +- 21 files changed, 8363 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index 9fbfb6c2..f60c3622 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Currently complete: - [x] Archive-related functionality - [x] Metadata-related functionality (excluding writing YAML) - [x] Sorting functionality -- [ ] Unit tests +- [x] Unit tests - [ ] Integration tests - [ ] C++ FFI - [ ] Python FFI @@ -35,7 +35,7 @@ $env:LIBLOOT_REVISION = git rev-parse --short HEAD cargo build --release ``` -There aren't many tests, but those that exist can be run by first extracting the [testing-plugins](https://github.com/Ortham/testing-plugins) archive to this readme's directory (so that there's a `testing-plugins` directory there), then running: +The tests include a complete port of libloot's tests, and can be run by first extracting the [testing-plugins](https://github.com/Ortham/testing-plugins) archive to this readme's directory (so that there's a `testing-plugins` directory there), then running: ``` cargo test diff --git a/src/archive/find.rs b/src/archive/find.rs index ae9fb1ac..c54d7044 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -195,12 +195,295 @@ mod tests { use super::*; - #[test] - fn are_file_paths_equivalent_should_be_true_if_given_the_same_path_twice() { - let temp_dir = tempdir().unwrap(); - let file_path = temp_dir.path().join("test"); - std::fs::write(&file_path, "").unwrap(); + mod find_associated_archives { + use std::path::absolute; - assert!(are_file_paths_equivalent(&file_path, &file_path)) + use rstest_reuse::apply; + use tempfile::TempDir; + + use super::*; + + use crate::tests::{ + BLANK_DIFFERENT_ESM, BLANK_DIFFERENT_ESP, BLANK_ESM, BLANK_ESP, + BLANK_MASTER_DEPENDENT_ESM, all_game_types, copy_file, source_plugins_path, + }; + + const NON_ASCII_ESP: &str = "non\u{00C1}scii.esp"; + + struct Fixture { + _temp_dir: TempDir, + cache: GameCache, + data_path: PathBuf, + } + + impl Fixture { + pub fn new(game_type: GameType) -> Self { + let tmp_dir = tempdir().unwrap(); + + let mut cache = GameCache::default(); + + let data_path = tmp_dir.path().to_path_buf(); + + match game_type { + GameType::TES3 | GameType::OpenMW => {} + GameType::FO4 | GameType::FO4VR | GameType::Starfield => { + let source = absolute("./testing-plugins/Fallout 4/Data").unwrap(); + copy_file(&source, &data_path, "Blank - Main.ba2"); + copy_file(&source, &data_path, "Blank - Textures.ba2"); + std::fs::copy( + source.join("Blank - Main.ba2"), + data_path.join("non\u{00C1}scii.ba2"), + ) + .unwrap(); + std::fs::copy( + source.join("Blank - Main.ba2"), + data_path.join("Blank - Different - Suffix.ba2"), + ) + .unwrap(); + + cache.set_archive_paths(vec![ + data_path.join("Blank - Main.ba2"), + data_path.join("Blank - Textures.ba2"), + data_path.join("non\u{00C1}scii.ba2"), + data_path.join("Blank - Different - Main.ba2"), + ]); + } + _ => { + let source = source_plugins_path(game_type); + copy_file(&source, &data_path, "Blank.bsa"); + std::fs::copy( + source.join("Blank.bsa"), + data_path.join("non\u{00C1}scii.bsa"), + ) + .unwrap(); + std::fs::copy( + source.join("Blank.bsa"), + data_path.join("Blank - Different - Main.bsa"), + ) + .unwrap(); + + cache.set_archive_paths(vec![ + data_path.join("Blank.bsa"), + data_path.join("non\u{00C1}scii.bsa"), + data_path.join("Blank - Different - Suffix.bsa"), + ]); + } + } + + Self { + _temp_dir: tmp_dir, + data_path, + cache, + } + } + } + + #[apply(all_game_types)] + fn should_return_empty_vec_if_no_matching_archives_are_found(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_MASTER_DEPENDENT_ESM), + ); + + assert!(archives.is_empty()); + } + + #[apply(all_game_types)] + fn should_find_an_archive_that_exactly_matches_an_esm_file_basename_except_for_morrowind_and_oblivion( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_ESM), + ); + + if matches!( + game_type, + GameType::TES3 | GameType::OpenMW | GameType::TES4 + ) { + assert!(archives.is_empty()); + } else { + assert!(!archives.is_empty()); + } + } + + #[apply(all_game_types)] + fn should_find_an_archive_that_exactly_matches_a_non_ascii_esp_file_basename_except_for_morrowind_and_starfield( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(NON_ASCII_ESP), + ); + + if matches!( + game_type, + GameType::TES3 | GameType::OpenMW | GameType::Starfield + ) { + assert!(archives.is_empty()); + } else { + assert!(!archives.is_empty()); + } + } + + #[apply(all_game_types)] + fn should_find_an_archive_that_starts_with_an_esp_file_basename_except_for_morrowind_and( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_ESP), + ); + + if matches!(game_type, GameType::TES3 | GameType::OpenMW) { + assert!(archives.is_empty()); + } else { + assert!(!archives.is_empty()); + } + } + + #[apply(all_game_types)] + fn should_find_an_archive_that_starts_with_an_esm_file_basename_only_for_fallout( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_DIFFERENT_ESM), + ); + + if matches!( + game_type, + GameType::FO3 | GameType::FONV | GameType::FO4 | GameType::FO4VR + ) { + assert!(!archives.is_empty()); + } else { + assert!(archives.is_empty()); + } + } + + #[apply(all_game_types)] + fn should_find_an_archive_that_starts_with_an_esp_file_basename_only_for_oblivion_and_fallout( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_DIFFERENT_ESP), + ); + + if matches!( + game_type, + GameType::TES4 | GameType::FO3 | GameType::FONV | GameType::FO4 | GameType::FO4VR + ) { + assert!(!archives.is_empty()); + } else { + assert!(archives.is_empty()); + } + } + } + + mod are_file_paths_equivalent { + use super::*; + + #[test] + fn should_be_true_if_given_equal_paths_that_exist() { + let file_path = Path::new("README.md"); + + assert!(file_path.exists()); + assert!(are_file_paths_equivalent(file_path, file_path)); + } + + #[test] + fn should_be_true_if_given_equal_paths_that_do_not_exist() { + let file_path = Path::new("missing"); + + assert!(!file_path.exists()); + assert!(are_file_paths_equivalent(file_path, file_path)); + } + + #[test] + fn should_be_false_if_given_case_insensitively_equal_paths_that_do_not_exist() { + let file_path1 = Path::new("missing"); + let file_path2 = Path::new("MISSING"); + + assert!(!file_path1.exists()); + assert!(!file_path2.exists()); + assert!(!are_file_paths_equivalent(file_path1, file_path2)); + } + + #[test] + fn should_be_false_if_given_case_insensitively_unequal_paths_that_exist() { + let file_path1 = Path::new("README.md"); + let file_path2 = Path::new("LICENSE"); + + assert!(file_path1.exists()); + assert!(file_path2.exists()); + assert!(!are_file_paths_equivalent(file_path1, file_path2)); + } + + #[test] + #[cfg(windows)] + fn should_be_true_if_given_case_insensitively_equal_paths_that_exist() { + let file_path1 = Path::new("README.md"); + let file_path2 = Path::new("readme.md"); + + assert!(file_path1.exists()); + assert!(file_path2.exists()); + assert!(are_file_paths_equivalent(file_path1, file_path2)); + } + + #[test] + #[cfg(windows)] + fn should_be_true_if_equal_paths_have_characters_that_are_unrepresentable_in_the_system_multi_byte_code_page() + { + let file_path = + Path::new("\u{2551}\u{00BB}\u{00C1}\u{2510}\u{2557}\u{00FE}\u{00C3}\u{00CE}.txt"); + + assert!(are_file_paths_equivalent(file_path, file_path)); + } + + #[test] + #[cfg(windows)] + fn should_be_false_if_case_insensitively_equal_paths_have_characters_that_are_unrepresentable_in_the_system_multi_byte_code_page_and_do_not_exist() + { + let file_path1 = + Path::new("\u{2551}\u{00BB}\u{00C1}\u{2510}\u{2557}\u{00FE}\u{00E3}\u{00CE}.txt"); + let file_path2 = + Path::new("\u{2551}\u{00BB}\u{00C1}\u{2510}\u{2557}\u{00FE}\u{00C3}\u{00CE}.txt"); + + assert!(!are_file_paths_equivalent(file_path1, file_path2)); + } + + #[test] + #[cfg(not(windows))] + fn should_be_false_if_given_case_insensitively_equal_paths_that_exist() { + let tmp_dir = tempdir(); + let file_path1 = tmp_dir.path().join("test"); + let file_path2 = tmp_dir.path().join("TEST"); + + std::fs::File::create(&file_path1).unwrap(); + std::fs::File::create(&file_path2).unwrap(); + + assert!(file_path1.exists()); + assert!(file_path2.exists()); + assert!(!are_file_paths_equivalent(file_path1, file_path2)); + } } } diff --git a/src/archive/mod.rs b/src/archive/mod.rs index 16582db8..950fbeb9 100644 --- a/src/archive/mod.rs +++ b/src/archive/mod.rs @@ -4,5 +4,66 @@ mod error; mod find; mod parse; +use std::collections::{BTreeMap, BTreeSet}; + pub use find::find_associated_archives; pub use parse::assets_in_archives; + +pub fn do_assets_overlap( + assets: &BTreeMap>, + other_assets: &BTreeMap>, +) -> bool { + let mut assets_iter = assets.iter(); + let mut other_assets_iter = other_assets.iter(); + + let mut assets = assets_iter.next(); + let mut other_assets = other_assets_iter.next(); + while let (Some((folder, files)), Some((other_folder, other_files))) = (assets, other_assets) { + if folder < other_folder { + assets = assets_iter.next(); + } else if folder > other_folder { + other_assets = other_assets_iter.next(); + } else if files.intersection(other_files).next().is_some() { + return true; + } else { + // The folder hashes are equal but they don't contain any of the same + // file hashes, move on to the next folder. It doesn't matter which + // iterator gets incremented. + assets = assets_iter.next(); + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + mod do_assets_overlap { + use std::path::PathBuf; + + use super::*; + + #[test] + fn should_return_true_if_the_same_file_exists_in_the_same_folder() { + let path = PathBuf::from("./testing-plugins/Oblivion/Data/Blank.bsa"); + let assets = assets_in_archives(&[path]); + + assert!(do_assets_overlap(&assets, &assets)); + } + + #[test] + fn should_return_false_if_the_same_file_exists_in_different_folders() { + let path = PathBuf::from("./testing-plugins/Oblivion/Data/Blank.bsa"); + let assets1 = assets_in_archives(&[path]); + + let path = PathBuf::from("./testing-plugins/Skyrim/Data/Blank.bsa"); + let assets2 = assets_in_archives(&[path]); + + assert_eq!(assets1.get(&0), assets2.get(&0x2E01002E)); + + assert!(!do_assets_overlap(&assets1, &assets2)) + } + } +} diff --git a/src/archive/parse.rs b/src/archive/parse.rs index 2e3ca660..6487b2dc 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -108,3 +108,195 @@ pub(super) fn to_u64(bytes: &[u8]) -> u64 { pub(super) fn to_usize(size: u32) -> usize { usize::try_from(size).expect("usize can hold a u32") } + +#[cfg(test)] +mod tests { + use super::*; + + mod get_assets_in_archive { + use std::{ + hash::{DefaultHasher, Hash, Hasher}, + io::SeekFrom, + }; + + use rstest::rstest; + use tempfile::tempdir; + + use super::*; + + fn hash(value: T) -> u64 { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() + } + + #[test] + fn should_error_if_file_cannot_be_opened() { + let path = Path::new("./invalid.bsa"); + assert!(get_assets_in_archive(path).is_err()); + } + + #[test] + fn should_support_v103_bsas() { + let path = Path::new("./testing-plugins/Oblivion/Data/Blank.bsa"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + let expected_key = 0; + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + assert_eq!(expected_key, *assets.first_key_value().unwrap().0); + assert_eq!(1, assets.get(&expected_key).unwrap().len()); + assert_eq!( + 0x4670B6836C077365, + *assets.get(&expected_key).unwrap().first().unwrap() + ); + } + + #[test] + fn should_support_v104_bsas() { + let path = Path::new("./testing-plugins/Skyrim/Data/Blank.bsa"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + let expected_key = 0x2E01002E; + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + assert_eq!(expected_key, *assets.first_key_value().unwrap().0); + assert_eq!(1, assets.get(&expected_key).unwrap().len()); + assert_eq!( + 0x4670B6836C077365, + *assets.get(&expected_key).unwrap().first().unwrap() + ); + } + + #[test] + fn should_support_v105_bsas() { + let path = Path::new("./testing-plugins/SkyrimSE/Data/Blank.bsa"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + let expected_key = 0xB68102C964176E73; + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + assert_eq!(expected_key, *assets.first_key_value().unwrap().0); + assert_eq!(1, assets.get(&expected_key).unwrap().len()); + assert_eq!( + 0x4670B6836C077365, + *assets.get(&expected_key).unwrap().first().unwrap() + ); + } + + #[test] + fn should_support_general_ba2s() { + let path = Path::new("./testing-plugins/Fallout 4/Data/Blank - Main.ba2"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + let expected_key = hash("dev\\git\\testing-plugins".as_bytes()); + let expected_file_hash = hash("license.txt".as_bytes()); + + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + + let (key, value) = assets.first_key_value().unwrap(); + assert_eq!(expected_key, *key); + assert_eq!(1, value.len()); + assert_eq!(expected_file_hash, *value.first().unwrap()); + } + + #[test] + fn should_support_texture_ba2s() { + let path = Path::new("./testing-plugins/Fallout 4/Data/Blank - Textures.ba2"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + let expected_key = hash("dev\\git\\testing-plugins".as_bytes()); + let expected_file_hash = hash("blank.dds".as_bytes()); + + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + + let (key, value) = assets.first_key_value().unwrap(); + assert_eq!(expected_key, *key); + assert_eq!(1, value.len()); + assert_eq!(expected_file_hash, *value.first().unwrap()); + } + + #[rstest] + fn should_support_ba2_versions(#[values(1, 2, 3, 7, 8)] version: u32) { + use std::io::{Seek, Write}; + + let tmp_dir = tempdir().unwrap(); + let path = tmp_dir.path().join("test.ba2"); + + std::fs::copy("./testing-plugins/Fallout 4/Data/Blank - Main.ba2", &path).unwrap(); + + { + let mut file = File::options().write(true).open(&path).unwrap(); + file.seek(SeekFrom::Start(4)).unwrap(); + file.write_all(&version.to_le_bytes()).unwrap(); + } + + let assets = get_assets_in_archive(&path).unwrap(); + assert!(!assets.is_empty()); + } + } + + mod assets_in_archives { + use super::*; + + #[test] + fn should_skip_files_that_cannot_be_read() { + let paths = [ + PathBuf::from("invalid.bsa"), + PathBuf::from("./testing-plugins/Skyrim/Data/Blank.bsa"), + ]; + + let assets = assets_in_archives(&paths); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + + let (key, value) = assets.first_key_value().unwrap(); + assert_eq!(0x2E01002E, *key); + assert_eq!(1, value.len()); + assert_eq!(0x4670B6836C077365, *value.first().unwrap()); + } + + #[test] + fn should_combine_assets_from_each_loaded_archive() { + let paths = [ + PathBuf::from("./testing-plugins/Oblivion/Data/Blank.bsa"), + PathBuf::from("./testing-plugins/Skyrim/Data/Blank.bsa"), + PathBuf::from("./testing-plugins/SkyrimSE/Data/Blank.bsa"), + ]; + + let assets = assets_in_archives(&paths); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + assert_eq!(3, assets.len()); + assert_eq!(3, files_count); + + let value = assets.get(&0).unwrap(); + assert_eq!(1, value.len()); + assert_eq!(0x4670B6836C077365, *value.first().unwrap()); + + let value = assets.get(&0x2E01002E).unwrap(); + assert_eq!(1, value.len()); + assert_eq!(0x4670B6836C077365, *value.first().unwrap()); + + let value = assets.get(&0xB68102C964176E73).unwrap(); + assert_eq!(1, value.len()); + assert_eq!(0x4670B6836C077365, *value.first().unwrap()); + } + } +} diff --git a/src/database/conditions.rs b/src/database/conditions.rs index 22640623..9e777998 100644 --- a/src/database/conditions.rs +++ b/src/database/conditions.rs @@ -106,3 +106,78 @@ fn filter_cleaning_data_on_conditions( }) .collect::, _>>() } + +#[cfg(test)] +mod tests { + use super::*; + + mod evaluate_all_conditions { + use crate::{ + metadata::{Message, MessageType, Tag, TagSuggestion}, + tests::{BLANK_DIFFERENT_ESM, BLANK_ESM, BLANK_ESP, source_plugins_path}, + }; + + use super::*; + + #[test] + fn should_evaluate_all_conditions_on_metadata_in_plugin_metadata_object() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_group("group1".into()); + + let condition = "file(\"missing.esp\")".to_string(); + let file1 = File::new(BLANK_ESP.into()); + let file2 = File::new(BLANK_DIFFERENT_ESM.into()).with_condition(condition.clone()); + let files = vec![file1.clone(), file2]; + plugin.set_load_after_files(files.clone()); + plugin.set_requirements(files.clone()); + plugin.set_incompatibilities(files.clone()); + + let message1 = Message::new(MessageType::Say, "content1".into()); + let message2 = + Message::new(MessageType::Say, "content2".into()).with_condition(condition.clone()); + plugin.set_messages(vec![message1.clone(), message2]); + + let tag1 = Tag::new("Delev".into(), TagSuggestion::Addition); + let tag2 = + Tag::new("Relev".into(), TagSuggestion::Addition).with_condition(condition.clone()); + plugin.set_tags(vec![tag1.clone(), tag2]); + + let info1 = PluginCleaningData::new(0x374E2A6F, "utility1".into()); + let info2 = PluginCleaningData::new(0xDEADBEEF, "utility2".into()); + plugin.set_dirty_info(vec![info1.clone(), info2.clone()]); + plugin.set_clean_info(vec![info1.clone(), info2.clone()]); + + let state = loot_condition_interpreter::State::new( + loot_condition_interpreter::GameType::Oblivion, + source_plugins_path(crate::GameType::TES4), + ); + let result = evaluate_all_conditions(plugin, &state).unwrap().unwrap(); + + let expected_files = &[file1]; + let expected_info = &[info1]; + assert_eq!("group1", result.group().unwrap()); + assert_eq!(expected_files, result.load_after_files()); + assert_eq!(expected_files, result.requirements()); + assert_eq!(expected_files, result.incompatibilities()); + assert_eq!(&[message1], result.messages()); + assert_eq!(&[tag1], result.tags()); + assert_eq!(expected_info, result.dirty_info()); + assert_eq!(expected_info, result.clean_info()); + } + + #[test] + fn should_return_none_if_evaluated_plugin_metadata_has_name_only() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + + let file = File::new(BLANK_DIFFERENT_ESM.into()) + .with_condition("file(\"missing.esp\")".into()); + plugin.set_load_after_files(vec![file]); + + let state = loot_condition_interpreter::State::new( + loot_condition_interpreter::GameType::Oblivion, + source_plugins_path(crate::GameType::TES4), + ); + assert!(evaluate_all_conditions(plugin, &state).unwrap().is_none()); + } + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs index ebf7aff9..b1fdac90 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -106,7 +106,7 @@ impl Database { let mut doc = MetadataDocument::default(); - for plugin in self.masterlist.plugins() { + for plugin in self.masterlist.plugins_iter() { let mut minimal_plugin = PluginMetadata::new(plugin.name()) .expect("Regex plugin name from existing PluginMetadata object is valid"); minimal_plugin.set_tags(plugin.tags().to_vec()); @@ -333,3 +333,918 @@ fn merge_groups(lhs: &[Group], rhs: &[Group]) -> Vec { groups } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::{ + EdgeType, GameType, + metadata::{File, MessageType}, + tests::{BLANK_DIFFERENT_ESM, BLANK_ESM, BLANK_MASTER_DEPENDENT_ESM}, + }; + + use super::*; + + struct Fixture { + inner: crate::tests::Fixture, + prelude_path: PathBuf, + metadata_path: PathBuf, + } + + impl Fixture { + fn new(game_type: GameType) -> Self { + let inner = crate::tests::Fixture::new(game_type); + + let prelude = "- &preludeBashTag Actors.ACBS"; + let prelude_path = inner.local_path.join("prelude.yaml"); + std::fs::write(&prelude_path, prelude).unwrap(); + + let metadata = " +prelude: + - &preludeBashTag C.Climate +bash_tags: + - *preludeBashTag +globals: + - type: say + content: 'A general message' + condition: 'file(\"missing.esp\")' +groups: + - name: group1 + - name: group2 + after: + - group1 +plugins: + - name: Blank.esm + after: + - Oblivion.esm + msg: + - type: say + content: 'A note message' + condition: 'file(\"missing.esp\")' + tag: + - Actors.ACBS + - Actors.AIData + - '-C.Water' + - name: Blank - Different.esm + after: + - Blank - Master Dependent.esm + msg: + - type: warn + content: 'A warning message' + dirty: + - crc: 0x7d22f9df + util: TES4Edit + udr: 4 + - name: Blank - Different.esp + after: + - Blank - Plugin Dependent.esp + msg: + - type: error + content: 'An error message' + - name: Blank.esp + after: + - Blank - Different Master Dependent.esp + - name: Blank - Different Master Dependent.esp + after: + - Blank - Master Dependent.esp + msg: + - type: say + content: 'A note message' + - type: warn + content: 'A warning message' + - type: error + content: 'An error message'"; + let metadata_path = inner.local_path.join("metadata.yaml"); + std::fs::write(&metadata_path, metadata).unwrap(); + + Self { + inner, + prelude_path, + metadata_path, + } + } + + fn database(&self) -> Database { + Database::new(loot_condition_interpreter::State::new( + self.inner.game_type.into(), + self.inner.data_path(), + )) + } + } + + #[test] + fn load_masterlist_should_succeed_if_given_a_valid_path() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + assert_eq!(&["C.Climate"], database.known_bash_tags().as_slice()); + } + + #[test] + fn load_masterlist_with_prelude_should_succeed_if_given_valid_paths() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database + .load_masterlist_with_prelude(&fixture.metadata_path, &fixture.prelude_path) + .unwrap(); + + assert_eq!(&["Actors.ACBS"], database.known_bash_tags().as_slice()); + } + + #[test] + fn load_userlist_should_succeed_if_given_a_valid_path() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_userlist(&fixture.metadata_path).unwrap(); + + assert_eq!(&["C.Climate"], database.known_bash_tags().as_slice()); + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()).with_after_groups(vec!["group1".into()]) + ], + database.user_groups() + ); + } + + mod write_user_metadata { + use super::*; + + #[test] + fn should_write_only_user_metadata() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + database.set_user_groups(vec![Group::new("group3".into())]); + + let output_path = fixture.inner.local_path.join("userlist.yaml"); + database + .write_user_metadata(&output_path, false) + .unwrap(); + + let content = std::fs::read_to_string(output_path).unwrap(); + + assert_eq!("groups:\n - name: 'default'\n - name: 'group3'", content); + } + + #[test] + fn should_succeed_if_the_path_does_not_exist() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + assert!( + database + .write_user_metadata(&output_path, false) + .is_ok() + ); + } + + #[test] + fn should_succeed_if_the_path_does_not_exist_and_truncation_is_allowed() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + assert!( + database + .write_user_metadata(&output_path, true) + .is_ok() + ); + } + + #[test] + fn should_succeed_if_the_path_exists_and_truncation_is_allowed() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + assert!( + database + .write_user_metadata(&output_path, true) + .is_ok() + ); + } + + #[test] + fn should_error_if_the_parent_path_does_not_exist() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("missing/userlist.yaml"); + + assert!( + database + .write_user_metadata(&output_path, false) + .is_err() + ); + } + + #[test] + fn should_error_if_the_path_is_read_only() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + let mut permissions = output_path.metadata().unwrap().permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&output_path, permissions).unwrap(); + + assert!( + database + .write_user_metadata(&output_path, true) + .is_err() + ); + } + + #[test] + fn should_error_if_the_path_exists_and_truncation_is_not_allowed() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + assert!( + database + .write_user_metadata(&output_path, false) + .is_err() + ); + } + } + + mod write_minimal_list { + use crate::tests::{BLANK_DIFFERENT_ESM, BLANK_ESM}; + + use super::*; + + #[test] + fn should_only_write_plugin_bash_tags_and_dirty_info() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + assert!( + database + .write_minimal_list(&output_path, false) + .is_ok() + ); + + let content = std::fs::read_to_string(output_path).unwrap(); + + // Plugin entries are unordered. + let expected_content = if content.find(BLANK_DIFFERENT_ESM) < content.find(BLANK_ESM) { + "plugins: + - name: 'Blank - Different.esm' + dirty: + - crc: 0x7D22F9DF + util: 'TES4Edit' + udr: 4 + - name: 'Blank.esm' + tag: + - Actors.ACBS + - Actors.AIData + - -C.Water" + } else { + "plugins: + - name: 'Blank.esm' + tag: + - Actors.ACBS + - Actors.AIData + - -C.Water + - name: 'Blank - Different.esm' + dirty: + - crc: 0x7D22F9DF + util: 'TES4Edit' + udr: 4" + }; + + assert_eq!(expected_content, content); + } + + #[test] + fn should_succeed_if_the_path_does_not_exist() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + assert!( + database + .write_minimal_list(&output_path, false) + .is_ok() + ); + } + + #[test] + fn should_succeed_if_the_path_does_not_exist_and_truncation_is_allowed() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + assert!( + database + .write_minimal_list(&output_path, true) + .is_ok() + ); + } + + #[test] + fn should_succeed_if_the_path_exists_and_truncation_is_allowed() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + assert!( + database + .write_minimal_list(&output_path, true) + .is_ok() + ); + } + + #[test] + fn should_error_if_the_parent_path_does_not_exist() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("missing/minimal.yaml"); + + assert!( + database + .write_minimal_list(&output_path, false) + .is_err() + ); + } + + #[test] + fn should_error_if_the_path_is_read_only() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + let mut permissions = output_path.metadata().unwrap().permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&output_path, permissions).unwrap(); + + assert!( + database + .write_minimal_list(&output_path, true) + .is_err() + ); + } + + #[test] + fn should_error_if_the_path_exists_and_truncation_is_not_allowed() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + assert!( + database + .write_minimal_list(&output_path, false) + .is_err() + ); + } + } + + #[test] + fn known_bash_tags_should_append_userlist_tags_to_masterlist_tags() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write(&userlist_path, "bash_tags: [Relev, Delev]").unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + vec!["C.Climate", "Relev", "Delev"], + database.known_bash_tags() + ); + } + + mod general_messages { + use super::*; + + #[test] + fn should_append_userlist_messages_to_masterlist_messages() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "globals: [{type: say, content: 'A user message'}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + &[ + Message::new(MessageType::Say, "A general message".into()) + .with_condition("file(\"missing.esp\")".into()), + Message::new(MessageType::Say, "A user message".into()) + ], + database.general_messages(false).unwrap().as_slice() + ); + } + + #[test] + fn should_filter_out_messages_with_false_conditions_when_evaluating_conditions() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "globals: [{type: say, content: 'A user message'}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + &[Message::new(MessageType::Say, "A user message".into())], + database.general_messages(true).unwrap().as_slice() + ); + } + } + + mod groups { + use super::*; + + #[test] + fn should_return_default_group_before_metadata_has_been_loaded() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + + assert_eq!(&[Group::default(),], database.groups(true).as_slice()); + } + + #[test] + fn should_not_include_user_groups_if_param_is_false() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "groups: [{name: group2, after: [default]}, {name: group3, after: [group1]}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()).with_after_groups(vec!["group1".into()]) + ], + database.groups(false).as_slice() + ); + } + + #[test] + fn should_merge_masterlist_and_userlist_groups() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "groups: [{name: group2, after: [default]}, {name: group3, after: [group1]}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()) + .with_after_groups(vec!["group1".into(), "default".into()]), + Group::new("group3".into()).with_after_groups(vec!["group1".into()]) + ], + database.groups(true).as_slice() + ); + } + } + + #[test] + fn user_groups_should_not_include_masterlist_groups() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + assert_eq!(&[Group::default(),], database.user_groups()); + } + + #[test] + fn set_user_groups_should_replace_existing_user_groups() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "groups: [{name: group2, after: [default]}, {name: group3, after: [group1]}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + database.set_user_groups(vec![Group::new("group4".into())]); + + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()).with_after_groups(vec!["group1".into()]) + ], + database.groups(false).as_slice() + ); + + assert_eq!( + &[Group::default(), Group::new("group4".into())], + database.user_groups() + ); + } + + #[test] + fn groups_path_should_find_path_using_masterlist_and_user_metadata() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + database.set_user_groups(vec![ + Group::new("group3".into()).with_after_groups(vec!["group2".into()]), + ]); + + let path = database.groups_path("group1", "group3").unwrap(); + + assert_eq!( + vec![ + Vertex::new("group1".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("group2".into()).with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new("group3".into()), + ], + path + ); + } + + mod plugin_metadata { + use crate::tests::BLANK_ESM; + + use super::*; + + #[test] + fn should_return_none_if_plugin_has_no_metadata_set() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + + assert!( + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .is_none() + ) + } + + #[test] + fn should_return_none_if_plugin_metadata_has_only_name() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.set_plugin_user_metadata(PluginMetadata::new(BLANK_ESM).unwrap()); + + assert!( + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .is_none() + ) + } + + #[test] + fn should_prefer_user_metadata_when_merging_metadata() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[ + File::new(BLANK_DIFFERENT_ESM.into()), + File::new("Oblivion.esm".into()) + ], + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn should_return_only_masterlist_metadata_if_include_user_metadata_is_false() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[File::new("Oblivion.esm".into())], + database + .plugin_metadata(BLANK_ESM, false, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn should_filter_out_metadata_with_false_conditions_when_evaluating_conditions() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_messages(vec![ + Message::new(MessageType::Say, "content".into()) + .with_condition("file(\"missing.esp\")".into()), + ]); + + database.set_plugin_user_metadata(plugin); + + assert!( + database + .plugin_metadata(BLANK_ESM, true, true) + .unwrap() + .unwrap() + .messages() + .is_empty() + ); + } + } + + mod plugin_user_metadata { + use super::*; + + #[test] + fn should_return_none_if_plugin_has_no_user_metadata_set() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + + assert!( + database + .plugin_user_metadata(BLANK_ESM, false) + .unwrap() + .is_none() + ) + } + + #[test] + fn should_return_none_if_plugin_user_metadata_has_only_name() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.set_plugin_user_metadata(PluginMetadata::new(BLANK_ESM).unwrap()); + + assert!( + database + .plugin_user_metadata(BLANK_ESM, false) + .unwrap() + .is_none() + ) + } + + #[test] + fn should_return_only_user_metadata() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[File::new(BLANK_DIFFERENT_ESM.into())], + database + .plugin_user_metadata(BLANK_ESM, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn should_filter_out_metadata_with_false_conditions_when_evaluating_conditions() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![ + File::new(BLANK_DIFFERENT_ESM.into()) + .with_condition("file(\"missing.esp\")".into()), + ]); + + database.set_plugin_user_metadata(plugin); + + assert!( + database + .plugin_user_metadata(BLANK_ESM, true) + .unwrap() + .is_none() + ); + } + } + + mod set_plugin_user_metadata { + use super::*; + + #[test] + fn should_replace_existing_user_metadata_for_the_plugin() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin.clone()); + + plugin.set_load_after_files(vec![File::new(BLANK_MASTER_DEPENDENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[File::new(BLANK_MASTER_DEPENDENT_ESM.into())], + database + .plugin_user_metadata(BLANK_ESM, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn should_not_modify_masterlist_metadata_for_the_plugin() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[ + File::new(BLANK_DIFFERENT_ESM.into()), + File::new("Oblivion.esm".into()), + ], + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + } + + #[test] + fn discard_plugin_user_metadata_should_discard_only_user_metadata_for_only_the_given_plugin() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin1.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + let mut plugin2 = PluginMetadata::new(BLANK_DIFFERENT_ESM).unwrap(); + plugin2.set_load_after_files(vec![File::new(BLANK_ESM.into())]); + + database.set_plugin_user_metadata(plugin1); + database.set_plugin_user_metadata(plugin2); + + database.discard_plugin_user_metadata(BLANK_ESM); + + assert_eq!( + &[File::new("Oblivion.esm".into())], + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + assert_eq!( + &[ + File::new(BLANK_ESM.into()), + File::new(BLANK_MASTER_DEPENDENT_ESM.into()), + ], + database + .plugin_metadata(BLANK_DIFFERENT_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn discard_all_user_metadata_should_not_remove_masterlist_metadata() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin1.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + let mut plugin2 = PluginMetadata::new(BLANK_DIFFERENT_ESM).unwrap(); + plugin2.set_load_after_files(vec![File::new(BLANK_ESM.into())]); + + database.set_user_groups(vec![Group::new("group4".into())]); + database.set_plugin_user_metadata(plugin1); + database.set_plugin_user_metadata(plugin2); + + database.discard_all_user_metadata(); + + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()).with_after_groups(vec!["group1".into()]) + ], + database.groups(true).as_slice() + ); + assert_eq!( + &[File::new("Oblivion.esm".into())], + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + assert_eq!( + &[File::new(BLANK_MASTER_DEPENDENT_ESM.into()),], + database + .plugin_metadata(BLANK_DIFFERENT_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } +} diff --git a/src/game.rs b/src/game.rs index 60eb671d..9ae6dd85 100644 --- a/src/game.rs +++ b/src/game.rs @@ -739,7 +739,7 @@ pub(crate) struct GameCache { } impl GameCache { - fn set_archive_paths(&mut self, archive_paths: Vec) { + pub fn set_archive_paths(&mut self, archive_paths: Vec) { self.archive_paths.clear(); self.archive_paths.extend(archive_paths); } @@ -772,55 +772,1320 @@ impl GameCache { mod tests { use super::*; - use rstest::rstest; - use rstest_reuse::{apply, template}; + use rstest_reuse::apply; - use crate::tests::Fixture; + use crate::tests::{Fixture, all_game_types}; - #[template] - #[rstest] - fn all_game_types( - #[values( - GameType::TES4, - GameType::TES5, - GameType::FO3, - GameType::FONV, - GameType::FO4, - GameType::TES5SE, - GameType::FO4VR, - GameType::TES5VR, - GameType::TES3, - GameType::Starfield, - GameType::OpenMW - )] - game_type: GameType, - ) { - } + mod game { + use std::path::Component; + + use crate::tests::BLANK_ESM; - mod new { use super::*; - #[apply(all_game_types)] - fn should_succeed_if_given_valid_game_path(game_type: GameType) { - let fixture = Fixture::new(game_type); + #[cfg(windows)] + fn symlink_dir(original: &Path, link: &Path) { + std::os::windows::fs::symlink_dir(original, link).unwrap(); + } - let game = Game::new(fixture.game_type, &fixture.game_path); + #[cfg(unix)] + fn symlink_dir(original: &Path, link: &Path) { + std::os::unix::fs::symlink(original, link).unwrap(); + } - assert!(game.is_ok()); + #[cfg(windows)] + fn junction_link(original: &Path, link: &Path) { + use std::ffi::{OsStr, OsString}; + + // The paths may contain forward slashes, which cmd doesn't accept, + // so replace them. + let original = OsString::from(original.to_str().unwrap().replace("/", "\\")); + let link = OsString::from(link.to_str().unwrap().replace("/", "\\")); + + let status = std::process::Command::new("cmd") + .args([ + OsStr::new("/C"), + OsStr::new("mklink"), + OsStr::new("/J"), + link.as_os_str(), + original.as_os_str(), + ]) + .status() + .unwrap(); + + assert!(status.success()); + } + + fn make_relative(path: &Path) -> PathBuf { + if path.is_relative() { + return path.to_path_buf(); + } + + let base = std::env::current_dir().unwrap(); + assert!(base.is_absolute()); + + let mut path_iter = path.components(); + let mut base_iter = base.components(); + + let mut relative_components = Vec::new(); + loop { + match (path_iter.next(), base_iter.next()) { + (None, None) => break, + (None, _) => relative_components.push(Component::ParentDir), + (Some(p), None) => { + relative_components.push(p); + relative_components.extend(path_iter); + break; + } + (Some(p), Some(b)) => { + if relative_components.is_empty() && p == b { + continue; + } + + relative_components.push(Component::ParentDir); + for _ in base_iter { + relative_components.push(Component::ParentDir); + } + + relative_components.push(p); + relative_components.extend(path_iter); + break; + } + } + } + + relative_components + .into_iter() + .map(|c| c.as_os_str()) + .collect() + } + + mod new { + use super::*; + + #[cfg(windows)] + #[apply(all_game_types)] + fn should_succeed_if_given_valid_game_path(game_type: GameType) { + let fixture = Fixture::new(game_type); + + assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); + } + + #[cfg(not(windows))] + #[apply(all_game_types)] + fn should_succeed_for_morrowind_if_given_valid_game_path(game_type: GameType) { + if matches!(game_type, GameType::TES3 | GameType::OpenMW) { + assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); + } else { + assert!(Game::new(fixture.game_type, &fixture.game_path).is_err()); + } + } + + #[test] + fn should_succeed_if_given_a_relative_game_path() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = make_relative(&fixture.game_path); + assert!(game_path.is_relative()); + + assert!(Game::new(fixture.game_type, &game_path).is_ok()); + } + + #[test] + fn should_succeed_if_given_an_absolute_game_path() { + let fixture = Fixture::new(GameType::TES4); + + assert!(fixture.game_path.is_absolute()); + assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); + } + + #[test] + fn should_succeed_if_given_a_symlink_path() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = fixture.game_path.with_extension("symlink"); + symlink_dir(&fixture.game_path, &game_path); + assert!(game_path.is_symlink()); + + assert!(Game::new(fixture.game_type, &game_path).is_ok()); + } + + #[cfg(windows)] + #[test] + fn should_succeed_if_given_a_junction_link_path() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = fixture.game_path.with_extension("junction"); + junction_link(&fixture.game_path, &game_path); + + assert!(Game::new(fixture.game_type, &game_path).is_ok()); + } + + #[test] + fn should_error_if_given_a_game_path_that_does_not_exist() { + let game_path = Path::new("missing"); + match Game::new(GameType::TES4, game_path) { + Err(GameHandleCreationError::NotADirectory(p)) => { + assert_eq!(game_path, p) + } + _ => panic!("Expected a not-a-directory error"), + } + } + } + + mod with_local_path { + use super::*; + + #[apply(all_game_types)] + fn should_succeed_if_given_valid_paths(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ); + + assert!(game.is_ok()); + } + + #[test] + fn should_succeed_if_given_relative_paths() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = make_relative(&fixture.game_path); + assert!(game_path.is_relative()); + + let local_path = make_relative(&fixture.local_path); + assert!(local_path.is_relative()); + + let game = Game::with_local_path(fixture.game_type, &game_path, &local_path); + + assert!(game.is_ok()); + } + + #[test] + fn should_succeed_if_given_absolute_paths() { + let fixture = Fixture::new(GameType::TES4); + + assert!(fixture.game_path.is_absolute()); + assert!(fixture.local_path.is_absolute()); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ); + + assert!(game.is_ok()); + } + + #[test] + fn should_succeed_if_given_symlink_paths() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = fixture.game_path.with_extension("symlink"); + symlink_dir(&fixture.game_path, &game_path); + assert!(game_path.is_symlink()); + + let local_path = fixture.local_path.with_extension("symlink"); + symlink_dir(&fixture.local_path, &local_path); + assert!(local_path.is_symlink()); + + let game = Game::with_local_path(fixture.game_type, &game_path, &local_path); + + assert!(game.is_ok()); + } + + #[cfg(windows)] + #[test] + fn should_succeed_if_given_junction_link_paths() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = fixture.game_path.with_extension("junction"); + junction_link(&fixture.game_path, &game_path); + + let local_path = fixture.local_path.with_extension("junction"); + junction_link(&fixture.local_path, &local_path); + + let game = Game::with_local_path(fixture.game_type, &game_path, &local_path); + + assert!(game.is_ok()); + } + + #[test] + fn should_error_if_given_a_game_path_that_does_not_exist() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = Path::new("missing"); + let game = Game::with_local_path(fixture.game_type, game_path, &fixture.local_path); + + match game { + Err(GameHandleCreationError::NotADirectory(p)) => { + assert_eq!(game_path, p) + } + _ => panic!("Expected a not-a-directory error"), + } + } + + #[test] + fn should_succeed_if_given_a_local_path_that_does_not_exist() { + let fixture = Fixture::new(GameType::TES4); + + let local_path = Path::new("missing"); + let game = Game::with_local_path(fixture.game_type, &fixture.game_path, local_path); + + assert!(game.is_ok()); + } + + #[test] + fn should_error_if_given_a_local_path_that_is_not_a_directory() { + let fixture = Fixture::new(GameType::TES4); + + let local_path = Path::new("README.md"); + assert!(local_path.exists()); + + let game = Game::with_local_path(fixture.game_type, &fixture.game_path, local_path); + + match game { + Err(GameHandleCreationError::NotADirectory(p)) => { + assert_eq!(local_path, p) + } + _ => panic!("Expected a not-a-directory error"), + } + } + + #[apply(all_game_types)] + fn should_set_default_additional_data_paths(game_type: GameType) { + let fixture = Fixture::new(game_type); + + match game_type { + GameType::FO4 => { + std::fs::File::create(fixture.game_path.join("appxmanifest.xml")).unwrap(); + } + GameType::OpenMW => { + let contents = format!( + "data-local=\"{}\"\nconfig=\"{}\"", + fixture.local_path.join("data").display(), + fixture.local_path.display() + ); + std::fs::write(fixture.game_path.join("openmw.cfg"), contents).unwrap(); + } + _ => {} + } + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + match game_type { + GameType::FO4 => { + let base_path = fixture.game_path.join("../.."); + assert_eq!( + &[ + base_path + .join("Fallout 4- Automatron (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Nuka-World (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Wasteland Workshop (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- High Resolution Texture Pack") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Vault-Tec Workshop (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Far Harbor (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Contraptions Workshop (PC)") + .join("Content") + .join("Data") + ], + game.additional_data_paths() + ); + } + GameType::Starfield => { + assert_eq!(1, game.additional_data_paths().len()); + + let expected_suffix = Path::new("Documents") + .join("My Games") + .join("Starfield") + .join("Data"); + + assert!(game.additional_data_paths()[0].ends_with(expected_suffix)); + } + GameType::OpenMW => { + assert_eq!( + &[fixture.local_path.join("data")], + game.additional_data_paths() + ); + } + _ => assert!(game.additional_data_paths().is_empty()), + } + } + + mod set_additional_data_paths { + use std::time::{Duration, SystemTime}; + + use crate::{ + metadata::{File, PluginMetadata}, + tests::{BLANK_ESM, BLANK_ESP}, + }; + + use super::*; + + #[test] + fn should_clear_the_condition_cache() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let mut metadata = PluginMetadata::new(BLANK_ESM).unwrap(); + metadata.set_load_after_files(vec![ + File::new("plugin.esp".into()) + .with_condition("file(\"plugin.esp\")".into()), + ]); + game.database() + .write() + .unwrap() + .set_plugin_user_metadata(metadata); + + let evaluated_metadata = game + .database() + .read() + .unwrap() + .plugin_user_metadata(BLANK_ESM, true) + .unwrap(); + assert!(evaluated_metadata.is_none()); + + std::fs::File::create(fixture.data_path().join("plugin.esp")).unwrap(); + + game.set_additional_data_paths(&[Path::new("")]).unwrap(); + + let evaluated_metadata = game + .database() + .read() + .unwrap() + .plugin_user_metadata(BLANK_ESM, true) + .unwrap() + .unwrap(); + assert!(!evaluated_metadata.load_after_files().is_empty()); + } + + #[test] + fn should_update_where_load_order_plugins_are_found() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_current_load_order_state().unwrap(); + + let mut load_order: Vec<_> = + game.load_order().iter().map(|s| s.to_string()).collect(); + + let filename = "plugin.esp"; + let data_file_path = fixture + .game_path + .parent() + .unwrap() + .join("Data") + .join(filename); + + std::fs::create_dir_all(data_file_path.parent().unwrap()).unwrap(); + std::fs::copy(fixture.data_path().join(BLANK_ESP), &data_file_path).unwrap(); + + std::fs::File::options() + .write(true) + .open(&data_file_path) + .unwrap() + .set_modified(SystemTime::now() + Duration::from_secs(3600)) + .unwrap(); + + game.set_additional_data_paths(&[data_file_path.parent().unwrap()]) + .unwrap(); + game.load_current_load_order_state().unwrap(); + + load_order.push(filename.to_string()); + + assert_eq!(load_order, game.load_order()); + } + } + } + + mod is_valid_plugin { + use super::*; + + use crate::tests::{ + BLANK_ESM, BLANK_MASTER_DEPENDENT_ESM, NON_ASCII_ESM, NON_PLUGIN_FILE, + }; + + #[apply(all_game_types)] + fn should_return_true_for_a_valid_non_ascii_plugin(game_type: GameType) { + let fixture = Fixture::new(game_type); + + std::fs::copy( + fixture.data_path().join(BLANK_ESM), + fixture.data_path().join(NON_ASCII_ESM), + ) + .unwrap(); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.is_valid_plugin(Path::new(NON_ASCII_ESM))); + } + + #[apply(all_game_types)] + fn should_return_true_for_an_omwscripts_plugin(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let plugin = fixture.data_path().join("empty.omwscripts"); + let _ = std::fs::File::create(&plugin).unwrap(); + + if game_type == GameType::OpenMW { + assert!(game.is_valid_plugin(&plugin)); + } else { + assert!(!game.is_valid_plugin(&plugin)); + } + } + + #[apply(all_game_types)] + fn should_return_false_for_a_non_plugin_file(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(!game.is_valid_plugin(Path::new(NON_PLUGIN_FILE))); + } + + #[apply(all_game_types)] + fn should_return_false_for_an_empty_file(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let empty_file_path = fixture.data_path().join("empty.esp"); + let _ = std::fs::File::create(&empty_file_path).unwrap(); + + assert!(!game.is_valid_plugin(&empty_file_path)); + } + + #[apply(all_game_types)] + fn should_try_ghosted_path_if_given_plugin_does_not_exist_unless_game_is_openmw( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = fixture.data_path().join(BLANK_MASTER_DEPENDENT_ESM); + + if game_type == GameType::OpenMW { + std::fs::rename( + &path, + path.with_file_name(format!("{}.ghost", BLANK_MASTER_DEPENDENT_ESM)), + ) + .unwrap(); + + assert!(!game.is_valid_plugin(&path)); + } else { + assert!(!path.exists()); + + assert!(game.is_valid_plugin(&path)); + } + } + + #[test] + fn should_resolve_relative_paths_relative_to_the_data_path() { + let fixture = Fixture::new(GameType::TES4); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = Path::new("..") + .join(fixture.data_path().file_name().unwrap()) + .join(BLANK_ESM); + + assert!(game.is_valid_plugin(&path)); + } + + #[test] + fn should_use_absolute_paths_as_given() { + let fixture = Fixture::new(GameType::TES4); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = fixture.data_path().join(BLANK_ESM); + + assert!(game.is_valid_plugin(&path)); + } + } + + mod load_plugin_headers { + use crate::tests::{BLANK_DIFFERENT_ESM, BLANK_ESM, BLANK_ESP, NON_PLUGIN_FILE}; + + use super::*; + + #[apply(all_game_types)] + fn should_load_the_headers_of_the_given_plugins(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.plugin(BLANK_ESM).is_none()); + assert!(game.plugin(BLANK_DIFFERENT_ESM).is_none()); + assert!(game.plugin(BLANK_ESP).is_none()); + + game.load_plugin_headers(&[Path::new(BLANK_ESM), Path::new(BLANK_ESP)]) + .unwrap(); + + let plugin = game.plugin(BLANK_ESM).unwrap(); + assert_eq!("5.0", plugin.version().unwrap()); + assert!(plugin.crc().is_none()); + + assert!(game.plugin(BLANK_DIFFERENT_ESM).is_none()); + assert!(game.plugin(BLANK_ESP).is_some()); + } + + #[test] + fn should_not_modify_loaded_plugins_storage_if_given_a_non_plugin() { + let fixture = Fixture::new(GameType::TES3); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + assert!(game.plugin(BLANK_ESM).is_some()); + + assert!( + game.load_plugin_headers(&[Path::new(NON_PLUGIN_FILE)]) + .is_err() + ); + + assert!(game.plugin(BLANK_ESM).is_some()); + assert!(game.plugin(NON_PLUGIN_FILE).is_none()); + } + + #[test] + fn should_not_clear_the_plugins_cache() { + let fixture = Fixture::new(GameType::TES3); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + assert!(game.plugin(BLANK_ESM).is_some()); + + game.load_plugin_headers(&[Path::new(BLANK_ESP)]).unwrap(); + + assert!(game.plugin(BLANK_ESM).is_some()); + assert!(game.plugin(BLANK_ESP).is_some()); + } + + #[test] + fn should_replace_an_existing_cache_entry_for_the_same_plugin() { + let fixture = Fixture::new(GameType::TES3); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + let plugin1: *const str = game.plugin(BLANK_ESM).unwrap().name(); + let plugin2: *const str = game.plugin(BLANK_ESM).unwrap().name(); + + assert_eq!(plugin1, plugin2); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + + let plugin3: *const str = game.plugin(BLANK_ESM).unwrap().name(); + + assert_ne!(plugin2, plugin3); + } + } + + mod load_plugins { + use std::error::Error; + + use crate::tests::{ + BLANK_DIFFERENT_ESM, BLANK_ESM, BLANK_ESP, BLANK_FULL_ESM, + BLANK_MASTER_DEPENDENT_ESM, + }; + + use super::*; + + #[apply(all_game_types)] + fn should_fully_load_the_given_plugins(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.plugin(BLANK_ESM).is_none()); + assert!(game.plugin(BLANK_DIFFERENT_ESM).is_none()); + assert!(game.plugin(BLANK_ESP).is_none()); + + game.load_plugins(&[Path::new(BLANK_ESM), Path::new(BLANK_ESP)]) + .unwrap(); + + let plugin = game.plugin(BLANK_ESM).unwrap(); + assert_eq!("5.0", plugin.version().unwrap()); + assert!(plugin.crc().is_some()); + + assert!(game.plugin(BLANK_DIFFERENT_ESM).is_none()); + assert!(game.plugin(BLANK_ESP).is_some()); + } + + #[test] + fn should_not_clear_the_plugins_cache() { + let fixture = Fixture::new(GameType::TES3); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + assert!(game.plugin(BLANK_ESM).is_some()); + + game.load_plugin_headers(&[Path::new(BLANK_ESP)]).unwrap(); + + assert!(game.plugin(BLANK_ESM).is_some()); + assert!(game.plugin(BLANK_ESP).is_some()); + } + + #[test] + fn should_replace_an_existing_cache_entry_for_the_same_plugin() { + let fixture = Fixture::new(GameType::TES3); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugins(&[Path::new(BLANK_ESM)]).unwrap(); + let plugin1: *const str = game.plugin(BLANK_ESM).unwrap().name(); + let plugin2: *const str = game.plugin(BLANK_ESM).unwrap().name(); + + assert_eq!(plugin1, plugin2); + + game.load_plugins(&[Path::new(BLANK_ESM)]).unwrap(); + + let plugin3: *const str = game.plugin(BLANK_ESM).unwrap().name(); + + assert_ne!(plugin2, plugin3); + } + + #[apply(all_game_types)] + fn should_error_if_loading_a_plugin_with_a_master_that_is_not_loaded_if_game_is_morrowind_or_starfield( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let paths = &[Path::new(BLANK_MASTER_DEPENDENT_ESM)]; + + if matches!( + game_type, + GameType::TES3 | GameType::OpenMW | GameType::Starfield + ) { + match game.load_plugins(paths) { + Err(LoadPluginsError::PluginDataError(e)) => { + let source = e.source().unwrap(); + match source.downcast_ref::().unwrap() { + esplugin::Error::PluginMetadataNotFound(p) => { + if game_type == GameType::Starfield { + assert_eq!(BLANK_FULL_ESM, p) + } else { + assert_eq!(BLANK_ESM, p) + }; + } + _ => panic!("Unexpected esplugin error: {e}"), + } + } + _ => panic!("Expected an error due to esplugin metadata not found"), + } + } else { + game.load_plugins(paths).unwrap(); + + assert!(game.plugin(BLANK_MASTER_DEPENDENT_ESM).is_some()); + } + } + + #[apply(all_game_types)] + fn should_not_error_if_loading_a_plugin_with_a_master_that_is_also_being_loaded_if_game_is_morrowind_or_starfield( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let paths: &[&Path] = if game_type == GameType::Starfield { + &[ + Path::new(BLANK_MASTER_DEPENDENT_ESM), + Path::new(BLANK_FULL_ESM), + ] + } else { + &[Path::new(BLANK_MASTER_DEPENDENT_ESM), Path::new(BLANK_ESM)] + }; + + game.load_plugins(paths).unwrap(); + + assert!(game.plugin(BLANK_MASTER_DEPENDENT_ESM).is_some()); + } + } + + mod load_plugins_common { + use crate::tests::{BLANK_ESM, BLANK_MASTER_DEPENDENT_ESM}; + + use super::*; + + #[apply(all_game_types)] + fn should_find_archives_in_additional_data_paths(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let extension = if matches!( + game_type, + GameType::FO4 | GameType::FO4VR | GameType::Starfield + ) { + ".ba2" + } else { + ".bsa" + }; + + let path1 = fixture + .game_path + .join("sub1") + .join("archive") + .with_extension(extension); + let path2 = fixture + .game_path + .join("sub2") + .join("archive") + .with_extension(extension); + std::fs::create_dir(path1.parent().unwrap()).unwrap(); + std::fs::create_dir(path2.parent().unwrap()).unwrap(); + std::fs::File::create(&path1).unwrap(); + std::fs::File::create(&path2).unwrap(); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.set_additional_data_paths(&[path1.parent().unwrap(), path2.parent().unwrap()]) + .unwrap(); + + game.load_plugins_common(&[], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(HashSet::from([path1, path2]), game.cache.archive_paths); + } + + #[test] + fn should_clear_the_archive_cache_before_finding_archives() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + std::fs::File::create(fixture.data_path().join("Blank.bsa")).unwrap(); + + game.load_plugins_common(&[], LoadScope::HeaderOnly) + .unwrap(); + game.load_plugins_common(&[], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(1, game.cache.archive_paths.len()); + } + + #[test] + fn should_not_error_if_an_installed_filename_has_non_windows_1252_encodable_characters() + { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let filename = + "\u{2551}\u{00BB}\u{00C1}\u{2510}\u{2557}\u{00FE}\u{00C3}\u{00CE}.txt"; + std::fs::File::create(fixture.data_path().join(filename)).unwrap(); + + assert!(game.load_plugins_common(&[], LoadScope::HeaderOnly).is_ok()); + } + + #[test] + fn should_error_given_duplicate_filenames() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let paths = &[ + Path::new("a").join(BLANK_ESM), + Path::new("b").join(BLANK_ESM), + ]; + + match game.load_plugins_common(&[&paths[0], &paths[1]], LoadScope::HeaderOnly) { + Err(LoadPluginsError::PluginValidationError(e)) => { + assert_eq!( + format!( + "the path \"{}\" has a filename that is not unique", + paths[1].display() + ), + e.to_string() + ); + } + _ => panic!("Expected an error due to duplicate filenames"), + } + } + + #[test] + fn should_resolve_relative_paths_relative_to_the_data_path() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = Path::new("..") + .join(fixture.data_path().file_name().unwrap()) + .join(BLANK_ESM); + + let plugins = game + .load_plugins_common(&[&path], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(1, plugins.len()); + assert_eq!(BLANK_ESM, plugins[0].name()); + } + + #[test] + fn should_use_absolute_paths_as_given() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = fixture.data_path().join(BLANK_ESM); + + let plugins = game + .load_plugins_common(&[&path], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(1, plugins.len()); + assert_eq!(BLANK_ESM, plugins[0].name()); + } + + #[test] + fn should_trim_ghost_extensions_from_loaded_plugin_names() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = fixture + .data_path() + .join(format!("{}.ghost", BLANK_MASTER_DEPENDENT_ESM)); + + let plugins = game + .load_plugins_common(&[&path], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(1, plugins.len()); + assert_eq!(BLANK_MASTER_DEPENDENT_ESM, plugins[0].name()); + } + } + + #[test] + fn clear_loaded_plugins_should_clear_the_plugins_cache() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = + Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + + assert!(!game.cache.plugins.is_empty()); + + game.clear_loaded_plugins(); + + assert!(game.cache.plugins.is_empty()); + } + + mod sort_plugins { + use crate::tests::{BLANK_DIFFERENT_ESP, BLANK_ESP, initial_load_order}; + + use super::*; + + fn load_all_installed_plugins(game: &mut Game, fixture: &Fixture) { + let load_order = initial_load_order(fixture.game_type); + + let plugins: Vec<_> = load_order.iter().map(|(n, _)| Path::new(n)).collect(); + + game.load_current_load_order_state().unwrap(); + game.load_plugins(&plugins).unwrap(); + } + + #[test] + fn should_return_an_empty_list_if_given_an_empty_list() { + let fixture = Fixture::new(GameType::TES4); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.sort_plugins(&[]).unwrap().is_empty()); + } + + #[test] + fn should_only_sort_the_given_plugins() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + load_all_installed_plugins(&mut game, &fixture); + + let input = &[BLANK_ESP, BLANK_DIFFERENT_ESP]; + let sorted = game.sort_plugins(input).unwrap(); + + assert_eq!(input, sorted.as_slice()); + } + + #[test] + fn should_error_if_a_given_plugin_is_not_loaded() { + let fixture = Fixture::new(GameType::TES4); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.sort_plugins(&[BLANK_ESP]).is_err()); + } + } + + mod is_plugin_active { + use crate::tests::BLANK_ESP; + + use super::*; + + #[test] + fn should_be_independent_of_plugins_being_loaded() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_current_load_order_state().unwrap(); + + assert!(game.is_plugin_active(BLANK_ESM)); + assert!(!game.is_plugin_active(BLANK_ESP)); + + let paths = &[Path::new(BLANK_ESM), Path::new(BLANK_ESP)]; + game.load_plugin_headers(paths).unwrap(); + + assert!(game.is_plugin_active(BLANK_ESM)); + assert!(!game.is_plugin_active(BLANK_ESP)); + + game.load_plugins(paths).unwrap(); + + assert!(game.is_plugin_active(BLANK_ESM)); + assert!(!game.is_plugin_active(BLANK_ESP)); + } + } + + #[test] + fn set_load_order_should_persist_the_given_load_order() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = + Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) + .unwrap(); + + game.load_current_load_order_state().unwrap(); + + let mut load_order: Vec<_> = game.load_order().iter().map(|s| s.to_string()).collect(); + load_order.swap(7, 10); + let load_order: Vec<_> = load_order.iter().map(|s| s.as_str()).collect(); + + game.set_load_order(&load_order).unwrap(); + + let mut game = + Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) + .unwrap(); + + game.load_current_load_order_state().unwrap(); + + assert_eq!(load_order, game.load_order()); + } + + #[test] + fn should_support_loading_plugins_and_metadata_in_parallel() { + let fixture = Fixture::new(GameType::TES3); + + let mut game = + Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) + .unwrap(); + + let masterlist_path = fixture.local_path.join("masterlist.yaml"); + std::fs::write(&masterlist_path, "bash_tags: [Relev]").unwrap(); + + std::thread::scope(|s| { + let database = game.database(); + s.spawn(move || { + if let Ok(mut database) = database.write() { + database.load_masterlist(&masterlist_path).unwrap(); + } + }); + s.spawn(|| { + game.load_plugins(&[]).unwrap(); + }); + }); } } - mod with_local_path { + mod game_cache { use super::*; - #[apply(all_game_types)] - fn should_succeed_if_given_valid_paths(game_type: GameType) { - let fixture = Fixture::new(game_type); + use crate::tests::{BLANK_ESM, source_plugins_path}; - let game = - Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path); + mod insert_plugins { - assert!(game.is_ok()); + use super::*; + + #[test] + fn should_add_plugins_not_already_cached() { + let mut cache = GameCache::default(); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::TES4, + &cache, + &source_plugins_path(GameType::TES4).join(BLANK_ESM), + LoadScope::HeaderOnly, + ) + .unwrap(), + ]); + + assert_eq!(BLANK_ESM, cache.plugin(BLANK_ESM).unwrap().name()); + } + + #[test] + fn should_replace_plugins_that_are_already_cached() { + let mut cache = GameCache::default(); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::TES4, + &cache, + &source_plugins_path(GameType::TES4).join(BLANK_ESM), + LoadScope::HeaderOnly, + ) + .unwrap(), + ]); + + assert!(cache.plugin(BLANK_ESM).unwrap().crc().is_none()); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::TES4, + &cache, + &source_plugins_path(GameType::TES4).join(BLANK_ESM), + LoadScope::WholePlugin, + ) + .unwrap(), + ]); + + assert!(cache.plugin(BLANK_ESM).unwrap().crc().is_some()); + } + } + + mod plugin { + use super::*; + + #[test] + fn should_be_case_insensitive() { + let mut cache = GameCache::default(); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::TES4, + &cache, + &source_plugins_path(GameType::TES4).join(BLANK_ESM), + LoadScope::HeaderOnly, + ) + .unwrap(), + ]); + + assert_eq!("Blank.esm", cache.plugin("blank.esm").unwrap().name()); + } + + #[test] + fn should_return_none_if_the_plugin_is_not_cached() { + let cache = GameCache::default(); + + assert!(cache.plugin(BLANK_ESM).is_none()); + } + } + + mod clear_plugins { + use super::*; + + #[test] + fn should_clear_any_cached_plugins() { + let mut cache = GameCache::default(); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::TES4, + &cache, + &source_plugins_path(GameType::TES4).join(BLANK_ESM), + LoadScope::HeaderOnly, + ) + .unwrap(), + ]); + + assert!(!cache.plugins.is_empty()); + + cache.clear_plugins(); + + assert!(cache.plugins.is_empty()); + } } } } diff --git a/src/metadata/file.rs b/src/metadata/file.rs index dea1bd84..4556a0b0 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -194,6 +194,131 @@ impl EmitYaml for File { mod tests { use super::*; + mod file_eq { + use super::*; + + #[test] + fn should_be_case_insensitive_on_name() { + assert_eq!(File::new("name".into()), File::new("name".into())); + assert_eq!(File::new("name".into()), File::new("NAME".into())); + assert_ne!(File::new("name1".into()), File::new("name2".into())); + } + } + + mod filename_eq { + use super::*; + + #[test] + fn should_be_case_insensitive_on_name() { + assert_eq!(Filename::new("name".into()), Filename::new("name".into())); + assert_eq!(Filename::new("name".into()), Filename::new("NAME".into())); + assert_ne!(Filename::new("name1".into()), Filename::new("name2".into())); + } + } + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_only_set_name_if_decoding_from_scalar() { + let yaml = parse("name1"); + + let file = File::try_from(&yaml).unwrap(); + + assert_eq!("name1", file.name().as_str()); + assert!(file.display_name().is_none()); + assert!(file.condition().is_none()); + assert!(file.detail().is_empty()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(File::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_missing() { + let yaml = parse("{display: display1}"); + + assert!(File::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_an_invalid_condition() { + let yaml = parse("{name: name1, condition: invalid}"); + + assert!(File::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = parse( + "{name: name1, display: display1, condition: 'file(\"Foo.esp\")', detail: 'details'}", + ); + + let file = File::try_from(&yaml).unwrap(); + + assert_eq!("name1", file.name().as_str()); + assert_eq!("display1", file.display_name().unwrap()); + assert_eq!("file(\"Foo.esp\")", file.condition().unwrap()); + assert_eq!(&[MessageContent::new("details".into())], file.detail()); + } + + #[test] + fn should_leave_optional_fields_empty_if_not_present() { + let yaml = parse("{name: name1}"); + + let file = File::try_from(&yaml).unwrap(); + + assert_eq!("name1", file.name().as_str()); + assert!(file.display_name().is_none()); + assert!(file.condition().is_none()); + assert!(file.detail().is_empty()); + } + + #[test] + fn should_read_all_listed_detail_message_contents() { + let yaml = parse( + "{name: name1, detail: [{text: english, lang: en}, {text: french, lang: fr}]}", + ); + + let file = File::try_from(&yaml).unwrap(); + + assert_eq!( + &[ + MessageContent::new("english".into()), + MessageContent::new("french".into()).with_language("fr".into()) + ], + file.detail() + ); + } + + #[test] + fn should_not_error_if_one_detail_is_given_and_it_is_not_english() { + let yaml = parse("name: name1\ndetail:\n - lang: fr\n text: content1"); + + let file = File::try_from(&yaml).unwrap(); + + assert_eq!( + &[MessageContent::new("content1".into()).with_language("fr".into())], + file.detail() + ); + } + + #[test] + fn should_error_if_multiple_details_are_given_and_none_are_english() { + let yaml = parse( + "name: name1\ndetail:\n - lang: de\n text: content1\n - lang: fr\n text: content2", + ); + + assert!(File::try_from(&yaml).is_err()); + } + } + mod emit_yaml { use crate::metadata::emit; diff --git a/src/metadata/group.rs b/src/metadata/group.rs index e5124676..104b5b76 100644 --- a/src/metadata/group.rs +++ b/src/metadata/group.rs @@ -126,6 +126,48 @@ impl EmitYaml for Group { mod tests { use super::*; + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(Group::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_missing() { + let yaml = parse("{description: text}"); + + assert!(Group::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = parse("{name: group1, description: text, after: [ other_group ]}"); + + let group = Group::try_from(&yaml).unwrap(); + + assert_eq!("group1", group.name()); + assert_eq!("text", group.description().unwrap()); + assert_eq!(&["other_group"], group.after_groups()); + } + + #[test] + fn should_leave_optional_fields_empty_if_not_present() { + let yaml = parse("{name: group1}"); + + let group = Group::try_from(&yaml).unwrap(); + + assert_eq!("group1", group.name()); + assert!(group.description().is_none()); + assert!(group.after_groups().is_empty()); + } + } + mod emit_yaml { use super::*; use crate::metadata::emit; diff --git a/src/metadata/location.rs b/src/metadata/location.rs index bb79fc0b..66872672 100644 --- a/src/metadata/location.rs +++ b/src/metadata/location.rs @@ -105,6 +105,53 @@ impl EmitYaml for Location { mod tests { use super::*; + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_only_set_name_if_decoding_from_scalar() { + let yaml = parse("https://www.example.com"); + + let location = Location::try_from(&yaml).unwrap(); + + assert_eq!("https://www.example.com", location.url()); + assert!(location.name().is_none()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(Location::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_link_is_missing() { + let yaml = parse("{name: example}"); + + assert!(Location::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_missing() { + let yaml = parse("{link: https://www.example.com}"); + + assert!(Location::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_fields() { + let yaml = parse("{link: https://www.example.com, name: example}"); + + let location = Location::try_from(&yaml).unwrap(); + + assert_eq!("https://www.example.com", location.url()); + assert_eq!("example", location.name().unwrap()); + } + } + mod emit_yaml { use crate::metadata::emit; @@ -112,7 +159,7 @@ mod tests { #[test] fn should_emit_url_only_if_there_is_no_name() { - let location = Location::new("http://www.example.com".into()); + let location = Location::new("https://www.example.com".into()); let yaml = emit(&location); assert_eq!(format!("'{}'", location.url), yaml); @@ -121,7 +168,7 @@ mod tests { #[test] fn should_emit_map_if_there_is_a_name() { let location = - Location::new("http://www.example.com".into()).with_name("example".into()); + Location::new("https://www.example.com".into()).with_name("example".into()); let yaml = emit(&location); assert_eq!( diff --git a/src/metadata/message.rs b/src/metadata/message.rs index dedda515..1c31868c 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -453,14 +453,128 @@ mod tests { #[test] fn should_return_the_only_element_of_a_single_element_slice() { let slice = &[MessageContent::new("test".into()).with_language("de".into())]; - let content = select_message_content(slice, "fr"); + let content = select_message_content(slice, "fr").unwrap(); - assert_eq!(&slice[0], content.unwrap()); + assert_eq!(&slice[0], content); + } + + #[test] + fn should_return_element_with_exactly_matching_locale_code() { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt".into()), + MessageContent::new("test4".into()).with_language("pt_PT".into()), + MessageContent::new("test5".into()).with_language("pt_BR".into()), + ]; + + let content = select_message_content(slice, "pt_BR").unwrap(); + + assert_eq!(&slice[4], content); + } + + #[test] + fn should_return_element_with_matching_language_code_if_exactly_matching_local_code_is_not_present() + { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt".into()), + MessageContent::new("test4".into()).with_language("pt_PT".into()), + ]; + + let content = select_message_content(slice, "pt_BR").unwrap(); + + assert_eq!(&slice[2], content); + } + + #[test] + fn should_return_element_with_en_language_code_if_no_matching_language_code_is_present() { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt_PT".into()), + ]; + + let content = select_message_content(slice, "pt_BR").unwrap(); + + assert_eq!(&slice[0], content); + } + + #[test] + fn should_return_element_with_exactly_matching_language_code_if_language_code_is_given() { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt_BR".into()), + MessageContent::new("test4".into()).with_language("pt".into()), + ]; + + let content = select_message_content(slice, "pt").unwrap(); + + assert_eq!(&slice[3], content); + } + + #[test] + fn should_return_first_element_with_matching_language_code_if_language_code_is_given_and_no_exact_match_is_present() + { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt_PT".into()), + MessageContent::new("test4".into()).with_language("pt_BR".into()), + ]; + + let content = select_message_content(slice, "pt").unwrap(); + + assert_eq!(&slice[2], content); + } + + #[test] + fn should_return_none_if_there_is_no_match_and_no_english_text() { + let slice = &[ + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt_PT".into()), + MessageContent::new("test4".into()).with_language("pt_BR".into()), + ]; + + assert!(select_message_content(slice, "fr").is_none()); } } mod message_content { use super::*; + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_scalar() { + let yaml = parse("content"); + + assert!(MessageContent::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(MessageContent::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = parse("{text: content, lang: fr}"); + + let content = MessageContent::try_from(&yaml).unwrap(); + + assert_eq!("content", content.text()); + assert_eq!("fr", content.language()); + } + } + mod emit_yaml { use super::*; @@ -479,6 +593,182 @@ mod tests { mod message { use super::*; + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_scalar() { + let yaml = parse("content"); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_content_is_missing() { + let yaml = parse("{type: say}"); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_an_invalid_condition() { + let yaml = parse("{type: say, content: text, condition: invalid}"); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = parse("{type: say, content: text, condition: 'file(\"Foo.esp\")'}"); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!(MessageType::Say, message.message_type()); + assert_eq!(&[MessageContent::new("text".into())], message.content()); + assert_eq!("file(\"Foo.esp\")", message.condition().unwrap()); + } + + #[test] + fn should_leave_optional_fields_empty_if_not_present() { + let yaml = parse("{type: say, content: text}"); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!(MessageType::Say, message.message_type()); + assert_eq!(&[MessageContent::new("text".into())], message.content()); + assert!(message.condition().is_none()); + } + + #[test] + fn should_set_say_warn_and_error_message_types() { + let yaml = parse("{type: say, content: text}"); + + let message = Message::try_from(&yaml).unwrap(); + assert_eq!(MessageType::Say, message.message_type()); + + let yaml = parse("{type: warn, content: text}"); + + let message = Message::try_from(&yaml).unwrap(); + assert_eq!(MessageType::Warn, message.message_type()); + + let yaml = parse("{type: error, content: text}"); + + let message = Message::try_from(&yaml).unwrap(); + assert_eq!(MessageType::Error, message.message_type()); + } + + #[test] + fn should_use_say_if_message_type_is_unrecognised() { + let yaml = parse("{type: info, content: text}"); + + let message = Message::try_from(&yaml).unwrap(); + assert_eq!(MessageType::Say, message.message_type()); + } + + #[test] + fn should_read_all_listed_message_contents() { + let yaml = parse( + "{type: say, content: [{text: english, lang: en}, {text: french, lang: fr}]}", + ); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!( + &[ + MessageContent::new("english".into()), + MessageContent::new("french".into()).with_language("fr".into()) + ], + message.content() + ); + } + + #[test] + fn should_not_error_if_one_content_object_is_given_and_it_is_not_english() { + let yaml = parse("type: say\ncontent:\n - lang: fr\n text: content1"); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!( + &[MessageContent::new("content1".into()).with_language("fr".into())], + message.content() + ); + } + + #[test] + fn should_error_if_multiple_contents_are_given_and_none_are_english() { + let yaml = parse( + "type: say\ncontent:\n - lang: de\n text: content1\n - lang: fr\n text: content2", + ); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_apply_substitutions_when_there_is_only_one_content_string() { + let yaml = parse("type: say\ncontent: con{0}tent1\nsubs:\n - sub1"); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!("consub1tent1", message.content()[0].text()); + } + + #[test] + fn should_apply_substitutions_to_all_content_strings() { + let yaml = parse( + "type: say\ncontent:\n - lang: en\n text: content1 {0}\n - lang: fr\n text: content2 {0}\nsubs:\n - sub", + ); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!("content1 sub", message.content()[0].text()); + assert_eq!("content2 sub", message.content()[1].text()); + } + + #[test] + fn should_error_if_the_message_has_more_substitutions_than_expected() { + let yaml = parse("{type: say, content: 'content1', subs: [sub1]}"); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_the_content_string_expects_more_substitutions_than_exist() { + let yaml = parse("{type: say, content: '{0} {1}', subs: [sub1]}"); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_ignore_substution_syntax_if_no_substitutions_exist() { + let yaml = parse("{type: say, content: 'content {0}'}"); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!("content {0}", message.content()[0].text()); + } + + #[test] + fn should_accept_percentage_placeholder_syntax() { + let yaml = parse( + "{type: say, content: 'content %1% %2% %3% %4% %5% %6% %7% %8% %9% %10% %11%', subs: [a, b, c, d, e, f, g, h, i, j, k]}", + ); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!("content a b c d e f g h i j k", message.content()[0].text()); + } + } + mod emit_yaml { use super::*; diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index 3317b98b..dde340f0 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -237,7 +237,7 @@ impl MetadataDocument { emitter.begin_array(); - for plugin in self.plugins() { + for plugin in self.plugins_iter() { if !plugin.has_name_only() { plugin.emit_yaml(&mut emitter); } @@ -269,7 +269,7 @@ impl MetadataDocument { &self.messages } - pub fn plugins(&self) -> impl Iterator { + pub fn plugins_iter(&self) -> impl Iterator { self.plugins.values().chain(self.regex_plugins.iter()) } @@ -477,9 +477,14 @@ fn indent_prelude(prelude: String, line_ending: &str) -> String { mod tests { use tempfile::tempdir; + use crate::metadata::File; + use super::*; - const METADATA_LIST_YAML: &str = r#"bash_tags: + mod metadata_document { + use super::*; + + const METADATA_LIST_YAML: &str = r#"bash_tags: - 'C.Climate' - 'Relev' @@ -520,10 +525,9 @@ plugins: util: utility "#; - #[test] - fn load_should_resolve_aliases() { - let tmp_dir = tempdir().unwrap(); - let yaml = r#" + #[test] + fn load_from_str_should_resolve_aliases() { + let yaml = r#" prelude: - &anchor type: say @@ -533,17 +537,13 @@ plugins: - *anchor "#; - let path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&path, yaml).unwrap(); + let mut metadata_list = MetadataDocument::default(); + metadata_list.load_from_str(yaml).unwrap(); + } - let mut metadata_list = MetadataDocument::default(); - metadata_list.load(&path).unwrap(); - } - - #[test] - fn load_should_resolve_merge_keys() { - let tmp_dir = tempdir().unwrap(); - let yaml = r#" + #[test] + fn load_from_str_should_resolve_merge_keys() { + let yaml = r#" prelude: - &anchor type: say @@ -554,80 +554,527 @@ plugins: condition: file("test.esp") "#; - let path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&path, yaml).unwrap(); + let mut metadata_list = MetadataDocument::default(); + metadata_list.load_from_str(yaml).unwrap(); + } - let mut metadata_list = MetadataDocument::default(); - metadata_list.load(&path).unwrap(); - } + #[test] + fn load_from_str_should_error_if_a_plugin_has_two_exact_entries() { + let yaml = r#" +plugins: + - name: 'Blank.esm' + msg: + - type: warn + content: 'This is a warning.' - #[test] - fn load_should_deserialise_masterlist() { - let tmp_dir = tempdir().unwrap(); + - name: 'Blank.esm' + msg: + - type: error + content: 'This plugin entry will cause a failure, as it is not the first exact entry.' + "#; - let path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&path, METADATA_LIST_YAML).unwrap(); + let mut metadata_list = MetadataDocument::default(); + assert!(metadata_list.load_from_str(yaml).is_err()); + } - let mut metadata_list = MetadataDocument::default(); - metadata_list.load(&path).unwrap(); - } + #[test] + fn load_should_deserialise_masterlist() { + let tmp_dir = tempdir().unwrap(); - #[test] - fn load_with_prelude_should_merge_docs_with_crlf_line_endings() { - let tmp_dir = tempdir().unwrap(); + let path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&path, METADATA_LIST_YAML).unwrap(); - let masterlist_path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&masterlist_path, "prelude:\r\n - &ref\r\n type: say\r\n content: Loaded from same file\r\nglobals:\r\n - *ref\r\n").unwrap(); + let mut metadata_list = MetadataDocument::default(); + metadata_list.load(&path).unwrap(); - let prelude_path = tmp_dir.path().join("prelude.yaml"); - std::fs::write( - &prelude_path, - "common:\r\n - &ref\r\n type: say\r\n content: Loaded from prelude\r\n", - ) - .unwrap(); + let plugin_names: Vec<_> = metadata_list + .plugins_iter() + .map(PluginMetadata::name) + .collect(); + assert!(plugin_names.contains(&"Blank.esm")); + assert!(plugin_names.contains(&"Blank.esp")); + assert!(plugin_names.contains(&"Blank.+\\.esp")); + assert!(plugin_names.contains(&"Blank.+(Different)?.*\\.esp")); - let mut metadata_list = MetadataDocument::default(); - metadata_list - .load_with_prelude(&masterlist_path, &prelude_path) + assert_eq!(&["C.Climate", "Relev"], metadata_list.bash_tags()); + + let groups = metadata_list.groups(); + assert_eq!(3, groups.len()); + + assert_eq!("default", groups[0].name()); + assert!(groups[0].after_groups().is_empty()); + + assert_eq!("group1", groups[1].name()); + assert_eq!(&["group2"], groups[1].after_groups()); + + assert_eq!("group2", groups[2].name()); + assert_eq!(&["default"], groups[2].after_groups()); + } + + #[test] + fn load_should_error_if_an_invalid_metadata_file_is_given() { + let tmp_dir = tempdir().unwrap(); + let path = tmp_dir.path().join("masterlist.yaml"); + let yaml = r#" + - 'C.Climate' + - 'Relev' + +globals: + - type: say + content: 'A global message.' + +plugins: + - name: 'Blank.+\.esp' + after: + - 'Blank.esm' + "#; + + std::fs::write(&path, yaml).unwrap(); + + let mut metadata_list = MetadataDocument::default(); + assert!(metadata_list.load(&path).is_err()); + } + + #[test] + fn load_should_error_if_the_given_path_does_not_exist() { + let mut metadata_list = MetadataDocument::default(); + assert!(metadata_list.load(Path::new("missing")).is_err()); + } + + #[test] + fn load_with_prelude_should_merge_docs_with_crlf_line_endings() { + let tmp_dir = tempdir().unwrap(); + + let masterlist_path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&masterlist_path, "prelude:\r\n - &ref\r\n type: say\r\n content: Loaded from same file\r\nglobals:\r\n - *ref\r\n").unwrap(); + + let prelude_path = tmp_dir.path().join("prelude.yaml"); + std::fs::write( + &prelude_path, + "common:\r\n - &ref\r\n type: say\r\n content: Loaded from prelude\r\n", + ) .unwrap(); - } - #[test] - fn load_with_prelude_should_merge_docs_with_lf_line_endings() { - let tmp_dir = tempdir().unwrap(); + let mut metadata_list = MetadataDocument::default(); + metadata_list + .load_with_prelude(&masterlist_path, &prelude_path) + .unwrap(); + } - let masterlist_path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&masterlist_path, "prelude:\n - &ref\n type: say\n content: Loaded from same file\nglobals:\n - *ref\n").unwrap(); + #[test] + fn load_with_prelude_should_merge_docs_with_lf_line_endings() { + let tmp_dir = tempdir().unwrap(); - let prelude_path = tmp_dir.path().join("prelude.yaml"); - std::fs::write( - &prelude_path, - "common:\n - &ref\n type: say\n content: Loaded from prelude\n", - ) - .unwrap(); + let masterlist_path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&masterlist_path, "prelude:\n - &ref\n type: say\n content: Loaded from same file\nglobals:\n - *ref\n").unwrap(); - let mut metadata_list = MetadataDocument::default(); - metadata_list - .load_with_prelude(&masterlist_path, &prelude_path) + let prelude_path = tmp_dir.path().join("prelude.yaml"); + std::fs::write( + &prelude_path, + "common:\n - &ref\n type: say\n content: Loaded from prelude\n", + ) .unwrap(); + + let mut metadata_list = MetadataDocument::default(); + metadata_list + .load_with_prelude(&masterlist_path, &prelude_path) + .unwrap(); + } + + #[test] + fn load_with_prelude_should_error_if_the_given_masterlist_path_does_not_exist() { + let tmp_dir = tempdir().unwrap(); + + let prelude_path = tmp_dir.path().join("prelude.yaml"); + std::fs::write( + &prelude_path, + "common:\n - &ref\n type: say\n content: Loaded from prelude\n", + ) + .unwrap(); + + let mut metadata_list = MetadataDocument::default(); + assert!( + metadata_list + .load_with_prelude(Path::new("missing"), &prelude_path) + .is_err() + ); + } + + #[test] + fn load_with_prelude_should_error_if_the_given_prelude_path_does_not_exist() { + let tmp_dir = tempdir().unwrap(); + + let masterlist_path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&masterlist_path, "prelude:\n - &ref\n type: say\n content: Loaded from same file\nglobals:\n - *ref\n").unwrap(); + + let mut metadata_list = MetadataDocument::default(); + assert!( + metadata_list + .load_with_prelude(&masterlist_path, Path::new("missing")) + .is_err() + ); + } + + #[test] + fn save_should_write_the_loaded_metadata() { + let tmp_dir = tempdir().unwrap(); + + let path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&path, METADATA_LIST_YAML).unwrap(); + + let mut metadata = MetadataDocument::default(); + metadata.load(&path).unwrap(); + + let other_path = tmp_dir.path().join("other.yaml"); + metadata.save(&other_path).unwrap(); + + let mut other_metadata = MetadataDocument::default(); + other_metadata.load(&other_path).unwrap(); + + assert_eq!(metadata, other_metadata); + } + + #[test] + fn clear_should_clear_all_loaded_data() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + assert!(!metadata.messages().is_empty()); + assert!(metadata.plugins_iter().next().is_some()); + assert!(!metadata.bash_tags().is_empty()); + + metadata.clear(); + + assert!(metadata.messages().is_empty()); + assert!(metadata.plugins_iter().next().is_none()); + assert!(metadata.bash_tags().is_empty()); + } + + #[test] + fn set_groups_should_replace_existing_groups() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + metadata.set_groups(vec![Group::new("group4".into())]); + + let groups = metadata.groups(); + + assert_eq!("default", groups[0].name()); + assert!(groups[0].after_groups().is_empty()); + + assert_eq!("group4", groups[1].name()); + assert!(groups[1].after_groups().is_empty()); + } + + #[test] + fn find_plugin_should_return_none_if_the_given_plugin_has_no_metadata() { + let metadata = MetadataDocument::default(); + assert!(metadata.find_plugin("Blank.esp").unwrap().is_none()); + } + + #[test] + fn find_plugin_should_return_the_metadata_object_if_one_exists() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + let name = "Blank - Different.esp"; + let plugin = metadata.find_plugin(name).unwrap().unwrap(); + + assert_eq!(name, plugin.name()); + assert_eq!(&[File::new("Blank.esm".into())], plugin.load_after_files()); + assert_eq!(&[File::new("Blank.esp".into())], plugin.incompatibilities()); + } + + #[test] + fn add_plugin_should_store_specific_plugin_metadata() { + let mut metadata = MetadataDocument::default(); + + let name = "Blank.esp"; + let mut plugin = PluginMetadata::new(name).unwrap(); + plugin.set_group("group1".into()); + metadata.set_plugin_metadata(plugin); + + let plugin = metadata.find_plugin(name).unwrap().unwrap(); + + assert_eq!(name, plugin.name()); + assert_eq!("group1", plugin.group().unwrap()); + } + + #[test] + fn add_plugin_should_store_given_regex_plugin_metadata() { + let mut metadata = MetadataDocument::default(); + + let mut plugin = PluginMetadata::new(".+Dependent\\.esp").unwrap(); + plugin.set_group("group1".into()); + metadata.set_plugin_metadata(plugin); + + let name = "Blank - Plugin Dependent.esp"; + let plugin = metadata.find_plugin(name).unwrap().unwrap(); + + assert_eq!(name, plugin.name()); + assert_eq!("group1", plugin.group().unwrap()); + } + + #[test] + fn remove_plugin_metadata_should_remove_the_given_plugin_specific_metadata() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + let name = "Blank.esp"; + assert!(metadata.find_plugin(name).unwrap().is_some()); + + metadata.remove_plugin_metadata(name); + + assert!(metadata.find_plugin(name).unwrap().is_none()); + } + + #[test] + fn remove_plugin_metadata_should_not_remove_matching_regex_plugin_metadata() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + let name = "Blank.+\\.esp"; + assert!(metadata.find_plugin(name).unwrap().is_some()); + + metadata.remove_plugin_metadata(name); + + assert!(metadata.find_plugin(name).unwrap().is_some()); + + metadata.remove_plugin_metadata("Blank - Different.esp"); + + assert!(metadata.find_plugin(name).unwrap().is_some()); + } } - #[test] - fn save_should_write_the_loaded_metadata() { - let tmp_dir = tempdir().unwrap(); + mod replace_prelude { + use super::*; - let path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&path, METADATA_LIST_YAML).unwrap(); + #[test] + fn should_return_an_empty_string_if_given_empty_strings() { + let result = replace_prelude(String::new(), String::new()); - let mut metadata = MetadataDocument::default(); - metadata.load(&path).unwrap(); + assert!(result.is_empty()); + } - let other_path = tmp_dir.path().join("other.yaml"); - metadata.save(&other_path).unwrap(); + #[test] + fn should_not_change_a_masterlist_with_no_prelude() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "plugins: + - name: a.esp +"; - let mut other_metadata = MetadataDocument::default(); - other_metadata.load(&other_path).unwrap(); + let result = replace_prelude(masterlist.into(), prelude.into()); - assert_eq!(metadata, other_metadata); + assert_eq!(masterlist, result); + } + + #[test] + fn should_not_change_a_flow_style_masterlist() { + let prelude = "globals: [{type: note, content: A message.}]"; + let masterlist = "{prelude: {}, plugins: [{name: a.esp}]}"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + assert_eq!(masterlist, result); + } + + #[test] + fn should_replace_a_prelude_at_the_start_of_the_masterlist() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "prelude: + a: b + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = "prelude: + globals: + - type: note + content: A message. + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_change_a_masterlist_that_ends_with_a_prelude() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "plugins: + - name: a.esp +prelude: + a: b + +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = "plugins: + - name: a.esp +prelude: + globals: + - type: note + content: A message. +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_replace_only_the_prelude_in_the_masterlist() { + let prelude = " + +globals: + - type: note + content: A message. + +"; + let masterlist = " +common: + key: value +prelude: + a: b +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = " +common: + key: value +prelude: + + + globals: + - type: note + content: A message. + + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_succeed_given_block_style_prelude_and_masterlist() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "prelude: + a: b + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = "prelude: + globals: + - type: note + content: A message. + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_succeed_given_a_flow_style_prelude_and_a_block_style_masterlist() { + let prelude = "globals: [{type: note, content: A message.}]"; + let masterlist = "prelude: + a: b + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = "prelude: + globals: [{type: note, content: A message.}] +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_not_stop_at_comments() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "prelude: + a: b +# Comment line + c: d + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = "prelude: + globals: + - type: note + content: A message. + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_not_stop_at_a_blank_line() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "prelude: + a: b + + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = "prelude: + globals: + - type: note + content: A message. + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } } } diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index a0245cd0..d430da2c 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -26,3 +26,11 @@ fn emit(metadata: &T) -> String { emitter.into_string() } + +#[cfg(test)] +fn parse(yaml: &str) -> saphyr::MarkedYaml { + saphyr::MarkedYaml::load_from_str(yaml) + .unwrap() + .pop() + .unwrap() +} diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index caae3508..db990fc2 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -190,6 +190,108 @@ impl EmitYaml for PluginCleaningData { mod tests { use super::*; + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_scalar() { + let yaml = parse("0x12345678"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_crc_is_missing() { + let yaml = parse("{util: cleaner}"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_util_is_missing() { + let yaml = parse("{crc: 0x12345678}"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = + parse("{crc: 0x12345678, util: cleaner, detail: info, itm: 2, udr: 10, nav: 30}"); + + let data = PluginCleaningData::try_from(&yaml).unwrap(); + + assert_eq!(0x12345678, data.crc()); + assert_eq!("cleaner", data.cleaning_utility()); + assert_eq!(&[MessageContent::new("info".into())], data.detail()); + assert_eq!(2, data.itm_count()); + assert_eq!(10, data.deleted_reference_count()); + assert_eq!(30, data.deleted_navmesh_count()); + } + + #[test] + fn should_leave_optional_fields_at_defaults_if_not_present() { + let yaml = parse("{crc: 0x12345678, util: cleaner}"); + + let data = PluginCleaningData::try_from(&yaml).unwrap(); + + assert_eq!(0x12345678, data.crc()); + assert_eq!("cleaner", data.cleaning_utility()); + assert!(data.detail().is_empty()); + assert_eq!(0, data.itm_count()); + assert_eq!(0, data.deleted_reference_count()); + assert_eq!(0, data.deleted_navmesh_count()); + } + + #[test] + fn should_read_all_listed_detail_message_contents() { + let yaml = parse( + "{crc: 0x12345678, util: cleaner, detail: [{text: english, lang: en}, {text: french, lang: fr}]}", + ); + + let data = PluginCleaningData::try_from(&yaml).unwrap(); + + assert_eq!( + &[ + MessageContent::new("english".into()), + MessageContent::new("french".into()).with_language("fr".into()) + ], + data.detail() + ); + } + + #[test] + fn should_not_error_if_one_detail_is_given_and_it_is_not_english() { + let yaml = + parse("crc: 0x12345678\nutil: cleaner\ndetail:\n - lang: fr\n text: content1"); + + let data = PluginCleaningData::try_from(&yaml).unwrap(); + + assert_eq!( + &[MessageContent::new("content1".into()).with_language("fr".into())], + data.detail() + ); + } + + #[test] + fn should_error_if_multiple_details_are_given_and_none_are_english() { + let yaml = parse( + "crc: 0x12345678\nutil: cleaner\ndetail:\n - lang: de\n text: content1\n - lang: fr\n text: content2", + ); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + } + } + mod emit_yaml { use crate::metadata::emit; diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index f599eb23..84ff20d0 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -419,8 +419,380 @@ impl EmitYaml for PluginMetadata { #[cfg(test)] mod tests { + use crate::{ + metadata::{MessageType, TagSuggestion}, + tests::{BLANK_DIFFERENT_ESM, BLANK_DIFFERENT_ESP, BLANK_ESM, BLANK_ESP}, + }; + use super::*; + mod name_matches { + use super::*; + + #[test] + fn should_use_case_insensitive_comparison_for_non_regex_names() { + let plugin = PluginMetadata::new("BLANK.ESM").unwrap(); + + assert!(plugin.name_matches("blank.esm")); + assert!(!plugin.name_matches("other.esm")); + } + + #[test] + fn should_treat_given_plugin_name_as_literal() { + let plugin = PluginMetadata::new("Blank.esm").unwrap(); + + assert!(!plugin.name_matches(".+")); + } + + #[test] + fn should_use_case_insensitive_regex_matching_for_a_regex_name() { + let plugin = PluginMetadata::new("Blank.ES(m|p)").unwrap(); + + assert!(plugin.name_matches("blank.esm")); + } + } + + mod merge_metadata { + use super::*; + + #[test] + fn should_not_change_name() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let plugin2 = PluginMetadata::new(BLANK_DIFFERENT_ESM).unwrap(); + + plugin1.merge_metadata(&plugin2); + + assert_eq!(BLANK_ESM, plugin1.name()); + } + + #[test] + fn should_not_use_other_group_if_current_group_is_set() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin1.set_group("group1".into()); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin2.set_group("group2".into()); + + plugin1.merge_metadata(&plugin2); + + assert_eq!("group1", plugin1.group().unwrap()); + + plugin2.unset_group(); + + plugin1.merge_metadata(&plugin2); + + assert_eq!("group1", plugin1.group().unwrap()); + } + + #[test] + fn should_use_other_group_if_current_group_is_none() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin2.set_group("group2".into()); + + plugin1.merge_metadata(&plugin2); + + assert_eq!("group2", plugin1.group().unwrap()); + } + + #[test] + fn should_merge_load_after_files() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let file1 = File::new(BLANK_DIFFERENT_ESM.into()); + let file2 = File::new(BLANK_ESP.into()); + let file3 = File::new(BLANK_DIFFERENT_ESP.into()); + plugin1.set_load_after_files(vec![file1.clone(), file2.clone()]); + plugin2.set_load_after_files(vec![file1.clone(), file3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[file1.clone(), file2.clone(), file3.clone()], + plugin1.load_after_files() + ); + } + + #[test] + fn should_merge_requirements() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let file1 = File::new(BLANK_DIFFERENT_ESM.into()); + let file2 = File::new(BLANK_ESP.into()); + let file3 = File::new(BLANK_DIFFERENT_ESP.into()); + plugin1.set_requirements(vec![file1.clone(), file2.clone()]); + plugin2.set_requirements(vec![file1.clone(), file3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[file1.clone(), file2.clone(), file3.clone()], + plugin1.requirements() + ); + } + + #[test] + fn should_merge_incompatibilities() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let file1 = File::new(BLANK_DIFFERENT_ESM.into()); + let file2 = File::new(BLANK_ESP.into()); + let file3 = File::new(BLANK_DIFFERENT_ESP.into()); + plugin1.set_incompatibilities(vec![file1.clone(), file2.clone()]); + plugin2.set_incompatibilities(vec![file1.clone(), file3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[file1.clone(), file2.clone(), file3.clone()], + plugin1.incompatibilities() + ); + } + + #[test] + fn should_merge_messages() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let message1 = Message::new(MessageType::Say, "content1".into()); + let message2 = Message::new(MessageType::Say, "content2".into()); + let message3 = Message::new(MessageType::Say, "content3".into()); + plugin1.set_messages(vec![message1.clone(), message2.clone()]); + plugin2.set_messages(vec![message1.clone(), message3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[ + message1.clone(), + message2.clone(), + message1.clone(), + message3.clone() + ], + plugin1.messages() + ); + } + + #[test] + fn should_merge_tags() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let tag1 = Tag::new("Relev".into(), TagSuggestion::Addition); + let tag2 = Tag::new("Delev".into(), TagSuggestion::Addition); + let tag3 = Tag::new("Relev".into(), TagSuggestion::Removal); + plugin1.set_tags(vec![tag1.clone(), tag2.clone()]); + plugin2.set_tags(vec![tag1.clone(), tag3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!(&[tag1.clone(), tag2.clone(), tag3.clone()], plugin1.tags()); + } + + #[test] + fn should_merge_dirty_info() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let data1 = PluginCleaningData::new(0x12345678, "util1".into()); + let data2 = PluginCleaningData::new(0xDEADBEEF, "util2".into()); + let data3 = PluginCleaningData::new(0xFEEDCAFE, "util3".into()); + plugin1.set_dirty_info(vec![data1.clone(), data2.clone()]); + plugin2.set_dirty_info(vec![data1.clone(), data3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[data1.clone(), data2.clone(), data3.clone()], + plugin1.dirty_info() + ); + } + + #[test] + fn should_merge_clean_info() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let data1 = PluginCleaningData::new(0x12345678, "util1".into()); + let data2 = PluginCleaningData::new(0xDEADBEEF, "util2".into()); + let data3 = PluginCleaningData::new(0xFEEDCAFE, "util3".into()); + plugin1.set_clean_info(vec![data1.clone(), data2.clone()]); + plugin2.set_clean_info(vec![data1.clone(), data3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[data1.clone(), data2.clone(), data3.clone()], + plugin1.clean_info() + ); + } + + #[test] + fn should_merge_locations() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let location1 = Location::new("url1".into()); + let location2 = Location::new("url2".into()); + let location3 = Location::new("url3".into()); + plugin1.set_locations(vec![location1.clone(), location2.clone()]); + plugin2.set_locations(vec![location1.clone(), location3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[location1.clone(), location2.clone(), location3.clone()], + plugin1.locations() + ); + } + } + + #[test] + fn unset_group_should_set_group_to_none() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + + plugin.set_group("group1".into()); + plugin.unset_group(); + + assert!(plugin.group().is_none()); + } + + mod has_name_only { + use super::*; + + #[test] + fn should_be_true_if_only_a_name_is_set() { + assert!(PluginMetadata::new(BLANK_ESM).unwrap().has_name_only()); + } + + #[test] + fn should_be_false_if_a_group_is_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_group("group1".into()); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_load_after_files_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_requirements_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_requirements(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_incompatibilities_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_incompatibilities(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_messages_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_messages(vec![Message::new(MessageType::Say, "content1".into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_tags_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_tags(vec![Tag::new("Relev".into(), TagSuggestion::Addition)]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_dirty_info_is_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_dirty_info(vec![PluginCleaningData::new(0x12345678, "util1".into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_clean_info_is_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_clean_info(vec![PluginCleaningData::new(0x12345678, "util1".into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_locations_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_locations(vec![Location::new("url1".into())]); + + assert!(!plugin.has_name_only()); + } + } + + mod is_regex_plugin { + use super::*; + + #[test] + fn should_be_false_for_an_empty_name() { + let plugin = PluginMetadata::new("").unwrap(); + + assert!(!plugin.is_regex_plugin()); + } + + #[test] + fn should_be_false_for_an_exact_plugin_name() { + let plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + + assert!(!plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_a_colon() { + let plugin = PluginMetadata::new("Blank:.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_a_backslash() { + let plugin = PluginMetadata::new("Blank\\.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_an_asterisk() { + let plugin = PluginMetadata::new("Blank*.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_a_question_mark() { + let plugin = PluginMetadata::new("Blank?.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_a_vertical_bar() { + let plugin = PluginMetadata::new("Blank|.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + } + mod as_yaml { use super::*; @@ -441,6 +813,119 @@ mod tests { } } + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_scalar() { + let yaml = parse("name1"); + + assert!(PluginMetadata::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(PluginMetadata::try_from(&yaml).is_err()); + } + + #[test] + fn should_store_all_given_data() { + let yaml = parse( + " + name: 'Blank.esp' + after: + - 'Blank.esm' + req: + - 'Blank - Different.esm' + inc: + - 'Blank - Different.esp' + msg: + - type: say + content: 'content' + tag: + - Relev + dirty: + - crc: 0x5 + util: 'utility' + clean: + - crc: 0x6 + util: 'utility' + url: + - 'https://www.example.com'", + ); + + let plugin = PluginMetadata::try_from(&yaml).unwrap(); + + assert_eq!(BLANK_ESP, plugin.name()); + assert_eq!(&[File::new(BLANK_ESM.into())], plugin.load_after_files()); + assert_eq!( + &[File::new(BLANK_DIFFERENT_ESM.into())], + plugin.requirements() + ); + assert_eq!( + &[File::new(BLANK_DIFFERENT_ESP.into())], + plugin.incompatibilities() + ); + assert_eq!( + &[Message::new(MessageType::Say, "content".into())], + plugin.messages() + ); + assert_eq!( + &[Tag::new("Relev".into(), TagSuggestion::Addition)], + plugin.tags() + ); + assert_eq!( + &[PluginCleaningData::new(0x5, "utility".into())], + plugin.dirty_info() + ); + assert_eq!( + &[PluginCleaningData::new(0x6, "utility".into())], + plugin.clean_info() + ); + assert_eq!( + &[Location::new("https://www.example.com".into())], + plugin.locations() + ); + } + + #[test] + fn should_not_error_if_regex_metadata_contains_dirty_or_clean_info() { + let yaml = parse( + " + name: 'Blank\\.esp' + dirty: + - crc: 0x5 + util: 'utility' + clean: + - crc: 0x6 + util: 'utility'", + ); + + let plugin = PluginMetadata::try_from(&yaml).unwrap(); + + assert_eq!("Blank\\.esp", plugin.name()); + assert_eq!( + &[PluginCleaningData::new(0x5, "utility".into())], + plugin.dirty_info() + ); + assert_eq!( + &[PluginCleaningData::new(0x6, "utility".into())], + plugin.clean_info() + ); + } + + #[test] + fn should_error_if_regex_name_is_invalid() { + let yaml = parse("{name: 'RagnvaldBook(Farengar(+Ragnvald)?)?\\.esp'}"); + + assert!(PluginMetadata::try_from(&yaml).is_err()); + } + } + mod emit_yaml { use super::*; use crate::metadata::{MessageType, TagSuggestion, emit}; diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index a689337d..e6fcbf0e 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -131,6 +131,77 @@ impl EmitYaml for Tag { mod tests { use super::*; + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_only_set_name_and_suggestion_if_decoding_from_scalar() { + let yaml = parse("Relev"); + + let tag = Tag::try_from(&yaml).unwrap(); + + assert_eq!("Relev", tag.name()); + assert!(tag.is_addition()); + assert!(tag.condition().is_none()); + } + + #[test] + fn should_only_set_name_and_suggestion_if_decoding_from_scalar_with_leading_hyphen() { + let yaml = parse("-Relev"); + + let tag = Tag::try_from(&yaml).unwrap(); + + assert_eq!("Relev", tag.name()); + assert!(!tag.is_addition()); + assert!(tag.condition().is_none()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(Tag::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_missing() { + let yaml = parse("{condition: 'file(\"Foo.esp\")'}"); + + assert!(Tag::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_an_invalid_condition() { + let yaml = parse("{name: Relev, condition: invalid}"); + + assert!(Tag::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_fields() { + let yaml = parse("{name: Relev, condition: 'file(\"Foo.esp\")'}"); + + let tag = Tag::try_from(&yaml).unwrap(); + + assert_eq!("Relev", tag.name()); + assert!(tag.is_addition()); + assert_eq!("file(\"Foo.esp\")", tag.condition().unwrap()); + } + + #[test] + fn should_leave_optional_fields_empty_if_not_present() { + let yaml = parse("{name: Relev}"); + + let tag = Tag::try_from(&yaml).unwrap(); + + assert_eq!("Relev", tag.name()); + assert!(tag.is_addition()); + assert!(tag.condition().is_none()); + } + } + mod emit_yaml { use crate::metadata::emit; diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 4ecfbc4d..c81752e0 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -14,7 +14,7 @@ use fancy_regex::Regex; use crate::{ GameType, - archive::{assets_in_archives, find_associated_archives}, + archive::{assets_in_archives, do_assets_overlap, find_associated_archives}, game::GameCache, logging, metadata::plugin_metadata::trim_dot_ghost, @@ -272,29 +272,7 @@ impl Plugin { } pub(crate) fn do_assets_overlap(&self, plugin: &Plugin) -> bool { - let mut assets_iter = self.archive_assets.iter(); - let mut other_assets_iter = plugin.archive_assets.iter(); - - let mut assets = assets_iter.next(); - let mut other_assets = other_assets_iter.next(); - while let (Some((folder, files)), Some((other_folder, other_files))) = - (assets, other_assets) - { - if folder < other_folder { - assets = assets_iter.next(); - } else if folder > other_folder { - other_assets = other_assets_iter.next(); - } else if files.intersection(other_files).next().is_some() { - return true; - } else { - // The folder hashes are equal but they don't contain any of the same - // file hashes, move on to the next folder. It doesn't matter which - // iterator gets incremented. - assets = assets_iter.next(); - } - } - - false + do_assets_overlap(&self.archive_assets, &plugin.archive_assets) } pub(crate) fn resolve_record_ids( @@ -480,3 +458,895 @@ fn extract_version(description: &str) -> Result, Box &'static str { + if game_type == GameType::Starfield { + BLANK_FULL_ESM + } else { + BLANK_ESM + } + } + + fn blank_master_dependent_esm(game_type: GameType) -> &'static str { + if game_type == GameType::Starfield { + "Blank - Override.full.esm" + } else { + BLANK_MASTER_DEPENDENT_ESM + } + } + + #[apply(all_game_types)] + fn new_should_trim_ghost_extension_unless_game_is_openmw(game_type: GameType) { + let tmp_dir = tempdir().unwrap(); + let source_path = source_plugins_path(game_type).join(BLANK_ESP); + let ghosted_path = tmp_dir.path().join(BLANK_ESP.to_owned() + ".ghost"); + + std::fs::copy(source_path, &ghosted_path).unwrap(); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &ghosted_path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + if game_type == GameType::OpenMW { + assert_eq!(BLANK_ESP.to_owned() + ".ghost", plugin.name()); + } else { + assert_eq!(BLANK_ESP, plugin.name()); + } + } + + #[test] + fn new_should_handle_non_ascii_filenames_correctly() { + let tmp_dir = tempdir().unwrap(); + let source_path = source_plugins_path(GameType::TES4).join(BLANK_ESM); + let path = tmp_dir.path().join(NON_ASCII_ESM); + + std::fs::copy(source_path, &path).unwrap(); + } + + #[apply(all_game_types)] + fn new_with_header_only_scope_should_read_header_data_only(game_type: GameType) { + let plugin_name = blank_master_dependent_esm(game_type); + let path = source_plugins_path(game_type).join(plugin_name); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + let expected_masters = vec![blank_esm(game_type)]; + + assert_eq!(plugin_name, plugin.name()); + assert_eq!(expected_masters, plugin.masters().unwrap()); + assert_eq!(game_type != GameType::OpenMW, plugin.is_master()); + assert!(!plugin.is_empty()); + assert!(plugin.version().is_none()); + + match game_type { + GameType::TES3 | GameType::OpenMW => { + assert_eq!(1.2, plugin.header_version().unwrap()) + } + GameType::TES4 => assert_eq!(0.8, plugin.header_version().unwrap()), + GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), + _ => assert_eq!(0.94, plugin.header_version().unwrap()), + } + + assert!(plugin.crc().is_none()); + assert!(!plugin.do_assets_overlap(&plugin)); + assert_eq!(0, plugin.asset_count()); + assert!(!plugin.do_records_overlap(&plugin).unwrap()); + assert_eq!(0, plugin.override_record_count().unwrap()); + } + + #[apply(all_game_types)] + fn new_with_header_only_scope_should_read_version_from_header_description( + game_type: GameType, + ) { + let path = source_plugins_path(game_type).join(blank_esm(game_type)); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert_eq!("5.0", plugin.version().unwrap()); + } + + #[apply(all_game_types)] + fn new_with_header_only_scope_should_not_read_assets(game_type: GameType) { + let path = source_plugins_path(game_type).join(blank_esm(game_type)); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!plugin.do_assets_overlap(&plugin)); + assert_eq!(0, plugin.asset_count()); + } + + #[apply(all_game_types)] + fn new_with_whole_plugin_scope_should_read_records(game_type: GameType) { + let plugin_name = blank_master_dependent_esm(game_type); + let path = source_plugins_path(game_type).join(plugin_name); + + let mut plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::WholePlugin, + ) + .unwrap(); + + let expected_masters = vec![blank_esm(game_type)]; + + assert_eq!(plugin_name, plugin.name()); + assert_eq!(expected_masters, plugin.masters().unwrap()); + assert_eq!(game_type != GameType::OpenMW, plugin.is_master()); + assert!(!plugin.is_empty()); + assert!(plugin.version().is_none()); + + match game_type { + GameType::TES3 | GameType::OpenMW => { + assert_eq!(1.2, plugin.header_version().unwrap()) + } + GameType::TES4 => assert_eq!(0.8, plugin.header_version().unwrap()), + GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), + _ => assert_eq!(0.94, plugin.header_version().unwrap()), + } + + let expected_crc = match game_type { + GameType::TES3 | GameType::OpenMW => 3317676987, + GameType::Starfield => 1422425298, + GameType::TES4 => 3759349588, + _ => 3000242590, + }; + + assert_eq!(expected_crc, plugin.crc().unwrap()); + assert!(!plugin.do_assets_overlap(&plugin)); + assert_eq!(0, plugin.asset_count()); + + if matches!(game_type, GameType::TES3 | GameType::OpenMW) { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + + assert_eq!(4, plugin.override_record_count().unwrap()); + } else if game_type == GameType::Starfield { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_FULL_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + + assert_eq!(1, plugin.override_record_count().unwrap()); + } else { + assert_eq!(4, plugin.override_record_count().unwrap()); + } + assert!(plugin.do_records_overlap(&plugin).unwrap()); + } + + #[apply(all_game_types)] + fn new_with_whole_plugin_scope_should_read_assets(game_type: GameType) { + let data_path = source_plugins_path(game_type); + let path = data_path.join(BLANK_ESP); + + let mut cache = GameCache::default(); + cache.set_archive_paths(vec![ + data_path.join("Blank.bsa"), + data_path.join("Blank - Main.ba2"), + ]); + + let plugin = Plugin::new(game_type, &cache, &path, LoadScope::WholePlugin).unwrap(); + + if matches!( + game_type, + GameType::TES3 | GameType::OpenMW | GameType::Starfield + ) { + // The Starfield test data doesn't include a BA2 file. + assert!(!plugin.loads_archive()); + assert_eq!(0, plugin.asset_count()); + assert!(!plugin.do_assets_overlap(&plugin)); + } else { + assert!(plugin.loads_archive()); + assert_eq!(1, plugin.asset_count()); + assert!(plugin.do_assets_overlap(&plugin)); + } + } + + #[apply(all_game_types)] + fn new_with_whole_plugin_scope_should_succeed_for_openmw_plugins(game_type: GameType) { + let tmp_dir = tempdir().unwrap(); + + let data_path = source_plugins_path(game_type); + let omwgame = tmp_dir.path().join("Blank.omwgame"); + let omwaddon = tmp_dir.path().join("Blank.omwaddon"); + let omwscripts = tmp_dir.path().join("Blank.omwscripts"); + + std::fs::copy(data_path.join(blank_esm(game_type)), &omwgame).unwrap(); + std::fs::copy(data_path.join(BLANK_ESP), &omwaddon).unwrap(); + let _ = File::create(&omwscripts).unwrap(); + + assert!( + Plugin::new( + game_type, + &GameCache::default(), + &omwgame, + LoadScope::WholePlugin + ) + .is_ok() + ); + assert!( + Plugin::new( + game_type, + &GameCache::default(), + &omwaddon, + LoadScope::WholePlugin + ) + .is_ok() + ); + + assert_eq!( + game_type == GameType::OpenMW, + Plugin::new( + game_type, + &GameCache::default(), + &omwscripts, + LoadScope::WholePlugin + ) + .is_ok() + ); + } + + #[test] + fn new_should_error_if_plugin_does_not_exist() { + let path = Path::new("missing.esp"); + assert!(!path.exists()); + + assert!( + Plugin::new( + GameType::TES4, + &GameCache::default(), + path, + LoadScope::HeaderOnly + ) + .is_err() + ); + } + + #[apply(all_game_types)] + fn is_master_should_be_false_for_a_non_master_plugin(game_type: GameType) { + let path = source_plugins_path(game_type).join(BLANK_ESP); + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!plugin.is_master()); + } + + #[apply(all_game_types)] + fn is_light_plugin_should_be_true_for_a_plugin_with_esl_extension_for_fo4_and_later( + game_type: GameType, + ) { + let tmp_dir = tempdir().unwrap(); + let data_path = source_plugins_path(game_type); + + let light_path = tmp_dir.path().join(BLANK_ESL); + std::fs::copy(data_path.join(BLANK_ESP), &light_path).unwrap(); + + let master = Plugin::new( + game_type, + &GameCache::default(), + &data_path.join(blank_esm(game_type)), + LoadScope::HeaderOnly, + ) + .unwrap(); + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &data_path.join(BLANK_ESP), + LoadScope::HeaderOnly, + ) + .unwrap(); + let light = Plugin::new( + game_type, + &GameCache::default(), + &light_path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!master.is_light_plugin()); + assert!(!plugin.is_light_plugin()); + + if matches!( + game_type, + GameType::FO4 + | GameType::FO4VR + | GameType::TES5SE + | GameType::TES5VR + | GameType::Starfield + ) { + assert!(light.is_light_plugin()); + } else { + assert!(!light.is_light_plugin()); + } + } + + #[apply(all_game_types)] + fn is_medium_plugin_should_be_true_for_a_medium_flagged_plugin_for_starfield( + game_type: GameType, + ) { + let tmp_dir = tempdir().unwrap(); + + let data_path = source_plugins_path(game_type); + let path = tmp_dir.path().join(BLANK_MEDIUM_ESM); + if game_type == GameType::Starfield { + std::fs::copy(data_path.join(BLANK_MEDIUM_ESM), &path).unwrap(); + } else { + std::fs::copy(data_path.join(BLANK_ESM), &path).unwrap(); + + let mut file = std::fs::File::options().write(true).open(&path).unwrap(); + file.seek(std::io::SeekFrom::Start(9)).unwrap(); + file.write_all(&[0x4]).unwrap(); + } + + let master = Plugin::new( + game_type, + &GameCache::default(), + &data_path.join(blank_esm(game_type)), + LoadScope::HeaderOnly, + ) + .unwrap(); + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!master.is_medium_plugin()); + assert_eq!(game_type == GameType::Starfield, plugin.is_medium_plugin()); + } + + #[apply(all_game_types)] + fn is_update_plugin_should_be_true_for_an_update_plugin_for_starfield(game_type: GameType) { + let tmp_dir = tempdir().unwrap(); + + let source_name = if game_type == GameType::Starfield { + BLANK_OVERRIDE_ESP + } else { + BLANK_MASTER_DEPENDENT_ESP + }; + let data_path = source_plugins_path(game_type); + let path = tmp_dir.path().join("Blank - Update.esp"); + std::fs::copy(data_path.join(source_name), &path).unwrap(); + + let mut file = std::fs::File::options().write(true).open(&path).unwrap(); + file.seek(std::io::SeekFrom::Start(9)).unwrap(); + file.write_all(&[0x2]).unwrap(); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_ESP), + LoadScope::HeaderOnly, + ) + .unwrap(); + let update = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!plugin.is_update_plugin()); + assert_eq!(game_type == GameType::Starfield, update.is_update_plugin()); + } + + #[apply(all_game_types)] + fn is_blueprint_plugin_should_be_true_for_a_blueprint_plugin_for_starfield( + game_type: GameType, + ) { + let blueprint_plugin_name = if game_type == GameType::Starfield { + BLANK_OVERRIDE_ESP + } else { + BLANK_MASTER_DEPENDENT_ESP + }; + let data_path = source_plugins_path(game_type); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_ESP), + LoadScope::HeaderOnly, + ) + .unwrap(); + let update = Plugin::new( + game_type, + &GameCache::default(), + &data_path.join(blueprint_plugin_name), + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!plugin.is_update_plugin()); + assert_eq!(game_type == GameType::Starfield, update.is_update_plugin()); + } + + #[apply(all_game_types)] + fn is_valid_as_light_plugin_should_be_true_only_for_a_skyrim_fallout4_or_starfield_plugin_with_new_formids_in_the_valid_range( + game_type: GameType, + ) { + let path = source_plugins_path(game_type).join(BLANK_ESP); + let mut plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::WholePlugin, + ) + .unwrap(); + + if game_type == GameType::Starfield { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_FULL_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + } + + let result = plugin.is_valid_as_light_plugin().unwrap(); + + if matches!( + game_type, + GameType::FO4 + | GameType::FO4VR + | GameType::TES5SE + | GameType::TES5VR + | GameType::Starfield + ) { + assert!(result); + } else { + assert!(!result); + } + } + + #[apply(all_game_types)] + fn is_valid_as_medium_plugin_should_be_true_only_for_a_starfield_plugin_with_new_formids_in_the_valid_range( + game_type: GameType, + ) { + let path = source_plugins_path(game_type).join(BLANK_ESP); + let mut plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::WholePlugin, + ) + .unwrap(); + + if game_type == GameType::Starfield { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_FULL_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + } + + let result = plugin.is_valid_as_medium_plugin().unwrap(); + + if game_type == GameType::Starfield { + assert!(result); + } else { + assert!(!result); + } + } + + #[apply(all_game_types)] + fn is_valid_as_update_plugin_should_be_true_only_for_a_starfield_plugin_with_no_new_records( + game_type: GameType, + ) { + let plugin_name = blank_master_dependent_esm(game_type); + let path = source_plugins_path(game_type).join(plugin_name); + let mut plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::WholePlugin, + ) + .unwrap(); + + if game_type == GameType::Starfield { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_FULL_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + } + + let result = plugin.is_valid_as_update_plugin().unwrap(); + + if game_type == GameType::Starfield { + assert!(result); + } else { + assert!(!result); + } + } + } + + mod has_plugin_file_extension { + use rstest_reuse::apply; + + use crate::tests::all_game_types; + + use super::*; + + #[apply(all_game_types)] + fn should_be_true_if_file_ends_in_dot_esp_or_dot_esm(game_type: GameType) { + assert!(has_plugin_file_extension(game_type, Path::new("file.esp"))); + assert!(has_plugin_file_extension(game_type, Path::new("file.esm"))); + assert!(!has_plugin_file_extension(game_type, Path::new("file.bsa"))); + } + + #[apply(all_game_types)] + fn should_be_true_if_file_ends_in_dot_esl_and_game_is_fo4_or_later(game_type: GameType) { + let result = has_plugin_file_extension(game_type, Path::new("file.esl")); + if matches!( + game_type, + GameType::FO4 + | GameType::TES5SE + | GameType::FO4VR + | GameType::TES5VR + | GameType::Starfield + ) { + assert!(result); + } else { + assert!(!result); + } + } + + #[apply(all_game_types)] + fn should_trim_ghost_extension_unless_game_is_openmw(game_type: GameType) { + if game_type == GameType::OpenMW { + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.esp.ghost") + )); + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.esm.ghost") + )); + } else { + assert!(has_plugin_file_extension( + game_type, + Path::new("file.esp.ghost") + )); + assert!(has_plugin_file_extension( + game_type, + Path::new("file.esm.ghost") + )); + } + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.bsa.ghost") + )); + } + + #[apply(all_game_types)] + fn should_recognise_openmw_plugin_extensions(game_type: GameType) { + if game_type == GameType::OpenMW { + assert!(has_plugin_file_extension( + game_type, + Path::new("file.omwgame") + )); + assert!(has_plugin_file_extension( + game_type, + Path::new("file.omwaddon") + )); + assert!(has_plugin_file_extension( + game_type, + Path::new("file.omwscripts") + )); + } else { + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.omwgame") + )); + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.omwaddon") + )); + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.omwscripts") + )); + } + } + } + + #[test] + fn extract_bash_tags_should_extract_tags_from_plugin_description_text() { + let text = "Unofficial Skyrim Special Edition Patch + +A comprehensive bugfixing mod for The Elder Scrolls V: Skyrim - Special Edition + +Version: 4.1.4 + +Requires Skyrim Special Edition 1.5.39 or greater. + +{{BASH:C.Climate,C.Encounter,C.ImageSpace,C.Light,C.Location,C.Music,C.Name,C.Owner,C.Water,Delev,Graphics,Invent,Names,Relev,Sound,Stats}}"; + + let tags = extract_bash_tags(text); + + assert_eq!( + vec![ + "C.Climate".to_string(), + "C.Encounter".into(), + "C.ImageSpace".into(), + "C.Light".into(), + "C.Location".into(), + "C.Music".into(), + "C.Name".into(), + "C.Owner".into(), + "C.Water".into(), + "Delev".into(), + "Graphics".into(), + "Invent".into(), + "Names".into(), + "Relev".into(), + "Sound".into(), + "Stats".into(), + ], + tags + ); + } + + mod extract_version { + use crate::plugin::extract_version; + + #[test] + fn should_extract_a_version_containing_a_single_digit() { + assert_eq!("5", extract_version("5").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_containing_multiple_digits() { + assert_eq!("10", extract_version("10").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_containing_multiple_numbers() { + assert_eq!( + "10.11.12.13", + extract_version("10.11.12.13").unwrap().unwrap() + ); + } + + #[test] + fn should_extract_a_semantic_version() { + assert_eq!( + "1.0.0-x.7.z.92", + extract_version("1.0.0-x.7.z.92+exp.sha.5114f85") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_extract_a_pseudosem_extended_version_stopping_at_the_first_space_separator() { + assert_eq!( + "01.0.0_alpha:1-2", + extract_version("01.0.0_alpha:1-2 3").unwrap().unwrap() + ); + } + + #[test] + fn should_extract_a_version_substring() { + assert_eq!("5.0", extract_version("v5.0").unwrap().unwrap()); + } + + #[test] + fn should_return_none_if_the_string_contains_no_version() { + assert!( + extract_version("The quick brown fox jumped over the lazy dog.") + .unwrap() + .is_none() + ); + } + + #[test] + fn should_extract_a_timestamp_with_forwardslash_date_separators() { + // Found in a Bashed Patch. Though the timestamp isn't useful to + // LOOT, it is semantically a version, and extracting it is far + // easier than trying to skip it and the number of records changed. + assert_eq!( + "10/09/2016 13:15:18", + extract_version("Updated: 10/09/2016 13:15:18\r\n\r\nRecords Changed: 43") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_not_extract_trailing_periods() { + // Found in . + assert_eq!("0.2", extract_version("Version 0.2.").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_following_text_and_a_version_colon_string() { + // Found in . + assert_eq!( + "3.0.0", + extract_version("Legendary Edition\r\n\r\nVersion: 3.0.0") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_ignore_numbers_containing_commas() { + // Found in . + assert_eq!( + "3.5.3", + extract_version("fixing over 2,300 bugs so far! Version: 3.5.3") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_extract_a_version_before_text() { + // Found in . + assert_eq!( + "2.1", + extract_version("Version: 2.1 The Unofficial Fallout 3 Patch") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_extract_a_version_with_a_preceding_v() { + // Found in . + assert_eq!( + "2.11", + extract_version("V2.11\r\n\r\n{{BASH:Invent}}") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_extract_a_version_preceded_by_colon_period_whitespace() { + // Found in . + assert_eq!("1.09", extract_version("Version:. 1.09").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_with_letters_immediately_after_numbers() { + // Found in . + assert_eq!("2.1.3b", extract_version("comprehensive bugfixing mod for The Elder Scrolls V: Skyrim\r\n\r\nVersion: 2.1.3b\r\n\r\n").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_with_period_and_no_preceding_identifier() { + // Found in . + assert_eq!("5.1", extract_version("SkyUI 5.1").unwrap().unwrap()); + } + + #[test] + fn should_not_extract_a_single_digit_in_a_sentence() { + // Found in . + assert!( + extract_version( + "Adds 8 variants of Triss Merigold's outfit from \"The Witcher 2\"" + ) + .unwrap() + .is_none() + ); + } + + #[test] + fn should_prefer_version_prefixed_numbers_over_versions_in_sentence() { + // Found in + assert_eq!("2.0.0", extract_version("Requires Skyrim patch 1.9.32.0.8 or greater.\nRequires Unofficial Skyrim Legendary Edition Patch 3.0.0 or greater.\nVersion 2.0.0").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_that_is_a_single_digit_preceded_by_v() { + // Found in + assert_eq!( + "8", + extract_version("Immersive Armors v8 Main Plugin") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_prefer_version_prefixed_numbers_over_v_prefixed_number() { + // Found in + assert_eq!("1.0", extract_version("Compatibility patch for AOS v2.5 and True Storms v1.5 (or later),\nPatch Version: 1.0").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_that_is_a_single_digit_after_version_colon_space() { + // Found in + assert_eq!( + "2", + extract_version("Version: 2 {{BASH:C.Water}}") + .unwrap() + .unwrap() + ); + } + } +} diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs index a4f9d7d5..373db7cc 100644 --- a/src/sorting/groups.rs +++ b/src/sorting/groups.rs @@ -267,3 +267,213 @@ pub fn get_default_group_node(graph: &GroupsGraph) -> Result { + assert_eq!("a", e.into_group_name()) + } + _ => panic!("Expected an undefined group error"), + } + } + + #[test] + fn should_error_if_masterlist_group_loads_after_user_group() { + let masterlist = &[Group::new("b".into()).with_after_groups(vec!["a".into()])]; + let userlist = &[Group::new("a".into())]; + + match build_groups_graph(masterlist, userlist) { + Err(BuildGroupsGraphError::UndefinedGroup(e)) => { + assert_eq!("a", e.into_group_name()) + } + _ => panic!("Expected an undefined group error"), + } + } + + #[test] + fn should_error_if_after_groups_are_cyclic() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + ]; + let userlist = &[ + Group::new("a".into()).with_after_groups(vec!["c".into()]), + Group::new("c".into()).with_after_groups(vec!["b".into()]), + ]; + + match build_groups_graph(masterlist, userlist) { + Err(BuildGroupsGraphError::CycleFound(e)) => { + let cycle = e.into_cycle(); + + assert_eq!( + &[ + Vertex::new("a".into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("b".into()).with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new("c".into()).with_out_edge_type(EdgeType::UserLoadAfter), + ], + cycle.as_slice() + ); + } + _ => panic!("Expected a cyclic interaction error"), + } + } + + #[test] + fn cyclic_interaction_error_should_only_include_groups_that_are_part_of_the_cycle() { + let masterlist = &[ + Group::new("a".into()).with_after_groups(vec!["b".into()]), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["b".into()]), + ]; + + match build_groups_graph(masterlist, &[]) { + Err(BuildGroupsGraphError::CycleFound(e)) => { + let cycle = e.into_cycle(); + + assert_eq!( + &[ + Vertex::new("a".into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("b".into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + ], + cycle.as_slice() + ); + } + _ => panic!("Expected a cyclic interaction error"), + } + } + } + + mod find_path { + use super::*; + + #[test] + fn should_error_if_the_from_group_does_not_exist() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + ]; + let graph = build_groups_graph(masterlist, &[]).unwrap(); + + assert!(find_path(&graph, "c", "a").is_err()); + } + + #[test] + fn should_error_if_the_to_group_does_not_exist() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + ]; + let graph = build_groups_graph(masterlist, &[]).unwrap(); + + assert!(find_path(&graph, "a", "c").is_err()); + } + + #[test] + fn should_return_an_empty_vec_if_there_is_no_path() { + let masterlist = &[Group::new("a".into()), Group::new("b".into())]; + let graph = build_groups_graph(masterlist, &[]).unwrap(); + + let path = find_path(&graph, "a", "b").unwrap(); + + assert!(path.is_empty()); + } + + #[test] + fn should_find_the_shortest_path_if_there_is_no_user_metadata() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["a".into()]), + Group::new("d".into()).with_after_groups(vec!["c".into()]), + Group::new("e".into()).with_after_groups(vec!["b".into(), "d".into()]), + ]; + let graph = build_groups_graph(masterlist, &[]).unwrap(); + + let path = find_path(&graph, "a", "e").unwrap(); + + assert_eq!( + &[ + Vertex::new("a".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("b".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("e".into()) + ], + path.as_slice() + ); + } + + #[test] + fn should_find_the_shortest_path_involving_user_metadata_if_there_no_user_metadata() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["a".into()]), + Group::new("e".into()).with_after_groups(vec!["b".into()]), + ]; + let userlist = &[ + Group::new("d".into()).with_after_groups(vec!["c".into()]), + Group::new("e".into()).with_after_groups(vec!["d".into()]), + ]; + let graph = build_groups_graph(masterlist, userlist).unwrap(); + + let path = find_path(&graph, "a", "e").unwrap(); + + assert_eq!( + &[ + Vertex::new("a".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("c".into()).with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new("d".into()).with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new("e".into()) + ], + path.as_slice() + ); + } + + #[test] + fn should_not_depend_on_the_after_group_definition_order() { + let masterlists = &[ + &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["a".into()]), + Group::new("d".into()).with_after_groups(vec!["b".into(), "c".into()]), + Group::new("e".into()).with_after_groups(vec!["d".into()]), + ], + &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["a".into()]), + Group::new("d".into()).with_after_groups(vec!["c".into(), "b".into()]), + Group::new("e".into()).with_after_groups(vec!["d".into()]), + ], + ]; + + for masterlist in masterlists { + let graph = build_groups_graph(*masterlist, &[]).unwrap(); + + let path = find_path(&graph, "a", "e").unwrap(); + assert_eq!( + &[ + Vertex::new("a".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("b".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("d".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("e".into()) + ], + path.as_slice() + ); + } + } + } +} diff --git a/src/sorting/mod.rs b/src/sorting/mod.rs index 682cb2fd..54b033dc 100644 --- a/src/sorting/mod.rs +++ b/src/sorting/mod.rs @@ -4,3 +4,78 @@ pub mod groups; pub mod plugins; mod validate; pub mod vertex; + +#[cfg(test)] +mod test { + use super::plugins::SortingPlugin; + use crate::error::PluginDataError; + + #[derive(Default)] + pub struct TestPlugin { + name: String, + masters: Vec, + pub is_master: bool, + pub is_blueprint_plugin: bool, + pub override_record_count: usize, + pub asset_count: usize, + overlapping_record_plugins: Vec, + overlapping_asset_plugins: Vec, + } + + impl TestPlugin { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + ..Default::default() + } + } + + pub fn add_master(&mut self, plugin_name: &str) { + self.masters.push(plugin_name.to_string()); + } + + pub fn add_overlapping_records(&mut self, plugin_name: &str) { + self.overlapping_record_plugins + .push(plugin_name.to_string()); + } + + pub fn add_overlapping_assets(&mut self, plugin_name: &str) { + self.overlapping_asset_plugins.push(plugin_name.to_string()); + } + } + + #[cfg(test)] + impl SortingPlugin for TestPlugin { + fn name(&self) -> &str { + &self.name + } + + fn is_master(&self) -> bool { + self.is_master + } + + fn is_blueprint_plugin(&self) -> bool { + self.is_blueprint_plugin + } + + fn masters(&self) -> Result, PluginDataError> { + Ok(self.masters.clone()) + } + + fn override_record_count(&self) -> Result { + Ok(self.override_record_count) + } + + fn asset_count(&self) -> usize { + self.asset_count + } + + fn do_records_overlap(&self, other: &Self) -> Result { + Ok(self.overlapping_record_plugins.contains(&other.name)) + } + + fn do_assets_overlap(&self, other: &Self) -> bool { + self.overlapping_asset_plugins.contains(&other.name) + } + } +} diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 34459f6f..856aad01 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -164,8 +164,8 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { PluginsGraph::default() } - fn add_node(&mut self, plugin: PluginSortingData<'a, T>) { - self.inner.add_node(Rc::new(plugin)); + fn add_node(&mut self, plugin: PluginSortingData<'a, T>) -> NodeIndex { + self.inner.add_node(Rc::new(plugin)) } fn add_edge(&mut self, from: NodeIndex, to: NodeIndex, edge_type: EdgeType) { @@ -1178,3 +1178,2567 @@ impl<'e, T: SortingPlugin> DfsVisitor<'e> for GroupsPathVisitor<'_, '_, '_, '_, self.edge_stack.pop(); } } + +#[cfg(test)] +mod tests { + use super::*; + + use crate::sorting::{groups::build_groups_graph, test::TestPlugin}; + + const PLUGIN_A: &str = "A.esp"; + const PLUGIN_B: &str = "B.esp"; + + struct Fixture { + groups_graph: GroupsGraph, + plugins: HashMap, + } + + impl Fixture { + fn with_plugins(plugin_names: &[&str]) -> Self { + let masterlist = &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()), + Group::new("default".into()).with_after_groups(vec!["C".into()]), + Group::new("E".into()).with_after_groups(vec!["default".into()]), + Group::new("F".into()).with_after_groups(vec!["E".into()]), + ]; + let userlist = &[Group::new("C".into()).with_after_groups(vec!["B".into()])]; + let groups_graph = build_groups_graph(masterlist, userlist).unwrap(); + + Self { + groups_graph, + plugins: plugin_names + .iter() + .enumerate() + .map(|(i, n)| (n.to_string(), (TestPlugin::new(n), i))) + .collect(), + } + } + + fn get_plugin(&self, name: &str) -> &(TestPlugin, usize) { + self.plugins.get(name).unwrap() + } + + fn get_plugin_mut(&mut self, name: &str) -> &mut TestPlugin { + &mut self.plugins.get_mut(name).unwrap().0 + } + + fn sorting_data<'a>(&'a self, name: &str) -> PluginSortingData<'a, TestPlugin> { + let (plugin, index) = self.get_plugin(name); + + PluginSortingData::new(plugin, None, None, *index).unwrap() + } + + fn group_sorting_data<'a>( + &'a self, + name: &str, + group_name: &str, + ) -> PluginSortingData<'a, TestPlugin> { + let (plugin, index) = self.get_plugin(name); + + let mut metadata = PluginMetadata::new(name).unwrap(); + metadata.set_group(group_name.into()); + + PluginSortingData::new(plugin, Some(&metadata), None, *index).unwrap() + } + + fn user_group_sorting_data<'a>( + &'a self, + name: &str, + group_name: &str, + ) -> PluginSortingData<'a, TestPlugin> { + let (plugin, index) = self.get_plugin(name); + + let mut metadata = PluginMetadata::new(name).unwrap(); + metadata.set_group(group_name.into()); + + PluginSortingData::new(plugin, None, Some(&metadata), *index).unwrap() + } + } + + mod plugin_sorting_data { + use crate::tests::BLANK_ESM; + + use super::*; + + #[test] + fn is_blueprint_master_should_be_true_if_a_plugin_is_a_master_and_a_blueprint_plugin() { + let mut master = TestPlugin::new(BLANK_ESM); + master.is_master = true; + let mut blueprint_plugin = TestPlugin::new(BLANK_ESM); + blueprint_plugin.is_blueprint_plugin = true; + let mut blueprint_master = TestPlugin::new(BLANK_ESM); + blueprint_master.is_master = true; + blueprint_master.is_blueprint_plugin = true; + + let plugin = PluginSortingData::new(&master, None, None, 0).unwrap(); + assert!(!plugin.is_blueprint_master()); + + let plugin = PluginSortingData::new(&blueprint_plugin, None, None, 0).unwrap(); + assert!(!plugin.is_blueprint_master()); + + let plugin = PluginSortingData::new(&blueprint_master, None, None, 0).unwrap(); + assert!(plugin.is_blueprint_master()); + } + } + + mod plugins_graph { + use super::*; + + use crate::Vertex; + + const PLUGIN_C: &str = "C.esp"; + const PLUGIN_D: &str = "D.esp"; + const PLUGIN_E: &str = "E.esp"; + + fn edge_type( + graph: &PluginsGraph<'_, TestPlugin>, + from: NodeIndex, + to: NodeIndex, + ) -> EdgeType { + *graph + .inner + .edge_weight(graph.inner.find_edge(from, to).unwrap()) + .unwrap() + } + + mod check_for_cycles { + use super::*; + + #[test] + fn should_succeed_if_there_is_no_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_edge(a, b, EdgeType::Master); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_error_if_there_is_a_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let mut graph = PluginsGraph::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_edge(a, b, EdgeType::Master); + graph.add_edge(b, a, EdgeType::Master); + + let cycle = graph.check_for_cycles().unwrap_err().into_cycle(); + + assert_eq!( + &[ + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::Master), + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::Master), + ], + cycle.as_slice() + ); + } + + #[test] + fn should_only_give_plugins_that_are_part_of_the_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let mut graph = PluginsGraph::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + let c = graph.add_node(fixture.sorting_data(PLUGIN_C)); + + graph.add_edge(a, b, EdgeType::Master); + graph.add_edge(b, c, EdgeType::Master); + graph.add_edge(b, a, EdgeType::MasterFlag); + + let cycle = graph.check_for_cycles().unwrap_err().into_cycle(); + + assert_eq!( + &[ + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::Master), + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + cycle.as_slice() + ); + } + } + + #[test] + fn topological_sort_should_return_empty_list_if_there_are_no_plugins() { + let graph = PluginsGraph::::new(); + let sorted = graph.topological_sort().unwrap(); + + assert!(sorted.is_empty()); + } + + mod add_early_loading_plugin_edges { + use super::*; + + #[test] + fn should_add_no_edges_if_there_are_no_early_loading_plugins() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + let c = graph.add_node(fixture.sorting_data(PLUGIN_C)); + + graph.add_early_loading_plugin_edges(&[]); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(b, a)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, a)); + assert!(!graph.inner.contains_edge(c, b)); + } + + #[test] + fn should_add_edges_between_consecutive_early_loaders_skipping_missing_plugins() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C, PLUGIN_D]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let c = graph.add_node(fixture.sorting_data(PLUGIN_C)); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_early_loading_plugin_edges(&[ + PLUGIN_A.into(), + PLUGIN_B.into(), + PLUGIN_C.into(), + PLUGIN_D.into(), + ]); + + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(c, d)); + assert!(!graph.inner.contains_edge(a, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_from_only_the_last_installed_early_loader_to_all_non_early_loader_plugins() + { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_D, PLUGIN_E]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.sorting_data(PLUGIN_E)); + + graph.add_early_loading_plugin_edges(&[ + PLUGIN_A.into(), + PLUGIN_B.into(), + PLUGIN_C.into(), + ]); + + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(b, e)); + assert!(!graph.inner.contains_edge(a, d)); + assert!(!graph.inner.contains_edge(a, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + mod add_group_edges { + use super::*; + + const PLUGIN_A1: &str = "A1.esp"; + const PLUGIN_A2: &str = "A2.esp"; + const PLUGIN_B1: &str = "B1.esp"; + const PLUGIN_B2: &str = "B2.esp"; + const PLUGIN_C1: &str = "C1.esp"; + const PLUGIN_C2: &str = "C2.esp"; + const PLUGIN_D1: &str = "D1.esp"; + const PLUGIN_D2: &str = "D2.esp"; + const PLUGIN_D3: &str = "D3.esp"; + const PLUGIN_F: &str = "F.esp"; + + #[test] + fn should_add_user_group_edge_if_source_plugin_is_in_group_due_to_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.user_group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, a, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_user_group_edge_if_target_plugin_is_in_group_due_to_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.user_group_sorting_data(PLUGIN_B, "B")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, a, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_user_group_edge_if_group_path_starts_with_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_D]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, b, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_user_group_edge_if_group_path_ends_with_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, a, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_user_group_edge_if_group_path_involves_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_D]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, a, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_masterlist_group_edge_if_no_user_metadata_is_involved() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::MasterlistGroup, edge_type(&graph, a, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_between_plugins_in_indirectly_connected_groups_when_an_intermediate_plugin_edge_is_skipped() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A1, PLUGIN_A2, PLUGIN_B1, PLUGIN_B2, PLUGIN_C1, PLUGIN_C2, + ]); + + let mut graph = PluginsGraph::::new(); + let a1 = graph.add_node(fixture.group_sorting_data(PLUGIN_A1, "A")); + let a2 = graph.add_node(fixture.group_sorting_data(PLUGIN_A2, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + + graph.add_edge(b1, a1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A2.esp -> B1.esp -> A1.esp -> B2.esp -> C1.esp + // -> C2.esp + assert!(graph.inner.contains_edge(b1, a1)); + assert!(graph.inner.contains_edge(a1, b2)); + assert!(graph.inner.contains_edge(a2, b1)); + assert!(graph.inner.contains_edge(a2, b2)); + assert!(graph.inner.contains_edge(b1, c1)); + assert!(graph.inner.contains_edge(b1, c2)); + assert!(graph.inner.contains_edge(b2, c1)); + assert!(graph.inner.contains_edge(b2, c2)); + assert!(graph.inner.contains_edge(a1, c1)); + assert!(graph.inner.contains_edge(a1, c2)); + assert!(!graph.inner.contains_edge(c1, c2)); + assert!(!graph.inner.contains_edge(c2, c1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_across_empty_groups() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A.esp -> C.esp + assert!(graph.inner.contains_edge(a, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_across_the_non_empty_default_group() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_D, PLUGIN_E]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A.esp -> D.esp -> E.esp + // ----------> + assert!(graph.inner.contains_edge(a, d)); + assert!(graph.inner.contains_edge(d, e)); + assert!(graph.inner.contains_edge(a, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_skip_an_edge_that_would_cause_a_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_edge(c, a, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert!(graph.inner.contains_edge(c, a)); + assert!(!graph.inner.contains_edge(a, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_skip_an_edge_that_would_cause_a_cycle_involving_other_non_default_groups() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_edge(c, a, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert!(graph.inner.contains_edge(c, a)); + assert!(graph.inner.contains_edge(a, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_skip_only_edges_to_the_target_group_plugins_that_would_cause_a_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C1, PLUGIN_C2]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + + graph.add_edge(c1, a, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be C1.esp -> A.esp -> C2.esp + assert!(graph.inner.contains_edge(c1, a)); + assert!(graph.inner.contains_edge(a, c2)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_skip_only_edges_from_ancestors_to_the_target_group_plugins_that_would_cause_a_cycle() + { + let fixture = + Fixture::with_plugins(&[PLUGIN_B, PLUGIN_C, PLUGIN_D1, PLUGIN_D2, PLUGIN_D3]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + let d3 = graph.add_node(fixture.sorting_data(PLUGIN_D3)); + + graph.add_edge(d1, b, EdgeType::Master); + graph.add_edge(d2, b, EdgeType::Master); + graph.add_edge(c, b, EdgeType::Master); + graph.add_edge(c, d2, EdgeType::Master); + graph.add_edge(c, d3, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be: C.esp -> D2.esp -> B.esp -> D3.esp + // -> D1.esp -> + // --------------------> + // -----------> + assert!(graph.inner.contains_edge(d1, b)); + assert!(graph.inner.contains_edge(d2, b)); + assert!(graph.inner.contains_edge(c, b)); + assert!(graph.inner.contains_edge(c, d2)); + assert!(graph.inner.contains_edge(c, d3)); + assert!(graph.inner.contains_edge(b, d3)); + assert!(graph.inner.contains_edge(c, d1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_plugin_edges_across_a_successor_if_at_least_one_edge_to_the_successor_group_was_skipped_with_successive_depths() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A1, PLUGIN_A2, PLUGIN_B1, PLUGIN_B2, PLUGIN_C1, PLUGIN_C2, + ]); + + let mut graph = PluginsGraph::::new(); + let a1 = graph.add_node(fixture.group_sorting_data(PLUGIN_A1, "A")); + let a2 = graph.add_node(fixture.group_sorting_data(PLUGIN_A2, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + + graph.add_edge(b1, a1, EdgeType::Master); + graph.add_edge(c1, b2, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A2.esp -> B1.esp -> A1.esp -> C1.esp -> B2.esp -> C2.esp + assert!(graph.inner.contains_edge(b1, a1)); + assert!(graph.inner.contains_edge(c1, b2)); + assert!(graph.inner.contains_edge(a1, b2)); + assert!(graph.inner.contains_edge(a1, c1)); + assert!(graph.inner.contains_edge(a1, c2)); + assert!(graph.inner.contains_edge(a2, b1)); + assert!(graph.inner.contains_edge(a2, b2)); + assert!(graph.inner.contains_edge(b1, c1)); + assert!(graph.inner.contains_edge(b1, c2)); + assert!(graph.inner.contains_edge(b2, c2)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_plugin_edges_across_a_successor_if_at_least_one_edge_to_the_successor_group_was_skipped_with_successive_depths_and_a_different_order() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A1, PLUGIN_A2, PLUGIN_B1, PLUGIN_B2, PLUGIN_C1, PLUGIN_C2, + ]); + + let mut graph = PluginsGraph::::new(); + let a1 = graph.add_node(fixture.group_sorting_data(PLUGIN_A1, "A")); + let a2 = graph.add_node(fixture.group_sorting_data(PLUGIN_A2, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + + graph.add_edge(b1, a1, EdgeType::Master); + graph.add_edge(c1, b1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A2.esp -> C1.esp -> B1.esp -> A1.esp -> B2.esp -> C2.esp + assert!(graph.inner.contains_edge(b1, a1)); + assert!(graph.inner.contains_edge(c1, b1)); + assert!(graph.inner.contains_edge(a1, b2)); + assert!(graph.inner.contains_edge(a1, c2)); + assert!(graph.inner.contains_edge(a2, b1)); + assert!(graph.inner.contains_edge(a2, b2)); + assert!(graph.inner.contains_edge(a2, c1)); + assert!(graph.inner.contains_edge(b1, c2)); + assert!(graph.inner.contains_edge(b2, c2)); + assert!(!graph.inner.contains_edge(b2, c1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edge_from_ancestor_to_successor_if_none_of_a_groups_plugins_can() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B1, PLUGIN_B2, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_edge(c, b1, EdgeType::Master); + graph.add_edge(c, b2, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A.esp -> C1.esp -> B1.esp + // -> B2.esp + assert!(graph.inner.contains_edge(a, b1)); + assert!(graph.inner.contains_edge(a, b2)); + assert!(graph.inner.contains_edge(c, b1)); + assert!(graph.inner.contains_edge(c, b2)); + assert!(graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(b1, b2)); + assert!(!graph.inner.contains_edge(b2, b1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edge_from_ancestor_to_successor_if_none_of_a_groups_plugins_can_with_edges_across_the_skipped_group() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A1, PLUGIN_A2, PLUGIN_B1, PLUGIN_B2, PLUGIN_C1, PLUGIN_C2, PLUGIN_D1, + PLUGIN_D2, + ]); + + let mut graph = PluginsGraph::::new(); + let a1 = graph.add_node(fixture.group_sorting_data(PLUGIN_A1, "A")); + let a2 = graph.add_node(fixture.group_sorting_data(PLUGIN_A2, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + + graph.add_edge(b1, a1, EdgeType::Master); + graph.add_edge(c1, b1, EdgeType::Master); + graph.add_edge(d1, c1, EdgeType::Master); + graph.add_edge(d2, c1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be: + // A2.esp -> D1.esp -> C1.esp -> B1.esp -> A1.esp -> B2.esp -> C2.esp + // -> D2.esp -> + assert!(graph.inner.contains_edge(b1, a1)); + assert!(graph.inner.contains_edge(c1, b1)); + assert!(graph.inner.contains_edge(d1, c1)); + assert!(graph.inner.contains_edge(d2, c1)); + assert!(graph.inner.contains_edge(a1, b2)); + assert!(graph.inner.contains_edge(a2, b1)); + assert!(graph.inner.contains_edge(a2, b2)); + assert!(graph.inner.contains_edge(a1, c2)); + assert!(graph.inner.contains_edge(b1, c2)); + assert!(graph.inner.contains_edge(a2, c1)); + assert!(graph.inner.contains_edge(a2, d1)); + assert!(graph.inner.contains_edge(a2, d2)); + assert!(!graph.inner.contains_edge(b2, c1)); + assert!(!graph.inner.contains_edge(d1, d2)); + assert!(!graph.inner.contains_edge(d2, d1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_default_last() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_edge(d, b, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be D.esp -> B.esp -> C.esp + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(d, b)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_default_first() { + let fixture = Fixture::with_plugins(&[PLUGIN_D, PLUGIN_E, PLUGIN_F]); + + let mut graph = PluginsGraph::::new(); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(f, d, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be E.esp -> F.esp -> D.esp + assert!(graph.inner.contains_edge(e, f)); + assert!(graph.inner.contains_edge(f, d)); + assert!(!graph.inner.contains_edge(d, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_across_skipped_intermediate_groups() + { + let fixture = Fixture::with_plugins(&[PLUGIN_D, PLUGIN_E, PLUGIN_F]); + + let mut graph = PluginsGraph::::new(); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(e, d, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be E.esp -> D.esp -> F.esp + assert!(graph.inner.contains_edge(e, d)); + assert!(graph.inner.contains_edge(d, f)); + assert!(!graph.inner.contains_edge(f, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_d1_first_d2_last() { + let fixture = Fixture::with_plugins(&[PLUGIN_D1, PLUGIN_D2, PLUGIN_E, PLUGIN_F]); + + let mut graph = PluginsGraph::::new(); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(f, d2, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be D1.esp -> E.esp -> F.esp -> D2.esp + assert!(graph.inner.contains_edge(e, f)); + assert!(graph.inner.contains_edge(f, d2)); + assert!(graph.inner.contains_edge(d1, e)); + assert!(!graph.inner.contains_edge(d2, d1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_no_ideal_result() { + let fixture = + Fixture::with_plugins(&[PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(d, b, EdgeType::Master); + graph.add_edge(f, d, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // No ideal result, expected is F.esp -> D.esp -> B.esp -> C.esp -> E.esp + assert!(graph.inner.contains_edge(f, d)); + assert!(graph.inner.contains_edge(d, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(!graph.inner.contains_edge(e, f)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_default_in_middle_and_d_bookends() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_B, PLUGIN_C, PLUGIN_D1, PLUGIN_D2, PLUGIN_E, PLUGIN_F, + ]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(d2, b, EdgeType::Master); + graph.add_edge(f, d1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be D2.esp -> B.esp -> C.esp -> E.esp -> F.esp -> D1.esp + assert!(graph.inner.contains_edge(d2, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(graph.inner.contains_edge(e, f)); + assert!(graph.inner.contains_edge(f, d1)); + assert!(!graph.inner.contains_edge(d1, d2)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_default_in_middle_and_d_throughout() + { + const PLUGIN_D4: &str = "D4.esp"; + let fixture = Fixture::with_plugins(&[ + PLUGIN_B, PLUGIN_C, PLUGIN_D1, PLUGIN_D2, PLUGIN_D3, PLUGIN_D4, PLUGIN_E, + PLUGIN_F, + ]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + let d3 = graph.add_node(fixture.sorting_data(PLUGIN_D3)); + let d4 = graph.add_node(fixture.sorting_data(PLUGIN_D4)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(d2, b, EdgeType::Master); + graph.add_edge(d4, c, EdgeType::Master); + graph.add_edge(f, d1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be: + // D2.esp -> B.esp -> D4.esp -> C.esp -> D3.esp -> E.esp -> F.esp -> D1.esp + assert!(graph.inner.contains_edge(d2, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, d3)); + assert!(graph.inner.contains_edge(c, e)); + assert!(graph.inner.contains_edge(d3, e)); + assert!(graph.inner.contains_edge(e, f)); + assert!(graph.inner.contains_edge(f, d1)); + assert!(graph.inner.contains_edge(d4, c)); + assert!(graph.inner.contains_edge(b, d4)); + assert!(!graph.inner.contains_edge(d1, d2)); + assert!(!graph.inner.contains_edge(d1, d3)); + assert!(!graph.inner.contains_edge(d1, d4)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_asymmetric_branches_in_the_groups_graph() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("D".into()).with_after_groups(vec!["A".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> C.esp + // -> D.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(a, d)); + assert!(!graph.inner.contains_edge(d, b)); + assert!(!graph.inner.contains_edge(d, c)); + assert!(!graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_asymmetric_branches_in_the_groups_graph_that_merge() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("D".into()).with_after_groups(vec!["A".into()]), + Group::new("E".into()).with_after_groups(vec!["C".into(), "D".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> C.esp -> E.esp + // -> D.esp ----------> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(graph.inner.contains_edge(a, d)); + assert!(graph.inner.contains_edge(d, e)); + assert!(!graph.inner.contains_edge(d, b)); + assert!(!graph.inner.contains_edge(d, c)); + assert!(!graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_branches_in_the_groups_graph_that_form_a_diamond_pattern() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> D.esp + // -> C.esp -> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(c, d)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_across_the_merge_point_of_branches_in_the_groups_graph() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::new("E".into()).with_after_groups(vec!["D".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_edge(d, c, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> D.esp -> C.esp -> E.esp + assert!(graph.inner.contains_edge(d, c)); + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(d, e)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_a_groups_graph_with_multiple_successive_branches() { + const PLUGIN_G: &str = "G.esp"; + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F, PLUGIN_G, + ]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::new("E".into()).with_after_groups(vec!["D".into()]), + Group::new("F".into()).with_after_groups(vec!["D".into()]), + Group::new("G".into()).with_after_groups(vec!["E".into(), "F".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "F")); + let g = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "G")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be: + // A.esp -> B.esp -> D.esp -> E.esp -> G.esp + // -> C.esp -> -> F.esp -> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(c, d)); + assert!(graph.inner.contains_edge(d, e)); + assert!(graph.inner.contains_edge(d, f)); + assert!(graph.inner.contains_edge(e, g)); + assert!(graph.inner.contains_edge(f, g)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + assert!(!graph.inner.contains_edge(e, f)); + assert!(!graph.inner.contains_edge(f, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_find_all_groups_in_all_paths_between_two_groups_when_ignoring_a_plugin() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default().with_after_groups(vec!["B".into(), "D".into()]), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.sorting_data(PLUGIN_E)); + + graph.add_edge(e, a, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be: + // A.esp -> B.esp -> D.esp -> E.esp -> G.esp + // -> C.esp -> -> F.esp -> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, d)); + + assert!(!graph.inner.contains_edge(a, e)); + assert!(!graph.inner.contains_edge(b, e)); + assert!(!graph.inner.contains_edge(c, e)); + assert!(!graph.inner.contains_edge(d, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_isolated_groups() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp + // C.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(c, a)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_disconnected_group_graphs() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp + // C.esp -> D.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(c, d)); + assert!(!graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(a, d)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(c, a)); + assert!(!graph.inner.contains_edge(c, b)); + assert!(!graph.inner.contains_edge(d, a)); + assert!(!graph.inner.contains_edge(d, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_across_the_merge_point_of_two_root_node_paths() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()), + Group::new("C".into()).with_after_groups(vec!["A".into(), "B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_edge(c, b, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> C.esp -> D.esp + // B.esp ----------> + assert!(graph.inner.contains_edge(c, b)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(c, d)); + assert!(graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_not_depend_on_group_definition_order_if_there_is_a_single_linear_path() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let masterlists = &[ + [ + Group::new("B".into()), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::default().with_after_groups(vec!["C".into()]), + ], + [ + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("B".into()), + Group::default().with_after_groups(vec!["C".into()]), + ], + ]; + + for masterlist in masterlists { + let groups_graph = build_groups_graph(masterlist, &[]).unwrap(); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_edge(d, b, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be D.esp -> B.esp -> C.esp + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(d, b)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + #[test] + fn should_not_depend_on_group_definition_order_if_there_are_multiple_roots() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let masterlists = &[ + [ + Group::new("A".into()), + Group::new("B".into()), + Group::new("C".into()).with_after_groups(vec!["A".into(), "B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + [ + Group::new("B".into()), + Group::new("A".into()), + Group::new("C".into()).with_after_groups(vec!["A".into(), "B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + ]; + + for masterlist in masterlists { + let groups_graph = build_groups_graph(masterlist, &[]).unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_edge(d, a, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be B.esp -> D.esp -> A.esp -> C.esp + // B.esp -------------------> + assert!(graph.inner.contains_edge(d, a)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + #[test] + fn should_not_depend_on_branching_group_definition_order() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let masterlists = &[ + [ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::new("E".into()).with_after_groups(vec!["D".into()]), + Group::default(), + ], + [ + Group::new("A".into()), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::new("E".into()).with_after_groups(vec!["D".into()]), + Group::default(), + ], + ]; + + for masterlist in masterlists { + let groups_graph = build_groups_graph(masterlist, &[]).unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_edge(e, c, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> D.esp -> E.esp -> C.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(a, d)); + assert!(graph.inner.contains_edge(a, e)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(b, e)); + assert!(graph.inner.contains_edge(d, e)); + assert!(graph.inner.contains_edge(e, c)); + + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + assert!(!graph.inner.contains_edge(c, d)); + assert!(!graph.inner.contains_edge(c, e)); + assert!(!graph.inner.contains_edge(d, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + #[test] + fn should_not_depend_on_plugin_graph_node_order() { + let fixture = Fixture::with_plugins(&[PLUGIN_A1, PLUGIN_A2, PLUGIN_B, PLUGIN_C]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let a1 = (PLUGIN_A1, "A"); + let a2 = (PLUGIN_A2, "A"); + let b = (PLUGIN_B, "B"); + let c = (PLUGIN_C, "C"); + + let variations = &[ + [a1, a2, b, c], + [a1, a2, c, b], + [a1, b, c, a2], + [a1, b, a2, c], + [a1, c, a2, b], + [a1, c, b, a2], + [a2, a1, b, c], + [a2, a1, c, b], + [a2, b, c, a1], + [a2, b, a1, c], + [a2, c, a1, b], + [a2, c, b, a1], + [b, a2, a1, c], + [b, a2, c, a1], + [b, a1, c, a2], + [b, a1, a2, c], + [b, c, a2, a1], + [b, c, a1, a2], + [c, a2, b, a1], + [c, a2, a1, b], + [c, b, a1, a2], + [c, b, a2, a1], + [c, a1, a2, b], + [c, a1, b, a2], + ]; + + for plugins in variations { + let mut graph = PluginsGraph::::new(); + + for plugin in plugins { + graph.add_node(fixture.group_sorting_data(plugin.0, plugin.1)); + } + + let a1 = graph.node_index_by_name(PLUGIN_A1).unwrap(); + let a2 = graph.node_index_by_name(PLUGIN_A2).unwrap(); + let b = graph.node_index_by_name(PLUGIN_B).unwrap(); + let c = graph.node_index_by_name(PLUGIN_C).unwrap(); + + graph.add_edge(c, a1, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A2.esp -> C.esp -> A1.esp -> B.esp + // A2.esp --------------------> + assert!(graph.inner.contains_edge(c, a1)); + assert!(graph.inner.contains_edge(a1, b)); + assert!(graph.inner.contains_edge(a2, b)); + assert!(graph.inner.contains_edge(a2, c)); + assert!(!graph.inner.contains_edge(a1, a2)); + assert!(!graph.inner.contains_edge(a2, a1)); + assert!(!graph.inner.contains_edge(b, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + #[test] + fn should_start_searching_from_root_groups_before_going_in_lexicographical_order() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("D".into()), + Group::new("A".into()).with_after_groups(vec!["D".into()]), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_edge(c, d, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be C.esp -> D.esp -> A.esp -> B.esp + // Processing groups lexicographically would give: + // A.esp -> B.esp -> C.esp -> D.esp + assert!(graph.inner.contains_edge(c, d)); + assert!(graph.inner.contains_edge(d, a)); + assert!(graph.inner.contains_edge(d, b)); + assert!(graph.inner.contains_edge(a, b)); + + assert!(!graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(a, d)); + assert!(!graph.inner.contains_edge(b, a)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(d, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_start_searching_from_the_root_group_with_the_longest_path() { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F, + ]); + + let groups_graph = build_groups_graph( + &[ + Group::new("D".into()), + Group::new("B".into()).with_after_groups(vec!["D".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("A".into()), + Group::new("E".into()).with_after_groups(vec!["C".into(), "A".into()]), + Group::new("F".into()).with_after_groups(vec!["E".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(f, b, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be D.esp -> B.esp -> C.esp -> E.esp + // A.esp -> F.esp ----------> B.esp + // A.esp -------------------------------------> E.esp + assert!(graph.inner.contains_edge(d, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(graph.inner.contains_edge(a, f)); + assert!(graph.inner.contains_edge(a, e)); + assert!(graph.inner.contains_edge(f, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn does_not_start_searching_with_the_longest_path() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::new("E".into()).with_after_groups(vec!["B".into(), "D".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_edge(e, c, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> E.esp -> C.esp -> D.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, e)); + assert!(graph.inner.contains_edge(e, c)); + assert!(graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + mod add_overlap_edges { + use super::*; + + #[test] + fn should_not_add_edges_between_non_overlapping_plugins() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_not_add_edges_between_overlapping_plugins_with_equal_override_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 1; + a.add_overlapping_records(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_add_edge_between_overlapping_plugins_with_unequal_override_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 2; + a.add_overlapping_records(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::RecordOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_not_add_edge_between_non_overlapping_plugins_with_unequal_override_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 2; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_not_add_edge_between_plugins_with_asset_overlap_and_equal_asset_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.asset_count = 1; + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_not_add_edge_between_plugins_with_no_asset_overlap_and_unequal_asset_counts() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.asset_count = 2; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_add_edge_between_plugins_with_asset_overlap_and_unequal_asset_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.asset_count = 2; + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::AssetOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_add_edge_between_overlapping_plugins_with_asset_overlap_and_equal_override_count_and_unequal_asset_counts() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.asset_count = 2; + a.add_overlapping_records(PLUGIN_B); + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::AssetOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_add_edge_between_plugins_with_asset_overlap_and_unequal_override_count_and_unequal_asset_counts() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 1; + a.asset_count = 2; + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 2; + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::AssetOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_choose_record_overlap_over_asset_overlap() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 2; + a.asset_count = 1; + a.add_overlapping_records(PLUGIN_B); + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 1; + b.asset_count = 2; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::RecordOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + } + + mod add_tie_break_edges { + use super::*; + + const PLUGIN_F: &str = "F.esp"; + const PLUGIN_G: &str = "G.esp"; + const PLUGIN_H: &str = "H.esp"; + const PLUGIN_I: &str = "I.esp"; + const PLUGIN_J: &str = "J.esp"; + + #[test] + fn should_not_error_on_a_graph_with_one_node() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + graph.add_node(fixture.sorting_data(PLUGIN_A)); + + assert!(graph.add_tie_break_edges().is_ok()); + } + + #[test] + fn should_result_in_a_sort_order_equal_to_vertex_creation_order_if_there_are_no_other_edges() + { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let mut graph = PluginsGraph::::new(); + graph.add_node(fixture.sorting_data(PLUGIN_A)); + graph.add_node(fixture.sorting_data(PLUGIN_B)); + graph.add_node(fixture.sorting_data(PLUGIN_C)); + graph.add_node(fixture.sorting_data(PLUGIN_D)); + graph.add_node(fixture.sorting_data(PLUGIN_E)); + + graph.add_tie_break_edges().unwrap(); + + let sorted = graph.topological_sort().unwrap(); + + assert!(graph.check_path_is_hamiltonian(&sorted).is_none()); + + let sorted_plugin_names: Vec<_> = sorted + .into_iter() + .map(|i| graph[i].name().to_string()) + .collect(); + + assert_eq!( + &[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E], + sorted_plugin_names.as_slice() + ); + } + + #[test] + fn should_pin_paths_that_prevent_the_vertex_creation_order_from_being_used() { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F, PLUGIN_G, PLUGIN_H, + PLUGIN_I, PLUGIN_J, + ]); + + let mut graph = PluginsGraph::::new(); + graph.add_node(fixture.sorting_data(PLUGIN_A)); + graph.add_node(fixture.sorting_data(PLUGIN_B)); + graph.add_node(fixture.sorting_data(PLUGIN_C)); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.sorting_data(PLUGIN_E)); + let f = graph.add_node(fixture.sorting_data(PLUGIN_F)); + let g = graph.add_node(fixture.sorting_data(PLUGIN_G)); + let h = graph.add_node(fixture.sorting_data(PLUGIN_H)); + let i = graph.add_node(fixture.sorting_data(PLUGIN_I)); + graph.add_node(fixture.sorting_data(PLUGIN_J)); + + // Add a path g -> h -> i -> f + graph.add_edge(g, h, EdgeType::RecordOverlap); + graph.add_edge(h, i, EdgeType::RecordOverlap); + graph.add_edge(i, f, EdgeType::RecordOverlap); + + // Also add g -> d and i -> e + graph.add_edge(g, d, EdgeType::RecordOverlap); + graph.add_edge(i, e, EdgeType::RecordOverlap); + + graph.add_tie_break_edges().unwrap(); + + let sorted = graph.topological_sort().unwrap(); + + assert!(graph.check_path_is_hamiltonian(&sorted).is_none()); + + let sorted_plugin_names: Vec<_> = sorted + .into_iter() + .map(|i| graph[i].name().to_string()) + .collect(); + + assert_eq!( + &[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_G, PLUGIN_D, PLUGIN_H, PLUGIN_I, + PLUGIN_E, PLUGIN_F, PLUGIN_J + ], + sorted_plugin_names.as_slice() + ); + } + + #[test] + fn should_prefix_path_to_new_load_order_if_the_first_pair_of_nodes_cannot_be_used_in_creation_order() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F, PLUGIN_G, PLUGIN_H, + PLUGIN_I, PLUGIN_J, + ]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + let c = graph.add_node(fixture.sorting_data(PLUGIN_C)); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + graph.add_node(fixture.sorting_data(PLUGIN_E)); + graph.add_node(fixture.sorting_data(PLUGIN_F)); + graph.add_node(fixture.sorting_data(PLUGIN_G)); + graph.add_node(fixture.sorting_data(PLUGIN_H)); + graph.add_node(fixture.sorting_data(PLUGIN_I)); + graph.add_node(fixture.sorting_data(PLUGIN_J)); + + // Add a path b -> c -> d -> a + graph.add_edge(b, c, EdgeType::RecordOverlap); + graph.add_edge(c, d, EdgeType::RecordOverlap); + graph.add_edge(d, a, EdgeType::RecordOverlap); + + graph.add_tie_break_edges().unwrap(); + + let sorted = graph.topological_sort().unwrap(); + + assert!(graph.check_path_is_hamiltonian(&sorted).is_none()); + + let sorted_plugin_names: Vec<_> = sorted + .into_iter() + .map(|i| graph[i].name().to_string()) + .collect(); + + assert_eq!( + &[ + PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_A, PLUGIN_E, PLUGIN_F, PLUGIN_G, + PLUGIN_H, PLUGIN_I, PLUGIN_J + ], + sorted_plugin_names.as_slice() + ); + } + } + } + + mod sort_plugins { + use crate::{Vertex, sorting::error::PluginGraphValidationError}; + + use super::*; + + #[test] + fn should_not_change_the_result_if_given_its_own_output() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins( + vec![ + fixture.sorting_data(PLUGIN_B), + fixture.sorting_data(PLUGIN_A), + ], + &fixture.groups_graph, + &[], + ) + .unwrap(); + + assert_eq!(expected, sorted.as_slice()); + + let sorted = sort_plugins( + vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ], + &fixture.groups_graph, + &[], + ) + .unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_use_group_metadata_when_deciding_relative_plugin_positions() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_A]); + + let data = vec![ + fixture.group_sorting_data(PLUGIN_A, "A"), + fixture.group_sorting_data(PLUGIN_B, "B"), + ]; + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_use_load_after_metadata_when_deciding_relative_plugin_positions() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + let expected = &[PLUGIN_B, PLUGIN_A]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_use_requirement_metadata_when_deciding_relative_plugin_positions() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + let expected = &[PLUGIN_B, PLUGIN_A]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_use_early_loader_positions_when_deciding_relative_plugin_positions() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_A]); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[PLUGIN_A.into()]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_error_if_a_plugin_has_a_group_that_does_not_exist() { + let fixture = Fixture::with_plugins(&[PLUGIN_A]); + + let data = vec![fixture.group_sorting_data(PLUGIN_A, "missing")]; + + assert!(sort_plugins(data, &fixture.groups_graph, &[]).is_err()); + } + + #[test] + fn should_error_if_a_cyclic_interaction_is_encountered() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).add_master(PLUGIN_B); + fixture.get_plugin_mut(PLUGIN_B).add_master(PLUGIN_A); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::CycleFound(e)) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::Master), + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::Master), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_master_edge_would_contradict_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.is_master = true; + a.add_master(PLUGIN_B); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::Master), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_load_after_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_load_after_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_requirement_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistRequirement), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_requirement_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserRequirement), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_an_early_loader_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + match sort_plugins(data, &fixture.groups_graph, &[PLUGIN_B.into()]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::Hardcoded), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_not_error_if_a_master_edge_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.is_master = true; + a.is_blueprint_plugin = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.add_master(PLUGIN_A); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_B, PLUGIN_A]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_not_error_if_a_master_edge_would_put_a_blueprint_master_before_a_non_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.is_master = true; + a.is_blueprint_plugin = true; + + fixture.get_plugin_mut(PLUGIN_B).add_master(PLUGIN_A); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_B, PLUGIN_A]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_error_if_a_masterlist_load_after_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_load_after_would_put_a_blueprint_master_before_a_non_master() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_load_after_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_load_after_would_put_a_blueprint_master_before_a_non_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_requirement_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistRequirement), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_requirement_would_put_a_blueprint_master_before_a_non_master() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistRequirement), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_requirement_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserRequirement), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_requirement_would_put_a_blueprint_master_before_a_non_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserRequirement), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_not_error_if_an_early_loader_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[PLUGIN_B.into()]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_not_error_if_an_early_loader_would_put_a_blueprint_master_before_a_non_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[PLUGIN_B.into()]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + } +} diff --git a/src/tests.rs b/src/tests.rs index c4ae348d..d4fd2631 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -5,25 +5,28 @@ use std::{ }; use crate::GameType; +use rstest_reuse::template; use tempfile::TempDir; -const BLANK_ESM: &str = "Blank.esm"; -const BLANK_DIFFERENT_ESM: &str = "Blank - Different.esm"; -const BLANK_MASTER_DEPENDENT_ESM: &str = "Blank - Master Dependent.esm"; +pub const BLANK_ESM: &str = "Blank.esm"; +pub const BLANK_DIFFERENT_ESM: &str = "Blank - Different.esm"; +pub const BLANK_MASTER_DEPENDENT_ESM: &str = "Blank - Master Dependent.esm"; const BLANK_DIFFERENT_MASTER_DEPENDENT_ESM: &str = "Blank - Different Master Dependent.esm"; -const BLANK_ESP: &str = "Blank.esp"; -const BLANK_DIFFERENT_ESP: &str = "Blank - Different.esp"; -const BLANK_MASTER_DEPENDENT_ESP: &str = "Blank - Master Dependent.esp"; +pub const BLANK_ESP: &str = "Blank.esp"; +pub const BLANK_DIFFERENT_ESP: &str = "Blank - Different.esp"; +pub const BLANK_MASTER_DEPENDENT_ESP: &str = "Blank - Master Dependent.esp"; const BLANK_DIFFERENT_MASTER_DEPENDENT_ESP: &str = "Blank - Different Master Dependent.esp"; const BLANK_PLUGIN_DEPENDENT_ESP: &str = "Blank - Plugin Dependent.esp"; const BLANK_DIFFERENT_PLUGIN_DEPENDENT_ESP: &str = "Blank - Different Plugin Dependent.esp"; -const BLANK_FULL_ESM: &str = "Blank.full.esm"; -const BLANK_MEDIUM_ESM: &str = "Blank.medium.esm"; -const BLANK_ESL: &str = "Blank.esl"; -const NON_PLUGIN_FILE: &str = "NotAPlugin.esm"; +pub const BLANK_FULL_ESM: &str = "Blank.full.esm"; +pub const BLANK_MEDIUM_ESM: &str = "Blank.medium.esm"; +pub const BLANK_OVERRIDE_ESP: &str = "Blank - Override.esp"; +pub const BLANK_ESL: &str = "Blank.esl"; +pub const NON_PLUGIN_FILE: &str = "NotAPlugin.esm"; +pub const NON_ASCII_ESM: &str = "non\u{00C1}scii.esm"; -fn source_plugins_path(game_type: GameType) -> PathBuf { +pub fn source_plugins_path(game_type: GameType) -> PathBuf { match game_type { GameType::TES3 | GameType::OpenMW => absolute("./testing-plugins/Morrowind/Data Files"), GameType::TES4 => absolute("./testing-plugins/Oblivion/Data"), @@ -48,7 +51,7 @@ fn master_file(game_type: GameType) -> &'static str { } } -fn copy_file(source_dir: &Path, dest_dir: &Path, filename: &str) { +pub fn copy_file(source_dir: &Path, dest_dir: &Path, filename: &str) { copy(source_dir.join(filename), dest_dir.join(filename)).unwrap(); } @@ -70,7 +73,7 @@ fn is_load_order_timestamp_based(game_type: GameType) -> bool { ) } -fn initial_load_order(game_type: GameType) -> Vec<(&'static str, bool)> { +pub fn initial_load_order(game_type: GameType) -> Vec<(&'static str, bool)> { if game_type == GameType::Starfield { vec![ (master_file(game_type), true), @@ -155,6 +158,14 @@ fn set_load_order( } } +fn data_path(game_type: GameType, game_path: &Path) -> PathBuf { + match game_type { + GameType::OpenMW => game_path.join("resources/vfs"), + GameType::TES3 => game_path.join("Data Files"), + _ => game_path.join("Data"), + } +} + pub struct Fixture { _temp_dir: TempDir, pub game_type: GameType, @@ -171,11 +182,7 @@ impl Fixture { let root_path = temp_dir.path(); let game_path = root_path.join("games/game"); let local_path = root_path.join("local/game"); - let data_path = match game_type { - GameType::OpenMW => game_path.join("resources/vfs"), - GameType::TES3 => game_path.join("Data Files"), - _ => game_path.join("Data"), - }; + let data_path = data_path(game_type, &game_path); create_dir_all(&data_path).unwrap(); create_dir_all(&local_path).unwrap(); @@ -208,7 +215,7 @@ impl Fixture { ) .unwrap(); copy( - source_plugins_path.join("Blank - Override.esp"), + source_plugins_path.join(BLANK_OVERRIDE_ESP), data_path.join(BLANK_MASTER_DEPENDENT_ESP), ) .unwrap(); @@ -282,4 +289,68 @@ impl Fixture { local_path, } } + + pub fn data_path(&self) -> PathBuf { + data_path(self.game_type, &self.game_path) + } +} + +#[template] +#[rstest::rstest] +pub fn all_game_types( + #[values( + GameType::TES4, + GameType::TES5, + GameType::FO3, + GameType::FONV, + GameType::FO4, + GameType::TES5SE, + GameType::FO4VR, + GameType::TES5VR, + GameType::TES3, + GameType::Starfield, + GameType::OpenMW + )] + game_type: GameType, +) { +} + +mod unicase { + #[test] + fn eq_should_be_case_insensitive() { + assert!(unicase::eq("i", "I")); + assert!(!unicase::eq("i", "\u{0130}")); + assert!(!unicase::eq("i", "\u{0131}")); + assert!(!unicase::eq("i", "\u{0307}")); + assert!(!unicase::eq("i", "\u{03a1}")); + assert!(!unicase::eq("i", "\u{03c1}")); + assert!(!unicase::eq("i", "\u{03f1}")); + + assert!(!unicase::eq("I", "\u{0130}")); + assert!(!unicase::eq("I", "\u{0131}")); + assert!(!unicase::eq("I", "\u{0307}")); + assert!(!unicase::eq("I", "\u{03a1}")); + assert!(!unicase::eq("I", "\u{03c1}")); + assert!(!unicase::eq("I", "\u{03f1}")); + + assert!(!unicase::eq("\u{0130}", "\u{0131}")); + assert!(!unicase::eq("\u{0130}", "\u{0307}")); + assert!(!unicase::eq("\u{0130}", "\u{03a1}")); + assert!(!unicase::eq("\u{0130}", "\u{03c1}")); + assert!(!unicase::eq("\u{0130}", "\u{03f1}")); + + assert!(!unicase::eq("\u{0131}", "\u{0307}")); + assert!(!unicase::eq("\u{0131}", "\u{03a1}")); + assert!(!unicase::eq("\u{0131}", "\u{03c1}")); + assert!(!unicase::eq("\u{0131}", "\u{03f1}")); + + assert!(!unicase::eq("\u{0307}", "\u{03a1}")); + assert!(!unicase::eq("\u{0307}", "\u{03c1}")); + assert!(!unicase::eq("\u{0307}", "\u{03f1}")); + + assert!(unicase::eq("\u{03a1}", "\u{03c1}")); + assert!(unicase::eq("\u{03a1}", "\u{03f1}")); + + assert!(unicase::eq("\u{03c1}", "\u{03f1}")); + } } From 5d53db48cdfe27b5fbb0a0a60323902faf750382 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 8 Mar 2025 09:53:23 +0000 Subject: [PATCH 027/206] Add setters to metadata and Vertex structs They're not provided by C++ libloot, but they're needed to usefully expose these types to C++ since it doesn't have an equivalent of the 'self' method receiver. --- src/metadata/file.rs | 32 ++++++++++++++++++++--- src/metadata/group.rs | 16 ++++++++++-- src/metadata/location.rs | 8 +++++- src/metadata/message.rs | 16 ++++++++++-- src/metadata/plugin_cleaning_data.rs | 39 ++++++++++++++++++++++++---- src/metadata/tag.rs | 6 +++++ src/sorting/vertex.rs | 8 +++++- 7 files changed, 110 insertions(+), 15 deletions(-) diff --git a/src/metadata/file.rs b/src/metadata/file.rs index 4556a0b0..13693736 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -38,14 +38,14 @@ impl File { /// CommonMark. #[must_use] pub fn with_display_name(mut self, display_name: String) -> Self { - self.display_name = Some(display_name); + self.set_display_name(display_name); self } /// Set the condition string. #[must_use] pub fn with_condition(mut self, condition: String) -> Self { - self.condition = Some(condition); + self.set_condition(condition); self } @@ -56,8 +56,7 @@ impl File { mut self, detail: Vec, ) -> Result { - validate_message_contents(&detail)?; - self.detail = detail; + self.set_detail(detail)?; Ok(self) } @@ -71,6 +70,13 @@ impl File { self.display_name.as_deref() } + /// Set the name to be displayed for the file in messages, formatted using + /// CommonMark. + pub fn set_display_name(&mut self, display_name: String) -> &mut Self { + self.display_name = Some(display_name); + self + } + /// Get the detail message content of the file. /// /// If this file causes an error message to be displayed, the detail message @@ -80,10 +86,28 @@ impl File { &self.detail } + /// Set the detail message content, which may be appended to any messages + /// generated for this file. If multilingual, one language must be + /// [MessageContent::DEFAULT_LANGUAGE]. + pub fn set_detail( + &mut self, + detail: Vec, + ) -> Result<&mut Self, MultilingualMessageContentsError> { + validate_message_contents(&detail)?; + self.detail = detail; + Ok(self) + } + /// Get the condition string. pub fn condition(&self) -> Option<&str> { self.condition.as_deref() } + + /// Set the condition string. + pub fn set_condition(&mut self, condition: String) -> &mut Self { + self.condition = Some(condition); + self + } } /// Represents a case-insensitive filename. diff --git a/src/metadata/group.rs b/src/metadata/group.rs index 104b5b76..f976e71d 100644 --- a/src/metadata/group.rs +++ b/src/metadata/group.rs @@ -27,14 +27,14 @@ impl Group { /// Set a description for the group. #[must_use] pub fn with_description(mut self, description: String) -> Self { - self.description = Some(description); + self.set_description(description); self } /// Set the names of the groups that this group loads after. #[must_use] pub fn with_after_groups(mut self, after_groups: Vec) -> Self { - self.after_groups = after_groups; + self.set_after_groups(after_groups); self } @@ -51,10 +51,22 @@ impl Group { self.description.as_deref() } + /// Set a description for the group. + pub fn set_description(&mut self, description: String) -> &mut Self { + self.description = Some(description); + self + } + /// Get the names of the groups that this group loads after. pub fn after_groups(&self) -> &[String] { &self.after_groups } + + /// Set the names of the groups that this group loads after. + pub fn set_after_groups(&mut self, after_groups: Vec) -> &mut Self { + self.after_groups = after_groups; + self + } } impl std::default::Default for Group { diff --git a/src/metadata/location.rs b/src/metadata/location.rs index 66872672..ae7eb639 100644 --- a/src/metadata/location.rs +++ b/src/metadata/location.rs @@ -27,7 +27,7 @@ impl Location { /// Set a name for the URL, eg. the page or site name. #[must_use] pub fn with_name(mut self, name: String) -> Self { - self.name = Some(name); + self.set_name(name); self } @@ -40,6 +40,12 @@ impl Location { pub fn name(&self) -> Option<&str> { self.name.as_deref() } + + /// Set a name for the URL, eg. the page or site name. + pub fn set_name(&mut self, name: String) -> &mut Self { + self.name = Some(name); + self + } } impl TryFrom<&MarkedYaml> for Location { diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 1c31868c..594be6f5 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -65,7 +65,7 @@ impl MessageContent { /// Set the language code to the given value. #[must_use] pub fn with_language(mut self, language: String) -> Self { - self.language = language; + self.set_language(language); self } @@ -78,6 +78,12 @@ impl MessageContent { pub fn language(&self) -> &str { &self.language } + + /// Set the language code to the given value. + pub fn set_language(&mut self, language: String) -> &mut Self { + self.language = language; + self + } } impl std::default::Default for MessageContent { @@ -194,7 +200,7 @@ impl Message { /// Set the condition string. #[must_use] pub fn with_condition(mut self, condition: String) -> Self { - self.condition = Some(condition); + self.set_condition(condition); self } @@ -212,6 +218,12 @@ impl Message { pub fn condition(&self) -> Option<&str> { self.condition.as_deref() } + + /// Set the condition string. + pub fn set_condition(&mut self, condition: String) -> &mut Self { + self.condition = Some(condition); + self + } } pub(crate) fn validate_message_contents( diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index db990fc2..550a077e 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -38,21 +38,21 @@ impl PluginCleaningData { /// Set the number of Identical To Master records found in the plugin. #[must_use] pub fn with_itm_count(mut self, itm_count: u32) -> Self { - self.itm_count = itm_count; + self.set_itm_count(itm_count); self } /// Set the number of deleted references found in the plugin. #[must_use] pub fn with_deleted_reference_count(mut self, deleted_reference_count: u32) -> Self { - self.deleted_reference_count = deleted_reference_count; + self.set_deleted_reference_count(deleted_reference_count); self } /// Set the number of deleted navmeshes found in the plugin. #[must_use] pub fn with_deleted_navmesh_count(mut self, deleted_navmesh_count: u32) -> Self { - self.deleted_navmesh_count = deleted_navmesh_count; + self.set_deleted_navmesh_count(deleted_navmesh_count); self } @@ -63,8 +63,7 @@ impl PluginCleaningData { mut self, detail: Vec, ) -> Result { - validate_message_contents(&detail)?; - self.detail = detail; + self.set_detail(detail)?; Ok(self) } @@ -78,16 +77,34 @@ impl PluginCleaningData { self.itm_count } + /// Set the number of Identical To Master records found in the plugin. + pub fn set_itm_count(&mut self, itm_count: u32) -> &mut Self { + self.itm_count = itm_count; + self + } + /// Get the number of deleted references found in the plugin. pub fn deleted_reference_count(&self) -> u32 { self.deleted_reference_count } + /// Set the number of deleted references found in the plugin. + pub fn set_deleted_reference_count(&mut self, deleted_reference_count: u32) -> &mut Self { + self.deleted_reference_count = deleted_reference_count; + self + } + /// Get the number of deleted navmeshes found in the plugin. pub fn deleted_navmesh_count(&self) -> u32 { self.deleted_navmesh_count } + /// Set the number of deleted navmeshes found in the plugin. + pub fn set_deleted_navmesh_count(&mut self, deleted_navmesh_count: u32) -> &mut Self { + self.deleted_navmesh_count = deleted_navmesh_count; + self + } + /// Get the cleaning utility that was used to check the plugin. /// /// The string may include a cleaning utility name, possibly related @@ -103,6 +120,18 @@ impl PluginCleaningData { pub fn detail(&self) -> &[MessageContent] { &self.detail } + + /// Set the detail message content, which may be appended to any messages + /// generated for this cleaning data. If multilingual, one language must be + /// [MessageContent::DEFAULT_LANGUAGE]. + pub fn set_detail( + &mut self, + detail: Vec, + ) -> Result<&mut Self, MultilingualMessageContentsError> { + validate_message_contents(&detail)?; + self.detail = detail; + Ok(self) + } } impl TryFrom<&MarkedYaml> for PluginCleaningData { diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index e6fcbf0e..d63a6b84 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -54,6 +54,12 @@ impl Tag { pub fn condition(&self) -> Option<&str> { self.condition.as_deref() } + + /// Set the condition string. + pub fn set_condition(&mut self, condition: String) -> &mut Self { + self.condition = Some(condition); + self + } } impl TryFrom<&saphyr::MarkedYaml> for Tag { diff --git a/src/sorting/vertex.rs b/src/sorting/vertex.rs index 0c4b8f38..0b96ae33 100644 --- a/src/sorting/vertex.rs +++ b/src/sorting/vertex.rs @@ -59,7 +59,7 @@ impl Vertex { /// Set the type of the edge going from this vertex to the next in the path. #[must_use] pub fn with_out_edge_type(mut self, out_edge_type: EdgeType) -> Self { - self.out_edge_type = Some(out_edge_type); + self.set_out_edge_type(out_edge_type); self } @@ -72,4 +72,10 @@ impl Vertex { pub fn out_edge_type(&self) -> Option { self.out_edge_type } + + /// Set the type of the edge going from this vertex to the next in the path. + pub fn set_out_edge_type(&mut self, out_edge_type: EdgeType) -> &mut Self { + self.out_edge_type = Some(out_edge_type); + self + } } From 3fe91be7e553015d40374404401882e93fcc7746 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 6 Mar 2025 22:24:39 +0000 Subject: [PATCH 028/206] Add a C++ wrapper crate using CXX --- Cargo.lock | 194 +++++++++++++++ Cargo.toml | 3 + cxx/.gitignore | 2 + cxx/CMakeLists.txt | 41 +++ cxx/Cargo.toml | 16 ++ cxx/README.md | 57 +++++ cxx/build.rs | 7 + cxx/cbindgen.toml | 1 + cxx/src/database.rs | 281 +++++++++++++++++++++ cxx/src/game.rs | 196 +++++++++++++++ cxx/src/lib.rs | 569 ++++++++++++++++++++++++++++++++++++++++++ cxx/src/metadata.rs | 592 ++++++++++++++++++++++++++++++++++++++++++++ cxx/src/plugin.rs | 88 +++++++ 13 files changed, 2047 insertions(+) create mode 100644 cxx/.gitignore create mode 100644 cxx/CMakeLists.txt create mode 100644 cxx/Cargo.toml create mode 100644 cxx/README.md create mode 100644 cxx/build.rs create mode 100644 cxx/cbindgen.toml create mode 100644 cxx/src/database.rs create mode 100644 cxx/src/game.rs create mode 100644 cxx/src/lib.rs create mode 100644 cxx/src/metadata.rs create mode 100644 cxx/src/plugin.rs diff --git a/Cargo.lock b/Cargo.lock index 3c7e598d..04a5fa64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + [[package]] name = "arraydeque" version = "0.5.1" @@ -77,12 +83,57 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cc" +version = "1.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +dependencies = [ + "shlex", +] + [[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.5.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027bb0d98429ae334a8698531da7077bdf906419543a35a55c2cb1b66437d767" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5589e0cba072e0f3d23791efac0fd8627b49c829c196a492e88168e6a669d863" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + [[package]] name = "const-random" version = "0.1.18" @@ -162,6 +213,65 @@ dependencies = [ "typenum", ] +[[package]] +name = "cxx" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050906babad73f9b32a91cecc3063ff1e2235226dd2367dd839fd6fbc941c68a" +dependencies = [ + "cc", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875d58f2ac56025a775b91a424515b5adf1e68205765f2c90e6dd81e269ae004" +dependencies = [ + "cc", + "codespan-reporting", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c3062da294104183e1c34ea9887941d4d8c74f6195ce9fbb430ac4b5290ede" +dependencies = [ + "clap", + "codespan-reporting", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4b358173a166833ddef75fe468579f71727c789b8082d4cc77c38d08f656c59" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9531217f3b5f7728244d2b7312bc6660f6b3e4cdbc118f4f1fbce48cb401a0f" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "dataview" version = "1.0.1" @@ -171,6 +281,17 @@ dependencies = [ "derive_pod", ] +[[package]] +name = "delegate" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297806318ef30ad066b15792a8372858020ae3ca2e414ee6c2133b1eb9e9e945" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_pod" version = "0.1.2" @@ -283,6 +404,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "foldhash" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" + [[package]] name = "futures-core" version = "0.3.31" @@ -454,6 +581,16 @@ dependencies = [ "windows", ] +[[package]] +name = "libloot-cxx" +version = "0.25.3" +dependencies = [ + "cxx", + "cxx-build", + "delegate", + "libloot", +] + [[package]] name = "libredox" version = "0.1.3" @@ -464,6 +601,15 @@ dependencies = [ "libc", ] +[[package]] +name = "link-cplusplus" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6f6da007f968f9def0d65a05b187e2960183de70c160204ecfccf0ee330212" +dependencies = [ + "cc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -841,6 +987,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + [[package]] name = "saphyr" version = "0.0.3" @@ -863,6 +1015,12 @@ dependencies = [ "hashlink", ] +[[package]] +name = "scratch" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f6280af86e5f559536da57a45ebc84948833b3bee313a7dd25232e09c878a52" + [[package]] name = "semver" version = "1.0.26" @@ -880,6 +1038,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "slab" version = "0.4.9" @@ -889,6 +1053,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.98" @@ -914,6 +1084,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1010,6 +1189,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "version_check" version = "0.9.5" @@ -1047,6 +1232,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 24ab2fcf..cb2eb6ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,6 @@ windows = { version = "0.60.0", features = ["Win32_Storage_FileSystem"] } rstest = "0.25.0" rstest_reuse = "0.7.0" tempfile = "3.17.1" + +[workspace] +members = ["cxx"] diff --git a/cxx/.gitignore b/cxx/.gitignore new file mode 100644 index 00000000..e9881e13 --- /dev/null +++ b/cxx/.gitignore @@ -0,0 +1,2 @@ +/build +/include diff --git a/cxx/CMakeLists.txt b/cxx/CMakeLists.txt new file mode 100644 index 00000000..1eab6eb3 --- /dev/null +++ b/cxx/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 2.8) +project(ffi_tests CXX) + +include_directories("${CMAKE_SOURCE_DIR}/../target/cxxbridge") + +set(CMAKE_CXX_STANDARD 17) + +include(FetchContent) +include(GoogleTest) + +set(BUILD_GMOCK OFF) +set(gtest_force_shared_crt ON) +set(INSTALL_GTEST OFF) +# Google Test doesn't have a scoped equivalent to BUILD_SHARED_LIBS, so set the +# global value and then set it back to the original value once Google Test has +# been configured. +set(BUILD_SHARED_LIBS_INITIAL ${BUILD_SHARED_LIBS}) +set(BUILD_SHARED_LIBS OFF) +FetchContent_Declare( + GTest + URL "https://github.com/google/googletest/archive/refs/tags/v1.15.2.tar.gz" + URL_HASH "SHA256=7b42b4d6ed48810c5362c265a17faebe90dc2373c885e5216439d37927f02926" + FIND_PACKAGE_ARGS) + +FetchContent_MakeAvailable(GTest) + +if (CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + set (SYSTEM_LIBS pthread dl) +endif () + +if (MSVC) + set (SYSTEM_LIBS ntdll ws2_32 Userenv bcrypt) +endif () + +set (FFI_LIBRARY "${CMAKE_SOURCE_DIR}/../target/debug/libloot_cxx.lib") + +add_executable(ffi_cpp_tests "${CMAKE_SOURCE_DIR}/tests/ffi.cpp") +target_link_libraries(ffi_cpp_tests ${FFI_LIBRARY} ${SYSTEM_LIBS} GTest::gtest_main) + +enable_testing() +gtest_discover_tests(ffi_cpp_tests DISCOVERY_TIMEOUT 10) diff --git a/cxx/Cargo.toml b/cxx/Cargo.toml new file mode 100644 index 00000000..b6c80298 --- /dev/null +++ b/cxx/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "libloot-cxx" +version = "0.25.3" +edition = "2024" +license = "GPL-3.0" + +[dependencies] +cxx = { version = "1.0", features = ["c++17"] } +delegate = "0.13.2" +libloot = { path = ".." } + +[build-dependencies] +cxx-build = "1.0" + +[lib] +crate-type = ["staticlib"] diff --git a/cxx/README.md b/cxx/README.md new file mode 100644 index 00000000..5f1bf278 --- /dev/null +++ b/cxx/README.md @@ -0,0 +1,57 @@ +# libloot-rs C++ wrapper + +This is an **imcomplete** and **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface. + +## Current status + +- [x] Expose all API types, aside from `LogLevel` and error types +- [x] Expose all getters and setters on metadata types +- [x] Expose all other methods on `PluginMetadata` +- [x] Expose creator functions (`new_*`) for all metadata types +- [x] Expose `Group::DEFAULT_NAME` (using an accessor function) +- [x] Expose `MessageContent::DEFAULT_LANGUAGE` (using an accessor function) +- [x] Expose `select_message_content()` +- [x] Expose `is_compatible()` +- [x] Expose `libloot_revision()` +- [x] Expose `libloot_version()` +- [x] Expose `LIBLOOT_VERSION_*` constants (as unmangled extern C statics) +- [ ] Expose `LogLevel` +- [ ] Expose `set_logging_callback()` +- [ ] Write tests to cover API usage patterns +- [ ] Write a C++ layer that provides the same API as the C++ implementation of libloot + +## Building + +The wrapper is currently built as a static library using Cargo and cbindgen. + +``` +cargo build --release +cbindgen -o include/libloot.h +``` + +cbindgen is needed to create a header that contains the C FFI exports. It needs to be v0.28.0 or higher to support the Rust 2024 edition's unsafe attributes. The header needs to be included into C++ code separately from the CXX-generated header. While CXX does support including headers, it produces the wrong include path in the C++ it generates due to this crate being a member of a workspace, so the C++ side expects the header in a subdirectory that doesn't exist. + +There are also a small number of C++ tests that can be built and run using CMake: + +``` +cmake -B build . +cmake --build build --config RelWithDebInfo +ctest --test-dir build --output-on-failure -V +``` + +To package the build: + +``` +cpack --config build/CPackConfig.cmake +``` + +This repository (and so the created package) doesn't currently include any of libloot's documentation, besides the API documentation included in the Rust source code. + +## Usage notes + +- `LIBLOOT_VERSION_MAJOR`, `LIBLOOT_VERSION_MINOR` and `LIBLOOT_VERSION_PATCH` are exposed as `extern "C"` `static unsigned int` globals. +- CXX doesn't provide integration between Rust's `Option<_>` and C++'s `std::optional`, so sentinel values are used to communicate the absence of a value in the wrapper's API: + - For `Option<&str>`, `None` is represented using an empty string, which is how it's already done for many metadata object fields in the C++ implementation of libloot. + - For `Option`, `None` is represented using `NaN`, since the C++ implementation of libloot's public API already says that `NaN` values get converted to `None`. + - Plugin CRCs (which are `Option` in Rust) are represented as `int64_t`, with `-1` representing `None`, since all `uint32_t` values are possibly valid CRCs. +- All Rust errors are converted to `::rust::Error` exceptions that have a `what()` string that is the concatenation of the error's display string and all its recursive source error display strings. diff --git a/cxx/build.rs b/cxx/build.rs new file mode 100644 index 00000000..92388cc9 --- /dev/null +++ b/cxx/build.rs @@ -0,0 +1,7 @@ +fn main() { + cxx_build::bridge("src/lib.rs") + .std("c++17") + .compile("libloot-cxx"); + + println!("cargo:rerun-if-changed=src/lib.rs"); +} diff --git a/cxx/cbindgen.toml b/cxx/cbindgen.toml new file mode 100644 index 00000000..87df20f0 --- /dev/null +++ b/cxx/cbindgen.toml @@ -0,0 +1 @@ +namespace = "loot" diff --git a/cxx/src/database.rs b/cxx/src/database.rs new file mode 100644 index 00000000..1f684894 --- /dev/null +++ b/cxx/src/database.rs @@ -0,0 +1,281 @@ +use std::{ + path::Path, + sync::{Arc, RwLock}, +}; + +use delegate::delegate; +use libloot::error::DatabaseLockPoisonError; + +use crate::{ + UnsupportedEnumValueError, VerboseError, + ffi::EdgeType, + metadata::{Group, Message, OptionalPluginMetadata, PluginMetadata, to_vec_of_unwrapped}, +}; + +#[derive(Debug)] +#[repr(transparent)] +pub struct Database(Arc>); + +impl Database { + pub fn new(db: Arc>) -> Self { + Self(db) + } + + pub fn load_masterlist(&self, path: &str) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .load_masterlist(Path::new(path)) + .map_err(Into::into) + } + + pub fn load_masterlist_with_prelude( + &self, + masterlist_path: &str, + prelude_path: &str, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .load_masterlist_with_prelude(Path::new(masterlist_path), Path::new(prelude_path)) + .map_err(Into::into) + } + + pub fn load_userlist(&self, path: &str) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .load_userlist(Path::new(path)) + .map_err(Into::into) + } + + pub fn write_user_metadata( + &self, + output_path: &str, + overwrite: bool, + ) -> Result<(), VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .write_user_metadata(Path::new(output_path), overwrite) + .map_err(Into::into) + } + + pub fn write_minimal_list( + &self, + output_path: &str, + overwrite: bool, + ) -> Result<(), VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .write_minimal_list(Path::new(output_path), overwrite) + .map_err(Into::into) + } + + pub fn known_bash_tags(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .known_bash_tags()) + } + + pub fn general_messages( + &self, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .general_messages(evaluate_conditions) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + pub fn groups(&self, include_user_metadata: bool) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .groups(include_user_metadata) + .into_iter() + .map(Into::into) + .collect()) + } + + // I tried returning a GroupRef<'_> here, but it borrows from the DB read guard, which the borrow checker sees as an owned value within this scope, so won't allow me to return a reference to it. This is the only place that uses a group reference, so it's probably not worth figuring out a workaround, and cloning the groups is fine. + pub fn user_groups(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .user_groups() + .iter() + .cloned() + .map(Into::into) + .collect()) + } + + // This is ugly, but Group can't be held as a value in C++ and CXX doesn't support Vec> as a parameter, so this can't take ownership of the input groups. + pub fn set_user_groups(&self, groups: &[Box]) -> Result<(), VerboseError> { + let groups = to_vec_of_unwrapped(groups); + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .set_user_groups(groups); + Ok(()) + } + + pub fn groups_path( + &self, + from_group_name: &str, + to_group_name: &str, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .groups_path(from_group_name, to_group_name) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + pub fn plugin_metadata( + &self, + plugin_name: &str, + include_user_metadata: bool, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .plugin_metadata(plugin_name, include_user_metadata, evaluate_conditions) + .map(|p| Box::new(p.map(Into::into).into())) + .map_err(Into::into) + } + + pub fn plugin_user_metadata( + &self, + plugin_name: &str, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .plugin_user_metadata(plugin_name, evaluate_conditions) + .map(|p| Box::new(p.map(Into::into).into())) + .map_err(Into::into) + } + + pub fn set_plugin_user_metadata( + &mut self, + plugin_metadata: Box, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .set_plugin_user_metadata(plugin_metadata.into()); + Ok(()) + } + + pub fn discard_plugin_user_metadata(&self, plugin: &str) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .discard_plugin_user_metadata(plugin); + Ok(()) + } + + pub fn discard_all_user_metadata(&self) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .discard_all_user_metadata(); + Ok(()) + } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Vertex(libloot::Vertex); + +pub fn new_vertex(name: String) -> Box { + Box::new(Vertex(libloot::Vertex::new(name))) +} + +impl Vertex { + // A value of 255 is used to indicate that there is no out edge. + pub fn out_edge_type(&self) -> Result { + match self.0.out_edge_type() { + Some(e) => Ok(EdgeType::try_from(e)?.repr), + None => Ok(u8::MAX), + } + } + + pub fn set_out_edge_type(&mut self, out_edge_type: EdgeType) -> Result<(), VerboseError> { + self.0.set_out_edge_type(out_edge_type.try_into()?); + Ok(()) + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn name(&self) -> &str; + } + } +} + +impl From for Vertex { + fn from(value: libloot::Vertex) -> Self { + Self(value) + } +} + +impl TryFrom for EdgeType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::EdgeType) -> Result { + match value { + libloot::EdgeType::Hardcoded => Ok(EdgeType::hardcoded), + libloot::EdgeType::MasterFlag => Ok(EdgeType::masterFlag), + libloot::EdgeType::Master => Ok(EdgeType::master), + libloot::EdgeType::MasterlistRequirement => Ok(EdgeType::masterlistRequirement), + libloot::EdgeType::UserRequirement => Ok(EdgeType::userRequirement), + libloot::EdgeType::MasterlistLoadAfter => Ok(EdgeType::masterlistLoadAfter), + libloot::EdgeType::UserLoadAfter => Ok(EdgeType::userLoadAfter), + libloot::EdgeType::MasterlistGroup => Ok(EdgeType::masterlistGroup), + libloot::EdgeType::UserGroup => Ok(EdgeType::userGroup), + libloot::EdgeType::RecordOverlap => Ok(EdgeType::recordOverlap), + libloot::EdgeType::AssetOverlap => Ok(EdgeType::assetOverlap), + libloot::EdgeType::TieBreak => Ok(EdgeType::tieBreak), + libloot::EdgeType::BlueprintMaster => Ok(EdgeType::blueprintMaster), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl TryFrom for libloot::EdgeType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: EdgeType) -> Result { + match value { + EdgeType::hardcoded => Ok(libloot::EdgeType::Hardcoded), + EdgeType::masterFlag => Ok(libloot::EdgeType::MasterFlag), + EdgeType::master => Ok(libloot::EdgeType::Master), + EdgeType::masterlistRequirement => Ok(libloot::EdgeType::MasterlistRequirement), + EdgeType::userRequirement => Ok(libloot::EdgeType::UserRequirement), + EdgeType::masterlistLoadAfter => Ok(libloot::EdgeType::MasterlistLoadAfter), + EdgeType::userLoadAfter => Ok(libloot::EdgeType::UserLoadAfter), + EdgeType::masterlistGroup => Ok(libloot::EdgeType::MasterlistGroup), + EdgeType::userGroup => Ok(libloot::EdgeType::UserGroup), + EdgeType::recordOverlap => Ok(libloot::EdgeType::RecordOverlap), + EdgeType::assetOverlap => Ok(libloot::EdgeType::AssetOverlap), + EdgeType::tieBreak => Ok(libloot::EdgeType::TieBreak), + EdgeType::blueprintMaster => Ok(libloot::EdgeType::BlueprintMaster), + _ => Err(UnsupportedEnumValueError), + } + } +} diff --git a/cxx/src/game.rs b/cxx/src/game.rs new file mode 100644 index 00000000..147ebfa7 --- /dev/null +++ b/cxx/src/game.rs @@ -0,0 +1,196 @@ +use std::path::Path; + +use delegate::delegate; + +use crate::{ + PluginRef, UnsupportedEnumValueError, VerboseError, database::Database, ffi::GameType, + plugin::OptionalPluginRef, +}; + +impl TryFrom for GameType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::GameType) -> Result { + match value { + libloot::GameType::TES4 => Ok(GameType::tes4), + libloot::GameType::TES5 => Ok(GameType::tes5), + libloot::GameType::FO3 => Ok(GameType::fo3), + libloot::GameType::FONV => Ok(GameType::fonv), + libloot::GameType::FO4 => Ok(GameType::fo4), + libloot::GameType::TES5SE => Ok(GameType::tes5se), + libloot::GameType::FO4VR => Ok(GameType::fo4vr), + libloot::GameType::TES5VR => Ok(GameType::tes5vr), + libloot::GameType::TES3 => Ok(GameType::tes3), + libloot::GameType::Starfield => Ok(GameType::starfield), + libloot::GameType::OpenMW => Ok(GameType::openmw), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl TryFrom for libloot::GameType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: GameType) -> Result { + match value { + GameType::tes4 => Ok(libloot::GameType::TES4), + GameType::tes5 => Ok(libloot::GameType::TES5), + GameType::fo3 => Ok(libloot::GameType::FO3), + GameType::fonv => Ok(libloot::GameType::FONV), + GameType::fo4 => Ok(libloot::GameType::FO4), + GameType::tes5se => Ok(libloot::GameType::TES5SE), + GameType::fo4vr => Ok(libloot::GameType::FO4VR), + GameType::tes5vr => Ok(libloot::GameType::TES5VR), + GameType::tes3 => Ok(libloot::GameType::TES3), + GameType::starfield => Ok(libloot::GameType::Starfield), + GameType::openmw => Ok(libloot::GameType::OpenMW), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl From for libloot::Game { + fn from(value: Game) -> Self { + value.0 + } +} + +impl From for Game { + fn from(value: libloot::Game) -> Self { + Game(value) + } +} + +#[derive(Debug)] +pub struct NotValidUtf8; + +impl std::fmt::Display for NotValidUtf8 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Value was not valid UTF-8") + } +} + +impl std::error::Error for NotValidUtf8 {} + +#[derive(Debug)] +#[repr(transparent)] +pub struct Game(libloot::Game); + +// CXX doesn't support &Path so use &str instead. +pub fn new_game(game_type: GameType, game_path: &str) -> Result, VerboseError> { + libloot::Game::new(game_type.try_into()?, Path::new(game_path)) + .map(|g| Box::new(g.into())) + .map_err(Into::into) +} + +pub fn new_game_with_local_path( + game_type: GameType, + game_path: &str, + game_local_path: &str, +) -> Result, VerboseError> { + libloot::Game::with_local_path( + game_type.try_into()?, + Path::new(game_path), + Path::new(game_local_path), + ) + .map(|g| Box::new(g.into())) + .map_err(Into::into) +} + +fn path_to_string(path: &Path) -> Result { + path.to_str() + .map(|s| s.to_owned()) + .ok_or(NotValidUtf8) + .map_err(Into::into) +} + +fn strings_to_paths<'a>(paths: &'a [&'a str]) -> Vec<&'a Path> { + paths.iter().map(Path::new).collect() +} + +impl Game { + pub fn game_type(&self) -> Result { + self.0.game_type().try_into().map_err(Into::into) + } + + pub fn additional_data_paths(&self) -> Result, VerboseError> { + self.0 + .additional_data_paths() + .iter() + .map(|p| path_to_string(p)) + .collect() + } + + pub fn set_additional_data_paths( + &mut self, + additional_data_paths: &[&str], + ) -> Result<(), VerboseError> { + self.0 + .set_additional_data_paths(&strings_to_paths(additional_data_paths)) + .map_err(Into::into) + } + + pub fn database(&self) -> Box { + Box::new(Database::new(self.0.database())) + } + + pub fn is_valid_plugin(&self, plugin_path: &str) -> bool { + self.0.is_valid_plugin(Path::new(plugin_path)) + } + + pub fn load_plugins(&mut self, plugin_paths: &[&str]) -> Result<(), VerboseError> { + self.0 + .load_plugins(&strings_to_paths(plugin_paths)) + .map_err(Into::into) + } + + pub fn load_plugin_headers(&mut self, plugin_paths: &[&str]) -> Result<(), VerboseError> { + self.0 + .load_plugin_headers(&strings_to_paths(plugin_paths)) + .map_err(Into::into) + } + + pub fn plugin(&self, plugin_name: &str) -> Box { + Box::new(self.0.plugin(plugin_name).into()) + } + + pub fn loaded_plugins(&self) -> Vec> { + self.0 + .loaded_plugins() + .into_iter() + .map(Into::into) + .collect() + } + + pub fn sort_plugins(&self, plugin_names: &[&str]) -> Result, VerboseError> { + self.0.sort_plugins(plugin_names).map_err(Into::into) + } + + pub fn load_current_load_order_state(&mut self) -> Result<(), VerboseError> { + self.0.load_current_load_order_state().map_err(Into::into) + } + + pub fn is_load_order_ambiguous(&self) -> Result { + self.0.is_load_order_ambiguous().map_err(Into::into) + } + + pub fn active_plugins_file_path(&self) -> Result { + path_to_string(self.0.active_plugins_file_path()) + } + + pub fn load_order(&self) -> Vec { + self.0.load_order().iter().map(|s| s.to_string()).collect() + } + + pub fn set_load_order(&mut self, load_order: &[&str]) -> Result<(), VerboseError> { + self.0.set_load_order(load_order).map_err(Into::into) + } + + delegate! { + to self.0 { + pub fn clear_loaded_plugins(&mut self); + + pub fn is_plugin_active(&self, plugin_name: &str) -> bool; + } + } +} diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs new file mode 100644 index 00000000..9e3091eb --- /dev/null +++ b/cxx/src/lib.rs @@ -0,0 +1,569 @@ +mod database; +mod game; +mod metadata; +mod plugin; + +use database::{Database, Vertex, new_vertex}; +use game::{Game, new_game, new_game_with_local_path}; +use metadata::{ + File, Filename, Group, Location, Message, MessageContent, OptionalMessageContentRef, + OptionalPluginMetadata, PluginCleaningData, PluginMetadata, Tag, group_default_name, + message_content_default_language, multilingual_message, new_file, new_filename, new_group, + new_location, new_message, new_message_content, new_plugin_cleaning_data, new_plugin_metadata, + new_tag, select_message_content, +}; +use plugin::{OptionalPluginRef, PluginRef}; +use std::ffi::{CString, c_char, c_uint}; + +use libloot::set_logging_callback; +pub use libloot::{is_compatible, libloot_revision, libloot_version}; + +#[derive(Debug)] +pub struct VerboseError(Box); + +impl std::fmt::Display for VerboseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0)?; + let mut error = self.0.as_ref(); + while let Some(source) = error.source() { + write!(f, ": {}", source)?; + error = source; + } + Ok(()) + } +} + +impl From for VerboseError { + fn from(value: E) -> Self { + VerboseError(Box::new(value)) + } +} + +#[derive(Debug)] +pub struct OptionalRef(*const T); + +impl OptionalRef { + pub fn is_some(&self) -> bool { + !self.0.is_null() + } +} + +impl From> for OptionalRef { + fn from(value: Option<&T>) -> Self { + match value { + Some(p) => OptionalRef(p), + None => OptionalRef(std::ptr::null()), + } + } +} + +#[derive(Debug)] +pub struct Optional(Option); + +impl Optional { + pub fn is_some(&self) -> bool { + self.0.is_some() + } + + pub fn as_ref(&self) -> Result<&T, EmptyOptionalError> { + match &self.0 { + Some(t) => Ok(t), + None => Err(EmptyOptionalError), + } + } +} + +#[derive(Debug)] +pub struct EmptyOptionalError; + +impl std::fmt::Display for EmptyOptionalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "optional is empty") + } +} + +impl std::error::Error for EmptyOptionalError {} + +#[derive(Debug)] +pub struct UnsupportedEnumValueError; + +impl std::fmt::Display for UnsupportedEnumValueError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Enum value is unsupported") + } +} + +impl std::error::Error for UnsupportedEnumValueError {} + +#[allow(clippy::needless_lifetimes)] +#[cxx::bridge(namespace = "loot")] +mod ffi { + + pub enum GameType { + tes4, + tes5, + fo3, + fonv, + fo4, + tes5se, + fo4vr, + tes5vr, + tes3, + starfield, + openmw, + } + + pub enum MessageType { + say, + warn, + error, + } + + pub enum TagSuggestion { + Addition, + Removal, + } + + pub enum EdgeType { + hardcoded, + masterFlag, + master, + masterlistRequirement, + userRequirement, + masterlistLoadAfter, + userLoadAfter, + masterlistGroup, + userGroup, + recordOverlap, + assetOverlap, + tieBreak, + blueprintMaster, + } + + extern "Rust" { + fn is_compatible(major: u32, minor: u32, patch: u32) -> bool; + fn libloot_version() -> String; + fn libloot_revision() -> String; + + fn select_message_content( + contents: &[MessageContent], + language: &str, + ) -> Box; + } + + extern "Rust" { + type Game; + + fn new_game(game_type: GameType, game_path: &str) -> Result>; + + fn new_game_with_local_path( + game_type: GameType, + game_path: &str, + game_local_path: &str, + ) -> Result>; + + pub fn game_type(&self) -> Result; + + pub fn additional_data_paths(&self) -> Result>; + + pub fn set_additional_data_paths(&mut self, additional_data_paths: &[&str]) -> Result<()>; + + pub fn database(&self) -> Box; + + pub fn is_valid_plugin(&self, plugin_path: &str) -> bool; + + pub fn load_plugins(&mut self, plugin_paths: &[&str]) -> Result<()>; + + pub fn load_plugin_headers(&mut self, plugin_paths: &[&str]) -> Result<()>; + + pub fn clear_loaded_plugins(&mut self); + + // The plugin's lifetime is actually less than &Game's, it's only valid so long as the loaded plugin is not overwritten or cleared. + pub fn plugin(&self, plugin_name: &str) -> Box; + + // The plugin's lifetime is actually less than &Game's, it's only valid so long as the loaded plugin is not overwritten or cleared. + pub unsafe fn loaded_plugins<'a>(&'a self) -> Vec>; + + pub fn sort_plugins(&self, plugin_names: &[&str]) -> Result>; + + pub fn load_current_load_order_state(&mut self) -> Result<()>; + + pub fn is_load_order_ambiguous(&self) -> Result; + + pub fn active_plugins_file_path(&self) -> Result; + + pub fn is_plugin_active(&self, plugin_name: &str) -> bool; + + pub fn load_order(&self) -> Vec; + + pub fn set_load_order(&mut self, load_order: &[&str]) -> Result<()>; + } + + extern "Rust" { + type Database; + + pub fn load_masterlist(&self, path: &str) -> Result<()>; + + pub fn load_masterlist_with_prelude( + &self, + masterlist_path: &str, + prelude_path: &str, + ) -> Result<()>; + + pub fn load_userlist(&self, path: &str) -> Result<()>; + + pub fn write_user_metadata(&self, output_path: &str, overwrite: bool) -> Result<()>; + + pub fn write_minimal_list(&self, output_path: &str, overwrite: bool) -> Result<()>; + + pub fn known_bash_tags(&self) -> Result>; + + pub fn general_messages(&self, evaluate_conditions: bool) -> Result>; + + pub fn groups(&self, include_user_metadata: bool) -> Result>; + + pub fn user_groups(&self) -> Result>; + + pub fn set_user_groups(&self, groups: &[Box]) -> Result<()>; + + pub fn groups_path( + &self, + from_group_name: &str, + to_group_name: &str, + ) -> Result>; + + pub fn plugin_metadata( + &self, + plugin_name: &str, + include_user_metadata: bool, + evaluate_conditions: bool, + ) -> Result>; + + pub fn plugin_user_metadata( + &self, + plugin_name: &str, + evaluate_conditions: bool, + ) -> Result>; + + pub fn set_plugin_user_metadata( + &mut self, + plugin_metadata: Box, + ) -> Result<()>; + + pub fn discard_plugin_user_metadata(&self, plugin: &str) -> Result<()>; + + pub fn discard_all_user_metadata(&self) -> Result<()>; + } + + extern "Rust" { + type Message; + + pub fn new_message(message_type: MessageType, content: String) -> Result>; + + pub fn multilingual_message( + message_type: MessageType, + contents: &[Box], + ) -> Result>; + + pub fn message_type(&self) -> MessageType; + + pub fn content(&self) -> &[MessageContent]; + + pub fn condition(&self) -> &str; + + pub fn set_condition(&mut self, condition: String); + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type MessageContent; + + pub fn message_content_default_language() -> &'static str; + + pub fn new_message_content(text: String) -> Box; + + pub fn text(&self) -> &str; + + pub fn language(&self) -> &str; + + pub fn set_language(&mut self, language: String); + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type Group; + + pub fn new_group(name: String) -> Box; + + pub fn group_default_name() -> &'static str; + + pub fn boxed_clone(&self) -> Box; + + pub fn name(&self) -> &str; + + pub fn description(&self) -> &str; + + pub fn set_description(&mut self, description: String); + + pub fn after_groups(&self) -> &[String]; + + pub fn set_after_groups(&mut self, groups: Vec); + } + + extern "Rust" { + type PluginRef<'a>; + + pub unsafe fn name<'a>(&'a self) -> &'a str; + + // The None case is signalled by NaN. + pub fn header_version(&self) -> f32; + + // The None case is signalled by an empty string (which is not a valid version). + pub unsafe fn version<'a>(&'a self) -> &'a str; + + pub fn masters(&self) -> Result>; + + pub unsafe fn bash_tags<'a>(&'a self) -> &'a [String]; + + // The None case is signalled by -1, all other values fit in u32. + pub fn crc(&self) -> i64; + + pub fn is_master(&self) -> bool; + + pub fn is_light_plugin(&self) -> bool; + + pub fn is_medium_plugin(&self) -> bool; + + pub fn is_update_plugin(&self) -> bool; + + pub fn is_blueprint_plugin(&self) -> bool; + + pub fn is_valid_as_light_plugin(&self) -> Result; + + pub fn is_valid_as_medium_plugin(&self) -> Result; + + pub fn is_valid_as_update_plugin(&self) -> Result; + + pub fn is_empty(&self) -> bool; + + pub fn loads_archive(&self) -> bool; + + pub unsafe fn do_records_overlap<'a>(&self, plugin: &PluginRef<'a>) -> Result; + } + + extern "Rust" { + type OptionalPluginRef; + + pub fn is_some(&self) -> bool; + + // Again, these lifetimes are wrong. + pub unsafe fn as_ref<'a>(&'a self) -> Result>>; + } + + extern "Rust" { + type OptionalMessageContentRef; + + pub fn is_some(&self) -> bool; + + // Again, these lifetimes are wrong. + pub unsafe fn as_ref<'a>(&'a self) -> Result<&'a MessageContent>; + } + + extern "Rust" { + type Vertex; + + pub fn new_vertex(name: String) -> Box; + + pub fn name(&self) -> &str; + + pub fn out_edge_type(&self) -> Result; + + pub fn set_out_edge_type(&mut self, #[into] out_edge_type: EdgeType) -> Result<()>; + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type OptionalPluginMetadata; + + pub fn is_some(&self) -> bool; + + // Again, these lifetimes are wrong. + pub unsafe fn as_ref<'a>(&'a self) -> Result<&'a PluginMetadata>; + } + + extern "Rust" { + type PluginMetadata; + + pub fn new_plugin_metadata(name: &str) -> Result>; + + pub fn name(&self) -> &str; + + pub fn group(&self) -> &str; + + pub fn load_after_files(&self) -> &[File]; + + pub fn requirements(&self) -> &[File]; + + pub fn incompatibilities(&self) -> &[File]; + + pub fn messages(&self) -> &[Message]; + + pub fn tags(&self) -> &[Tag]; + + pub fn dirty_info(&self) -> &[PluginCleaningData]; + + pub fn clean_info(&self) -> &[PluginCleaningData]; + + pub fn locations(&self) -> &[Location]; + + pub fn set_group(&mut self, group: String); + + pub fn unset_group(&mut self); + + pub fn set_load_after_files(&mut self, files: &[Box]); + + pub fn set_requirements(&mut self, files: &[Box]); + + pub fn set_incompatibilities(&mut self, files: &[Box]); + + pub fn set_messages(&mut self, messages: &[Box]); + + pub fn set_tags(&mut self, tags: &[Box]); + + pub fn set_dirty_info(&mut self, info: &[Box]); + + pub fn set_clean_info(&mut self, info: &[Box]); + + pub fn set_locations(&mut self, locations: &[Box]); + + pub fn merge_metadata(&mut self, plugin: &PluginMetadata); + + pub fn has_name_only(&self) -> bool; + + pub fn is_regex_plugin(&self) -> bool; + + pub fn name_matches(&self, other_name: &str) -> bool; + + pub fn as_yaml(&self) -> String; + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type File; + + pub fn new_file(name: String) -> Box; + + pub fn filename(&self) -> &Filename; + + pub fn display_name(&self) -> &str; + + pub fn set_display_name(&mut self, display_name: String); + + pub fn detail(&self) -> &[MessageContent]; + + pub fn set_detail(&mut self, detail: &[Box]) -> Result<()>; + + pub fn condition(&self) -> &str; + + pub fn set_condition(&mut self, condition: String); + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type Filename; + + pub fn new_filename(name: String) -> Box; + + pub fn as_str(&self) -> &str; + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type Tag; + + pub fn new_tag(name: String, suggestion: TagSuggestion) -> Result>; + + pub fn name(&self) -> &str; + + pub fn is_addition(&self) -> bool; + + pub fn condition(&self) -> &str; + + pub fn set_condition(&mut self, condition: String); + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type PluginCleaningData; + + pub fn new_plugin_cleaning_data( + crc: u32, + cleaning_utility: String, + ) -> Box; + + pub fn crc(&self) -> u32; + + pub fn itm_count(&self) -> u32; + + pub fn set_itm_count(&mut self, count: u32); + + pub fn deleted_reference_count(&self) -> u32; + + pub fn set_deleted_reference_count(&mut self, count: u32); + + pub fn deleted_navmesh_count(&self) -> u32; + + pub fn set_deleted_navmesh_count(&mut self, count: u32); + + pub fn cleaning_utility(&self) -> &str; + + pub fn detail(&self) -> &[MessageContent]; + + pub fn set_detail(&mut self, detail: &[Box]) -> Result<()>; + + pub fn boxed_clone(&self) -> Box; + } + + extern "Rust" { + type Location; + + pub fn new_location(url: String) -> Box; + + pub fn url(&self) -> &str; + + pub fn name(&self) -> &str; + + pub fn set_name(&mut self, name: String); + + pub fn boxed_clone(&self) -> Box; + } +} + +#[unsafe(no_mangle)] +pub static LIBLOOT_VERSION_MAJOR: c_uint = libloot::LIBLOOT_VERSION_MAJOR; + +#[unsafe(no_mangle)] +pub static LIBLOOT_VERSION_MINOR: c_uint = libloot::LIBLOOT_VERSION_MINOR; + +#[unsafe(no_mangle)] +pub static LIBLOOT_VERSION_PATCH: c_uint = libloot::LIBLOOT_VERSION_PATCH; + +// TODO: Implement this properly. +#[unsafe(no_mangle)] +unsafe extern "C" fn libloot_set_logging_callback( + callback: unsafe extern "C" fn(u8, *const c_char), +) { + set_logging_callback(move |level, message| { + let c_string = CString::new(message).unwrap(); + unsafe { + callback(level as u8, c_string.as_ptr()); + } + }); +} diff --git a/cxx/src/metadata.rs b/cxx/src/metadata.rs new file mode 100644 index 00000000..fe228775 --- /dev/null +++ b/cxx/src/metadata.rs @@ -0,0 +1,592 @@ +use delegate::delegate; + +use crate::{ + EmptyOptionalError, Optional, OptionalRef, UnsupportedEnumValueError, VerboseError, + ffi::{MessageType, TagSuggestion}, +}; + +impl From for MessageType { + fn from(value: libloot::metadata::MessageType) -> Self { + match value { + libloot::metadata::MessageType::Say => MessageType::say, + libloot::metadata::MessageType::Warn => MessageType::warn, + libloot::metadata::MessageType::Error => MessageType::error, + } + } +} + +impl TryFrom for libloot::metadata::MessageType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: MessageType) -> Result { + match value { + MessageType::say => Ok(libloot::metadata::MessageType::Say), + MessageType::warn => Ok(libloot::metadata::MessageType::Warn), + MessageType::error => Ok(libloot::metadata::MessageType::Error), + _ => Err(UnsupportedEnumValueError), + } + } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct MessageContent(libloot::metadata::MessageContent); + +pub fn new_message_content(text: String) -> Box { + Box::new(MessageContent(libloot::metadata::MessageContent::new(text))) +} + +pub fn message_content_default_language() -> &'static str { + libloot::metadata::MessageContent::DEFAULT_LANGUAGE +} + +impl MessageContent { + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn text(&self) -> &str; + + pub fn language(&self) -> &str; + + pub fn set_language(&mut self, language: String); + } + } +} + +impl From for libloot::metadata::MessageContent { + fn from(value: MessageContent) -> Self { + value.0 + } +} + +impl From> for libloot::metadata::MessageContent { + fn from(value: Box) -> Self { + value.0 + } +} + +fn wrap_message_content_ref(unwrapped: &libloot::metadata::MessageContent) -> &MessageContent { + let v = unwrapped as *const libloot::metadata::MessageContent; + // SAFETY: MessageContent is a transparent wrapper, so reinterpreting the pointer is safe. + unsafe { + let v = v as *const MessageContent; + &*v + } +} + +fn wrap_message_contents(slice: &[libloot::metadata::MessageContent]) -> &[MessageContent] { + // SAFETY: This is safe because MessageContent is a transparent wrapper around the libloot type. + unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } +} + +fn unwrap_message_contents(slice: &[MessageContent]) -> &[libloot::metadata::MessageContent] { + // SAFETY: This is safe because MessageContent is a transparent wrapper around the libloot type. + unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } +} + +pub type OptionalMessageContentRef = OptionalRef; + +impl OptionalRef { + /// # Safety + /// + /// This is safe as long as the pointer in the OptionalRef is still valid. + pub unsafe fn as_ref(&self) -> Result<&MessageContent, EmptyOptionalError> { + if self.0.is_null() { + Err(EmptyOptionalError) + } else { + unsafe { Ok(&*self.0) } + } + } +} + +pub fn select_message_content( + contents: &[MessageContent], + language: &str, +) -> Box { + let option = + libloot::metadata::select_message_content(unwrap_message_contents(contents), language); + + Box::new(option.map(wrap_message_content_ref).into()) +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Message(libloot::metadata::Message); + +pub fn new_message( + message_type: MessageType, + content: String, +) -> Result, VerboseError> { + Ok(Box::new(Message(libloot::metadata::Message::new( + message_type.try_into()?, + content, + )))) +} + +pub fn multilingual_message( + message_type: MessageType, + contents: &[Box], +) -> Result, VerboseError> { + let contents = to_vec_of_unwrapped(contents); + Ok(Box::new(Message(libloot::metadata::Message::multilingual( + message_type.try_into()?, + contents, + )?))) +} + +impl Message { + pub fn condition(&self) -> &str { + self.0.condition().unwrap_or("") + } + + pub fn content(&self) -> &[MessageContent] { + wrap_message_contents(self.0.content()) + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + #[into] + pub fn message_type(&self) -> MessageType; + + pub fn set_condition(&mut self, condition: String); + } + } +} + +impl From for Message { + fn from(value: libloot::metadata::Message) -> Self { + Self(value) + } +} + +impl From> for libloot::metadata::Message { + fn from(value: Box) -> Self { + value.0 + } +} + +fn wrap_messages(slice: &[libloot::metadata::Message]) -> &[Message] { + // SAFETY: This is safe because Message is a transparent wrapper around the libloot type. + unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Group(libloot::metadata::Group); + +pub fn new_group(name: String) -> Box { + Box::new(Group(libloot::metadata::Group::new(name))) +} + +pub fn group_default_name() -> &'static str { + libloot::metadata::Group::DEFAULT_NAME +} + +impl Group { + pub fn description(&self) -> &str { + self.0.description().unwrap_or("") + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn name(&self) -> &str; + + pub fn set_description(&mut self, description: String); + + pub fn after_groups(&self) -> &[String]; + + pub fn set_after_groups(&mut self, groups: Vec); + } + } +} + +impl From for Group { + fn from(value: libloot::metadata::Group) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Group { + fn from(value: Group) -> Self { + value.0 + } +} + +impl From> for libloot::metadata::Group { + fn from(value: Box) -> Self { + value.0 + } +} + +impl From> for Group { + fn from(value: Box) -> Self { + Self((*value).into()) + } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct PluginMetadata(libloot::metadata::PluginMetadata); + +pub fn new_plugin_metadata(name: &str) -> Result, VerboseError> { + Ok(Box::new(PluginMetadata( + libloot::metadata::PluginMetadata::new(name)?, + ))) +} + +impl PluginMetadata { + pub fn group(&self) -> &str { + self.0.group().unwrap_or("") + } + + pub fn load_after_files(&self) -> &[File] { + wrap_files(self.0.load_after_files()) + } + + pub fn requirements(&self) -> &[File] { + wrap_files(self.0.requirements()) + } + + pub fn incompatibilities(&self) -> &[File] { + wrap_files(self.0.incompatibilities()) + } + + pub fn messages(&self) -> &[Message] { + wrap_messages(self.0.messages()) + } + + pub fn tags(&self) -> &[Tag] { + wrap_tags(self.0.tags()) + } + + pub fn dirty_info(&self) -> &[PluginCleaningData] { + wrap_cleaning_data(self.0.dirty_info()) + } + + pub fn clean_info(&self) -> &[PluginCleaningData] { + wrap_cleaning_data(self.0.clean_info()) + } + + pub fn locations(&self) -> &[Location] { + wrap_locations(self.0.locations()) + } + + pub fn set_load_after_files(&mut self, files: &[Box]) { + self.0.set_load_after_files(to_vec_of_unwrapped(files)); + } + + pub fn set_requirements(&mut self, files: &[Box]) { + self.0.set_requirements(to_vec_of_unwrapped(files)); + } + + pub fn set_incompatibilities(&mut self, files: &[Box]) { + self.0.set_incompatibilities(to_vec_of_unwrapped(files)); + } + + pub fn set_messages(&mut self, messages: &[Box]) { + self.0.set_messages(to_vec_of_unwrapped(messages)); + } + + pub fn set_tags(&mut self, tags: &[Box]) { + self.0.set_tags(to_vec_of_unwrapped(tags)); + } + + pub fn set_dirty_info(&mut self, info: &[Box]) { + self.0.set_dirty_info(to_vec_of_unwrapped(info)); + } + + pub fn set_clean_info(&mut self, info: &[Box]) { + self.0.set_clean_info(to_vec_of_unwrapped(info)); + } + + pub fn set_locations(&mut self, locations: &[Box]) { + self.0.set_locations(to_vec_of_unwrapped(locations)); + } + + pub fn merge_metadata(&mut self, plugin: &PluginMetadata) { + self.0.merge_metadata(&plugin.0); + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn name(&self) -> &str; + + pub fn set_group(&mut self, group: String); + + pub fn unset_group(&mut self); + + pub fn has_name_only(&self) -> bool; + + pub fn is_regex_plugin(&self) -> bool; + + pub fn name_matches(&self, other_name: &str) -> bool; + + pub fn as_yaml(&self) -> String; + } + } +} + +impl From for PluginMetadata { + fn from(value: libloot::metadata::PluginMetadata) -> Self { + Self(value) + } +} + +impl From> for libloot::metadata::PluginMetadata { + fn from(value: Box) -> Self { + value.0 + } +} + +pub type OptionalPluginMetadata = Optional; + +impl From> for Optional { + fn from(value: Option) -> Self { + Self(value) + } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct File(libloot::metadata::File); + +pub fn new_file(name: String) -> Box { + Box::new(File(libloot::metadata::File::new(name))) +} + +impl File { + pub fn filename(&self) -> &Filename { + let f = self.0.name() as *const libloot::metadata::Filename; + // SAFETY: Filename is a transparent wrapper, so reinterpreting the pointer is safe. + unsafe { + let f = f as *const Filename; + &*f + } + } + + pub fn display_name(&self) -> &str { + self.0.display_name().unwrap_or("") + } + + pub fn set_display_name(&mut self, display_name: String) { + self.0.set_display_name(display_name); + } + + pub fn detail(&self) -> &[MessageContent] { + wrap_message_contents(self.0.detail()) + } + + pub fn set_detail(&mut self, detail: &[Box]) -> Result<(), VerboseError> { + self.0.set_detail(to_vec_of_unwrapped(detail))?; + Ok(()) + } + + pub fn condition(&self) -> &str { + self.0.condition().unwrap_or("") + } + + pub fn set_condition(&mut self, condition: String) { + self.0.set_condition(condition); + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } +} + +impl From> for libloot::metadata::File { + fn from(value: Box) -> Self { + value.0 + } +} + +fn wrap_files(slice: &[libloot::metadata::File]) -> &[File] { + // SAFETY: This is safe because File is a transparent wrapper around the libloot type. + unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Filename(libloot::metadata::Filename); + +pub fn new_filename(name: String) -> Box { + Box::new(Filename(libloot::metadata::Filename::new(name))) +} + +impl Filename { + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn as_str(&self) -> &str; + } + } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Tag(libloot::metadata::Tag); + +pub fn new_tag(name: String, suggestion: TagSuggestion) -> Result, VerboseError> { + Ok(Box::new(Tag(libloot::metadata::Tag::new( + name, + suggestion.try_into()?, + )))) +} + +impl Tag { + pub fn condition(&self) -> &str { + self.0.condition().unwrap_or("") + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn name(&self) -> &str; + + pub fn is_addition(&self) -> bool; + + pub fn set_condition(&mut self, condition: String); + } + } +} + +impl From> for libloot::metadata::Tag { + fn from(value: Box) -> Self { + value.0 + } +} + +fn wrap_tags(slice: &[libloot::metadata::Tag]) -> &[Tag] { + // SAFETY: This is safe because Tag is a transparent wrapper around the libloot type. + unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } +} + +impl TryFrom for libloot::metadata::TagSuggestion { + type Error = UnsupportedEnumValueError; + + fn try_from(value: TagSuggestion) -> Result { + match value { + TagSuggestion::Addition => Ok(libloot::metadata::TagSuggestion::Addition), + TagSuggestion::Removal => Ok(libloot::metadata::TagSuggestion::Removal), + _ => Err(UnsupportedEnumValueError), + } + } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct PluginCleaningData(libloot::metadata::PluginCleaningData); + +pub fn new_plugin_cleaning_data(crc: u32, cleaning_utility: String) -> Box { + Box::new(PluginCleaningData( + libloot::metadata::PluginCleaningData::new(crc, cleaning_utility), + )) +} + +impl PluginCleaningData { + pub fn detail(&self) -> &[MessageContent] { + wrap_message_contents(self.0.detail()) + } + + pub fn set_detail(&mut self, detail: &[Box]) -> Result<(), VerboseError> { + self.0.set_detail(to_vec_of_unwrapped(detail))?; + Ok(()) + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn crc(&self) -> u32; + + pub fn itm_count(&self) -> u32; + + pub fn set_itm_count(&mut self, count: u32); + + pub fn deleted_reference_count(&self) -> u32; + + pub fn set_deleted_reference_count(&mut self, count: u32); + + pub fn deleted_navmesh_count(&self) -> u32; + + pub fn set_deleted_navmesh_count(&mut self, count: u32); + + pub fn cleaning_utility(&self) -> &str; + } + } +} + +impl From> for libloot::metadata::PluginCleaningData { + fn from(value: Box) -> Self { + value.0 + } +} + +fn wrap_cleaning_data(slice: &[libloot::metadata::PluginCleaningData]) -> &[PluginCleaningData] { + // SAFETY: This is safe because PluginCleaningData is a transparent wrapper around the libloot type. + unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } +} + +#[derive(Clone, Debug)] +#[repr(transparent)] +pub struct Location(libloot::metadata::Location); + +pub fn new_location(url: String) -> Box { + Box::new(Location(libloot::metadata::Location::new(url))) +} + +impl Location { + pub fn name(&self) -> &str { + self.0.name().unwrap_or("") + } + + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0.clone())) + } + + delegate! { + to self.0 { + pub fn url(&self) -> &str; + + pub fn set_name(&mut self, name: String); + } + } +} + +impl From> for libloot::metadata::Location { + fn from(value: Box) -> Self { + value.0 + } +} + +fn wrap_locations(slice: &[libloot::metadata::Location]) -> &[Location] { + // SAFETY: This is safe because Location is a transparent wrapper around the libloot type. + unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } +} + +pub fn to_vec_of_unwrapped>>(slice: &[Box]) -> Vec { + slice.iter().cloned().map(Into::into).collect() +} diff --git a/cxx/src/plugin.rs b/cxx/src/plugin.rs new file mode 100644 index 00000000..4f769186 --- /dev/null +++ b/cxx/src/plugin.rs @@ -0,0 +1,88 @@ +use delegate::delegate; + +use crate::{EmptyOptionalError, OptionalRef, VerboseError}; + +#[derive(Debug)] +#[repr(transparent)] +pub struct PluginRef<'a>(&'a libloot::Plugin); + +impl<'a> PluginRef<'a> { + pub fn new(plugin: &'a libloot::Plugin) -> Self { + Self(plugin) + } + + pub fn header_version(&self) -> f32 { + self.0.header_version().unwrap_or(f32::NAN) + } + + pub fn version(&self) -> &str { + self.0.version().unwrap_or("") + } + + pub fn masters(&self) -> Result, VerboseError> { + self.0.masters().map_err(Into::into) + } + + pub fn crc(&self) -> i64 { + self.0.crc().map(Into::into).unwrap_or(-1) + } + + pub fn is_valid_as_light_plugin(&self) -> Result { + self.0.is_valid_as_light_plugin().map_err(Into::into) + } + + pub fn is_valid_as_medium_plugin(&self) -> Result { + self.0.is_valid_as_medium_plugin().map_err(Into::into) + } + + pub fn is_valid_as_update_plugin(&self) -> Result { + self.0.is_valid_as_update_plugin().map_err(Into::into) + } + + pub fn do_records_overlap(&self, plugin: &Self) -> Result { + self.0.do_records_overlap(plugin.0).map_err(Into::into) + } + + delegate! { + to self.0 { + pub fn name(&self) -> &str; + + pub fn bash_tags(&self) -> &[String]; + + pub fn is_master(&self) -> bool; + + pub fn is_light_plugin(&self) -> bool; + + pub fn is_medium_plugin(&self) -> bool; + + pub fn is_update_plugin(&self) -> bool; + + pub fn is_blueprint_plugin(&self) -> bool; + + pub fn is_empty(&self) -> bool; + + pub fn loads_archive(&self) -> bool; + } + } +} + +impl<'a> From<&'a libloot::Plugin> for PluginRef<'a> { + fn from(value: &'a libloot::Plugin) -> Self { + PluginRef(value) + } +} + +pub type OptionalPluginRef = OptionalRef; + +impl OptionalRef { + /// # Safety + /// + /// This is safe as long as the pointer in the OptionalRef is still valid. + pub unsafe fn as_ref(&self) -> Result>, EmptyOptionalError> { + if self.0.is_null() { + Err(EmptyOptionalError) + } else { + unsafe { Ok(Box::new(PluginRef::new(&*self.0))) } + } + } +} From 7b0add5e722be86ef7d898513f4b372fb6d80e05 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 10 Mar 2025 08:49:12 +0000 Subject: [PATCH 029/206] Add libloot v0.25.3 public API headers --- cxx/.gitignore | 1 - cxx/include/loot/api.h | 116 ++++++++ cxx/include/loot/api_decorator.h | 44 +++ cxx/include/loot/database_interface.h | 235 ++++++++++++++++ cxx/include/loot/enum/edge_type.h | 53 ++++ cxx/include/loot/enum/game_type.h | 59 ++++ cxx/include/loot/enum/log_level.h | 45 ++++ cxx/include/loot/enum/message_type.h | 49 ++++ .../loot/exception/condition_syntax_error.h | 41 +++ .../loot/exception/cyclic_interaction_error.h | 61 +++++ cxx/include/loot/exception/error_categories.h | 58 ++++ .../loot/exception/file_access_error.h | 41 +++ .../loot/exception/undefined_group_error.h | 55 ++++ cxx/include/loot/game_interface.h | 241 +++++++++++++++++ cxx/include/loot/loot_version.h | 55 ++++ .../loot/metadata/conditional_metadata.h | 69 +++++ cxx/include/loot/metadata/file.h | 137 ++++++++++ cxx/include/loot/metadata/filename.h | 104 +++++++ cxx/include/loot/metadata/group.h | 134 +++++++++ cxx/include/loot/metadata/location.h | 117 ++++++++ cxx/include/loot/metadata/message.h | 139 ++++++++++ cxx/include/loot/metadata/message_content.h | 150 +++++++++++ .../loot/metadata/plugin_cleaning_data.h | 188 +++++++++++++ cxx/include/loot/metadata/plugin_metadata.h | 254 ++++++++++++++++++ cxx/include/loot/metadata/tag.h | 120 +++++++++ cxx/include/loot/plugin_interface.h | 178 ++++++++++++ cxx/include/loot/vertex.h | 74 +++++ 27 files changed, 2817 insertions(+), 1 deletion(-) create mode 100644 cxx/include/loot/api.h create mode 100644 cxx/include/loot/api_decorator.h create mode 100644 cxx/include/loot/database_interface.h create mode 100644 cxx/include/loot/enum/edge_type.h create mode 100644 cxx/include/loot/enum/game_type.h create mode 100644 cxx/include/loot/enum/log_level.h create mode 100644 cxx/include/loot/enum/message_type.h create mode 100644 cxx/include/loot/exception/condition_syntax_error.h create mode 100644 cxx/include/loot/exception/cyclic_interaction_error.h create mode 100644 cxx/include/loot/exception/error_categories.h create mode 100644 cxx/include/loot/exception/file_access_error.h create mode 100644 cxx/include/loot/exception/undefined_group_error.h create mode 100644 cxx/include/loot/game_interface.h create mode 100644 cxx/include/loot/loot_version.h create mode 100644 cxx/include/loot/metadata/conditional_metadata.h create mode 100644 cxx/include/loot/metadata/file.h create mode 100644 cxx/include/loot/metadata/filename.h create mode 100644 cxx/include/loot/metadata/group.h create mode 100644 cxx/include/loot/metadata/location.h create mode 100644 cxx/include/loot/metadata/message.h create mode 100644 cxx/include/loot/metadata/message_content.h create mode 100644 cxx/include/loot/metadata/plugin_cleaning_data.h create mode 100644 cxx/include/loot/metadata/plugin_metadata.h create mode 100644 cxx/include/loot/metadata/tag.h create mode 100644 cxx/include/loot/plugin_interface.h create mode 100644 cxx/include/loot/vertex.h diff --git a/cxx/.gitignore b/cxx/.gitignore index e9881e13..796b96d1 100644 --- a/cxx/.gitignore +++ b/cxx/.gitignore @@ -1,2 +1 @@ /build -/include diff --git a/cxx/include/loot/api.h b/cxx/include/loot/api.h new file mode 100644 index 00000000..33dd52ec --- /dev/null +++ b/cxx/include/loot/api.h @@ -0,0 +1,116 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2013-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#ifndef LOOT_API_H +#define LOOT_API_H + +#include +#include +#include +#include + +#include "loot/api_decorator.h" +#include "loot/enum/game_type.h" +#include "loot/enum/log_level.h" +#include "loot/exception/condition_syntax_error.h" +#include "loot/exception/cyclic_interaction_error.h" +#include "loot/exception/error_categories.h" +#include "loot/exception/file_access_error.h" +#include "loot/exception/undefined_group_error.h" +#include "loot/game_interface.h" +#include "loot/loot_version.h" + +namespace loot { +/** + * @} + * @name Logging Functions + * @{ + */ + +/** + * @brief Set the callback function that is called when logging. + * @details If this function is not called, the default behaviour is to + * print messages to the console. + * @param callback + * The function called when logging. The first parameter is the + * level of the message being logged, and the second is the message. + */ +LOOT_API void SetLoggingCallback( + std::function callback); + +/** + * @} + * @name Version Functions + * @{ + */ + +/** + * @brief Checks for API compatibility. + * @details Checks whether the loaded API is compatible with the given + * version of the API, abstracting API stability policy away from + * clients. The version numbering used is major.minor.patch. + * @param major + * The major version number to check. + * @param minor + * The minor version number to check. + * @param patch + * The patch version number to check. + * @returns True if the API versions are compatible, false otherwise. + */ +LOOT_API bool IsCompatible(const unsigned int major, + const unsigned int minor, + const unsigned int patch); + +/** + * @} + * @name Lifecycle Management Functions + * @{ + */ + +/** + * @brief Initialise a new game handle. + * @details Creates a handle for a game, which is then used by all + * game-specific functions. + * @param game + * A game code for which to create the handle. + * @param game_path + * The relative or absolute path to the directory containing the + * game's executable. + * @param game_local_path + * The relative or absolute path to the game's local data folder, or an + * empty path. The local data folder is usually in `%%LOCALAPPDATA%`, but + * Morrowind has no local data folder and OpenMW's is in the user's + * My Games folder on Windows and in `$HOME/.config` on Linux. If an + * empty path is provided, the API will attempt to look up the relevant + * local data path, which may fail in some situations (e.g. when running + * libloot natively on Linux for a game other than Morrowind or OpenMW). + * @returns The new game handle. + */ +LOOT_API std::unique_ptr CreateGameHandle( + const GameType game, + const std::filesystem::path& game_path, + const std::filesystem::path& game_local_path = ""); +} + +#endif diff --git a/cxx/include/loot/api_decorator.h b/cxx/include/loot/api_decorator.h new file mode 100644 index 00000000..8a64d730 --- /dev/null +++ b/cxx/include/loot/api_decorator.h @@ -0,0 +1,44 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2013-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#ifndef LOOT_API_DECORATOR +#define LOOT_API_DECORATOR + +/* set up dll import/export decorators + when compiling the dll on windows, ensure LOOT_EXPORT is defined. clients + that use this header do not need to define anything to import the symbols + properly. */ +#if defined(_WIN32) +#ifdef LOOT_STATIC +#define LOOT_API +#elif defined LOOT_EXPORT +#define LOOT_API __declspec(dllexport) +#else +#define LOOT_API __declspec(dllimport) +#endif +#else +#define LOOT_API +#endif + +#endif diff --git a/cxx/include/loot/database_interface.h b/cxx/include/loot/database_interface.h new file mode 100644 index 00000000..3ce1263e --- /dev/null +++ b/cxx/include/loot/database_interface.h @@ -0,0 +1,235 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#ifndef LOOT_DATABASE_INTERFACE +#define LOOT_DATABASE_INTERFACE + +#include +#include +#include +#include + +#include "loot/exception/cyclic_interaction_error.h" +#include "loot/metadata/group.h" +#include "loot/metadata/message.h" +#include "loot/metadata/plugin_metadata.h" + +namespace loot { +/** @brief The interface provided by API's database handle. */ +class DatabaseInterface { +public: + virtual ~DatabaseInterface() = default; + + /** + * @name Data Reading & Writing + * @{ + */ + + /** + * @brief Loads the masterlist, userlist and masterlist prelude from the + * paths specified. + * @details Can be called multiple times, each time replacing the + * previously-loaded data. + * @param masterlist_path + * The relative or absolute path to the masterlist file that should be + * loaded. + * @param userlist_path + * The relative or absolute path to the userlist file that should be + * loaded, or an empty path. If an empty path, no userlist will be + * loaded. + * @param masterlist_prelude_path + * The relative or absolute path to the masterlist prelude file that + * should be loaded. If an empty path, no masterlist prelude will be + * loaded. + */ + virtual void LoadLists( + const std::filesystem::path& masterlist_path, + const std::filesystem::path& userlist_path = "", + const std::filesystem::path& masterlist_prelude_path = "") = 0; + + /** + * Writes a metadata file containing all loaded user-added metadata. + * @param outputFile + * The path to which the file shall be written. + * @param overwrite + * If `false` and `outputFile` already exists, no data will be + * written. Otherwise, data will be written. + */ + virtual void WriteUserMetadata(const std::filesystem::path& outputFile, + const bool overwrite) const = 0; + + /** + * @brief Writes a minimal metadata file that only contains plugins with + * Bash Tag suggestions and/or dirty info, plus the suggestions and + * info themselves. + * @param outputFile + * The path to which the file shall be written. + * @param overwrite + * If `false` and `outputFile` already exists, no data will be + * written. Otherwise, data will be written. + */ + virtual void WriteMinimalList(const std::filesystem::path& outputFile, + const bool overwrite) const = 0; + + /** + * @} + * @name Non-plugin Data Access + * @{ + */ + + /** + * @brief Gets the Bash Tags that are listed in the loaded metadata lists. + * @details Bash Tag suggestions can include Bash Tags not in this list. + * @returns A set of Bash Tag names. + */ + virtual std::vector GetKnownBashTags() const = 0; + + /** + * @brief Get all general messages listen in the loaded metadata lists. + * @param evaluateConditions + * If true, any metadata conditions are evaluated before the metadata + * is returned, otherwise unevaluated metadata is returned. Evaluating + * general message conditions also clears the condition cache before + * evaluating conditions. + * @returns A vector of messages supplied in the metadata lists but not + * attached to any particular plugin. + */ + virtual std::vector GetGeneralMessages( + bool evaluateConditions = false) const = 0; + + /** + * @brief Gets the groups that are defined in the loaded metadata lists. + * @param includeUserMetadata + * If true, any group metadata present in the userlist is included in + * the returned metadata, otherwise the metadata returned only includes + * metadata from the masterlist. + * @returns An vector of Group objects. Each Group's name is unique, if a + * group has masterlist and user metadata the two are merged into a + * single group object. + */ + virtual std::vector GetGroups( + bool includeUserMetadata = true) const = 0; + + /** + * @brief Gets the groups that are defined or extended in the loaded userlist. + * @returns An unordered set of Group objects. + */ + virtual std::vector GetUserGroups() const = 0; + + /** + * @brief Sets the group definitions to store in the userlist, overwriting any + * existing definitions there. + * @param groups + * The unordered set of Group objects to set. + */ + virtual void SetUserGroups(const std::vector& groups) = 0; + + /** + * @brief Get the "shortest" path between the two given groups according to + * their load after metadata. + * @details The "shortest" path is defined as the path that maximises the + * amount of user metadata involved while minimising the amount of + * masterlist metadata involved. It's not the path involving the + * fewest groups. + * @param fromGroupName + * The name of the source group, that loads earlier. + * @param toGroupName + * The name of the destination group, that loads later. + * @returns A vector of Vertex elements representing the path from the source + * group to the destination group, or an empty vector if no path + * exists. + */ + virtual std::vector GetGroupsPath( + const std::string& fromGroupName, + const std::string& toGroupName) const = 0; + + /** + * @} + * @name Plugin Data Access + * @{ + */ + + /** + * @brief Get all a plugin's loaded metadata. + * @param plugin + * The filename of the plugin to look up metadata for. + * @param includeUserMetadata + * If true, any user metadata the plugin has is included in the + * returned metadata, otherwise the metadata returned only includes + * metadata from the masterlist. + * @param evaluateConditions + * If true, any metadata conditions are evaluated before the metadata + * is returned, otherwise unevaluated metadata is returned. Evaluating + * plugin metadata conditions does not clear the condition cache. + * @returns If the plugin has metadata, an optional containing that metadata, + * otherwise an optional containing no value. + */ + virtual std::optional GetPluginMetadata( + const std::string& plugin, + bool includeUserMetadata = true, + bool evaluateConditions = false) const = 0; + + /** + * @brief Get a plugin's metadata loaded from the given userlist. + * @param plugin + * The filename of the plugin to look up user-added metadata for. + * @param evaluateConditions + * If true, any metadata conditions are evaluated before the metadata + * is returned, otherwise unevaluated metadata is returned. Evaluating + * plugin metadata conditions does not clear the condition cache. + * @returns If the plugin has user-added metadata, an optional containing + * that metadata, otherwise an optional containing no value. + */ + virtual std::optional GetPluginUserMetadata( + const std::string& plugin, + bool evaluateConditions = false) const = 0; + + /** + * @brief Sets a plugin's user metadata, overwriting any existing user + * metadata. + * @param pluginMetadata + * The user metadata you want to set, with plugin.Name() being the + * filename of the plugin the metadata is for. + */ + virtual void SetPluginUserMetadata(const PluginMetadata& pluginMetadata) = 0; + + /** + * @brief Discards all loaded user metadata for the plugin with the given + * filename. + * @param plugin + * The filename of the plugin for which all user-added metadata + * should be deleted. + */ + virtual void DiscardPluginUserMetadata(const std::string& plugin) = 0; + + /** + * @brief Discards all loaded user metadata for all plugins, and any + * user-added general messages and known bash tags. + */ + virtual void DiscardAllUserMetadata() = 0; + + /** @} */ +}; +} + +#endif diff --git a/cxx/include/loot/enum/edge_type.h b/cxx/include/loot/enum/edge_type.h new file mode 100644 index 00000000..db7b135c --- /dev/null +++ b/cxx/include/loot/enum/edge_type.h @@ -0,0 +1,53 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2012-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_EDGE_TYPE +#define LOOT_EDGE_TYPE + +/** + * The namespace used by libloot. + */ +namespace loot { +/** + * @brief An enum representing the different possible types of interactions + * between plugins or groups. + */ +enum struct EdgeType : unsigned int { + hardcoded, + masterFlag, + master, + masterlistRequirement, + userRequirement, + masterlistLoadAfter, + userLoadAfter, + masterlistGroup, + userGroup, + recordOverlap, + assetOverlap, + tieBreak, + blueprintMaster, +}; +} + +#endif diff --git a/cxx/include/loot/enum/game_type.h b/cxx/include/loot/enum/game_type.h new file mode 100644 index 00000000..ccec865c --- /dev/null +++ b/cxx/include/loot/enum/game_type.h @@ -0,0 +1,59 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2012-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_GAME_TYPE +#define LOOT_GAME_TYPE + +/** + * The namespace used by libloot. + */ +namespace loot { +/** @brief Codes used to create database handles for specific games. */ +enum struct GameType : unsigned int { + /** The Elder Scrolls IV: Oblivion */ + tes4, + /** The Elder Scrolls V: Skyrim */ + tes5, + /** Fallout 3 */ + fo3, + /** Fallout: New Vegas */ + fonv, + /** Fallout 4 */ + fo4, + /** The Elder Scrolls V: Skyrim Special Edition */ + tes5se, + /** Fallout 4 VR */ + fo4vr, + /** Skyrim VR */ + tes5vr, + /** The Elder Scrolls III: Morrowind */ + tes3, + /** Starfield */ + starfield, + /** OpenMW */ + openmw +}; +} + +#endif diff --git a/cxx/include/loot/enum/log_level.h b/cxx/include/loot/enum/log_level.h new file mode 100644 index 00000000..5fbb0e84 --- /dev/null +++ b/cxx/include/loot/enum/log_level.h @@ -0,0 +1,45 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2012-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_LOG_LEVEL +#define LOOT_LOG_LEVEL + +/** + * The namespace used by libloot. + */ +namespace loot { +/** + * @brief Codes used to specify different levels of API logging. + */ +enum struct LogLevel : unsigned int { + trace, + debug, + info, + warning, + error, + fatal +}; +} + +#endif diff --git a/cxx/include/loot/enum/message_type.h b/cxx/include/loot/enum/message_type.h new file mode 100644 index 00000000..c726b7c5 --- /dev/null +++ b/cxx/include/loot/enum/message_type.h @@ -0,0 +1,49 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2012-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_MESSAGE_TYPE +#define LOOT_MESSAGE_TYPE + +/** + * The namespace used by libloot. + */ +namespace loot { +/** @brief Codes used to indicate the type of a message. */ +enum struct MessageType : unsigned int { + /** A notification message that is of no significant severity. */ + say, + /** + * A warning message, used to indicate that an issue may be present that the + * user may wish to act on. + */ + warn, + /** + * An error message, used to indicate that an issue that requires user action + * is present. + */ + error, +}; +} + +#endif diff --git a/cxx/include/loot/exception/condition_syntax_error.h b/cxx/include/loot/exception/condition_syntax_error.h new file mode 100644 index 00000000..105e0a86 --- /dev/null +++ b/cxx/include/loot/exception/condition_syntax_error.h @@ -0,0 +1,41 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#ifndef LOOT_EXCEPTION_CONDITION_SYNTAX_ERROR +#define LOOT_EXCEPTION_CONDITION_SYNTAX_ERROR + +#include + +namespace loot { +/** + * @brief An exception class thrown if invalid syntax is encountered when + * parsing a metadata condition. + */ +class ConditionSyntaxError : public std::system_error { +public: + using std::system_error::system_error; +}; +} + +#endif diff --git a/cxx/include/loot/exception/cyclic_interaction_error.h b/cxx/include/loot/exception/cyclic_interaction_error.h new file mode 100644 index 00000000..dea19fde --- /dev/null +++ b/cxx/include/loot/exception/cyclic_interaction_error.h @@ -0,0 +1,61 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#ifndef LOOT_EXCEPTION_CYCLIC_INTERACTION_ERROR +#define LOOT_EXCEPTION_CYCLIC_INTERACTION_ERROR + +#include +#include + +#include "loot/api_decorator.h" +#include "loot/vertex.h" + +namespace loot { +/** + * @brief An exception class thrown if a cyclic interaction is detected when + * sorting a load order. + */ +class CyclicInteractionError : public std::runtime_error { +public: + /** + * @brief Construct an exception detailing a plugin or group graph cycle. + * @param cycle A representation of the cyclic path. + */ + LOOT_API CyclicInteractionError(std::vector cycle); + + /** + * @brief Get a representation of the cyclic path. + * @details Each Vertex is the name of a graph element (plugin or group) and + * the type of the edge going to the next Vertex. The last Vertex + * has an edge going to the first Vertex. + * @return A vector of Vertex elements representing the cyclic path. + */ + LOOT_API std::vector GetCycle() const; + +private: + std::vector cycle_; +}; +} + +#endif diff --git a/cxx/include/loot/exception/error_categories.h b/cxx/include/loot/exception/error_categories.h new file mode 100644 index 00000000..710cfa5a --- /dev/null +++ b/cxx/include/loot/exception/error_categories.h @@ -0,0 +1,58 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#ifndef LOOT_ERROR_CATEGORIES +#define LOOT_ERROR_CATEGORIES + +#include + +#include "loot/api_decorator.h" + +namespace loot { +/** + * @brief Get the error category that can be used to identify system_error + * exceptions that are due to esplugin errors. + * @returns A reference to the static object of unspecified runtime type, + * derived from std::error_category. + */ +LOOT_API const std::error_category& esplugin_category(); + +/** + * @brief Get the error category that can be used to identify system_error + * exceptions that are due to libloadorder errors. + * @returns A reference to the static object of unspecified runtime type, + * derived from std::error_category. + */ +LOOT_API const std::error_category& libloadorder_category(); + +/** + * @brief Get the error category that can be used to identify system_error + * exceptions that are due to loot condition interpreter errors. + * @returns A reference to the static object of unspecified runtime type, + * derived from std::error_category. + */ +LOOT_API const std::error_category& loot_condition_interpreter_category(); +} + +#endif diff --git a/cxx/include/loot/exception/file_access_error.h b/cxx/include/loot/exception/file_access_error.h new file mode 100644 index 00000000..af4ab237 --- /dev/null +++ b/cxx/include/loot/exception/file_access_error.h @@ -0,0 +1,41 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#ifndef LOOT_EXCEPTION_FILE_ACCESS_ERROR +#define LOOT_EXCEPTION_FILE_ACCESS_ERROR + +#include + +namespace loot { +/** + * @brief An exception class thrown if an error is encountered while reading or + * writing a file. + */ +class FileAccessError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; +} + +#endif diff --git a/cxx/include/loot/exception/undefined_group_error.h b/cxx/include/loot/exception/undefined_group_error.h new file mode 100644 index 00000000..008b3ae0 --- /dev/null +++ b/cxx/include/loot/exception/undefined_group_error.h @@ -0,0 +1,55 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#ifndef LOOT_EXCEPTION_UNDEFINED_GROUP_ERROR +#define LOOT_EXCEPTION_UNDEFINED_GROUP_ERROR + +#include + +#include "loot/api_decorator.h" + +namespace loot { +/** + * @brief An exception class thrown if group is referenced but is undefined. + */ +class UndefinedGroupError : public std::runtime_error { +public: + /** + * @brief Construct an exception for an undefined group. + * @param groupName The name of the group that is undefined. + */ + LOOT_API UndefinedGroupError(const std::string& groupName); + + /** + * Get the name of the undefined group. + * @return A group name. + */ + LOOT_API std::string GetGroupName() const; + +private: + std::string groupName_; +}; +} + +#endif diff --git a/cxx/include/loot/game_interface.h b/cxx/include/loot/game_interface.h new file mode 100644 index 00000000..a0115027 --- /dev/null +++ b/cxx/include/loot/game_interface.h @@ -0,0 +1,241 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#ifndef LOOT_GAME_INTERFACE +#define LOOT_GAME_INTERFACE + +#include "loot/database_interface.h" +#include "loot/plugin_interface.h" + +namespace loot { +/** @brief The interface provided for accessing game-specific functionality. */ +class GameInterface { +public: + virtual ~GameInterface() = default; + + /** + * @brief Get the game's type. + * @returns The game's type. + */ + virtual GameType GetType() const = 0; + + /** + * @brief Gets the currently-set additional data paths. + * @details The following games are configured with additional data paths by + * default: + * - Fallout 4, when installed from the Microsoft Store + * - Starfield + * - OpenMW + */ + virtual std::vector GetAdditionalDataPaths() const = 0; + + /** + * @brief Set additional data paths. + * @details The additional data paths are used when interacting with the load + * order, evaluating conditions and scanning for archives (BSA/BA2 + * depending on the game). Additional data paths are used in the + * order they are given (except with OpenMW, which checks them in + * reverse order), and take precedence over the game's main data + * path. + */ + virtual void SetAdditionalDataPaths( + const std::vector& additionalDataPaths) = 0; + + /** + * @name Metadata Access + * @{ + */ + + /** + * @brief Get the database interface used for accessing metadata-related + * functionality. + * @returns A reference to the game's DatabaseInterface. The reference remains + * valid for the lifetime of the GameInterface instance. + */ + virtual DatabaseInterface& GetDatabase() = 0; + + /** + * @brief Get the database interface used for accessing metadata-related + * functionality. + * @returns A reference to the game's DatabaseInterface. The reference remains + * valid for the lifetime of the GameInterface instance. + */ + virtual const DatabaseInterface& GetDatabase() const = 0; + + /** + * @} + * @name Plugin Data Access + * @{ + */ + + /** + * @brief Check if a file is a valid plugin. + * @details The validity check is not exhaustive: it generally checks that the + * file is a valid plugin file extension for the game and that its + * header (if applicable) can be parsed. + * @param pluginPath + * The path to the file to check. Relative paths are resolved relative + * to the game's plugins directory, while absolute paths are used + * as given. + * @returns True if the file is a valid plugin, false otherwise. + */ + virtual bool IsValidPlugin(const std::filesystem::path& pluginPath) const = 0; + + /** + * @brief Parses plugins and loads their data. + * @details If a given plugin filename (or one that is case-insensitively + * equal) has already been loaded, its previously-loaded data + * data is discarded, invalidating any existing shared pointers to + * that plugin's PluginInterface object. + * + * If the game is Morrowind, OpenMW or Starfield, it's only valid to + * fully load a plugin if its masters are already loaded or included + * in the same input vector. + * @param pluginPaths + * The plugin paths to load. Relative paths are resolved relative to + * the game's plugins directory, while absolute paths are used as + * given. Each plugin filename must be unique within the vector. + * @param loadHeadersOnly + * If true, only the plugins' headers are loaded. If false, all records + * in the plugins are parsed. + */ + virtual void LoadPlugins( + const std::vector& pluginPaths, + bool loadHeadersOnly) = 0; + + /** + * @brief Clears the plugins loaded by previous calls to `LoadPlugins()`. + * @details This invalidates any PluginInterface pointers retrieved using + * `GetPlugin()` or `GetLoadedPlugins()`. + */ + virtual void ClearLoadedPlugins() = 0; + + /** + * @brief Get data for a loaded plugin. + * @param pluginName + * The filename of the plugin to get data for. + * @returns A shared pointer to a const PluginInterface implementation. The + * pointer is null if the given plugin has not been loaded. The + * pointer remains valid until the `ClearLoadedPlugins()` function + * is called, this GameInterface is destroyed, or until a plugin with + * a case-insensitively equal filename is loaded. + */ + virtual const PluginInterface* GetPlugin( + const std::string& pluginName) const = 0; + + /** + * @brief Get a set of const references to all loaded plugins' PluginInterface + * objects. + * @returns A set of shared pointers to const PluginInterface. The pointers + * remain valid until the `ClearLoadedPlugins()` function is called, + * this GameInterface is destroyed, or until a plugin with a + * case-insensitively equal filename is loaded. + */ + virtual std::vector GetLoadedPlugins() const = 0; + + /** + * @} + * @name Sorting + * @{ + */ + + /** + * @brief Calculates a new load order for the game's installed plugins + * (including inactive plugins) and outputs the sorted order. + * @details Pulls metadata from the masterlist and userlist if they are + * loaded, and reads the contents of each plugin. No changes are + * applied to the load order used by the game. This function does + * not load or evaluate the masterlist or userlist. + * @param pluginPaths + * The plugins to sort, in their current load order. All given plugins + * must have been loaded using `LoadPlugins()`. + * @returns A vector of the given plugin filenames in their sorted load + * order. + */ + virtual std::vector SortPlugins( + const std::vector& pluginFilenames) = 0; + + /** + * @} + * @name Load Order Interaction + * @{ + */ + + /** + * + * @brief Load the current load order state, discarding any previously held + * state. + * @details This function should be called whenever the load order or active + * state of plugins "on disk" changes, so that the cached state is + * updated to reflect the changes. + */ + virtual void LoadCurrentLoadOrderState() = 0; + + /** + * @brief Check if the load order is ambiguous. + * @details This checks that all plugins in the current load order state have + * a well-defined position in the "on disk" state, and that all data + * sources are consistent. If the load order is ambiguous, different + * applications may read different load orders from the same source + * data. + * @returns True if the load order is ambiguous, false otherwise. + */ + virtual bool IsLoadOrderAmbiguous() const = 0; + + /** + * @brief Gets the path to the file that holds the list of active plugins. + * @details The active plugins file path is often within the game's local + path, but its name and location varies by game and game + configuration, so this function exposes the path that libloot + uses. + * @returns The file path. + */ + virtual std::filesystem::path GetActivePluginsFilePath() const = 0; + + /** + * @brief Check if a plugin is active. + * @param plugin + * The filename of the plugin for which to check the active state. + * @returns True if the plugin is active, false otherwise. + */ + virtual bool IsPluginActive(const std::string& plugin) const = 0; + + /** + * @brief Get the current load order. + * @returns A vector of plugin filenames in their load order. + */ + virtual std::vector GetLoadOrder() const = 0; + + /** + * @brief Set the game's load order. + * @details There is no way to persist the load order of inactive OpenMW + * plugins, so setting an OpenMW load order will have no effect if + * the relative order of active plugins is unchanged. + * @param loadOrder + * A vector of plugin filenames sorted in the load order to set. + */ + virtual void SetLoadOrder(const std::vector& loadOrder) = 0; +}; +} + +#endif diff --git a/cxx/include/loot/loot_version.h b/cxx/include/loot/loot_version.h new file mode 100644 index 00000000..65e8916d --- /dev/null +++ b/cxx/include/loot/loot_version.h @@ -0,0 +1,55 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_LOOT_VERSION +#define LOOT_LOOT_VERSION + +#include + +#include "loot/api_decorator.h" + +namespace loot { +/** @brief libloot's major version number. */ +inline constexpr unsigned int LIBLOOT_VERSION_MAJOR = 0; + +/** @brief libloot's minor version number. */ +inline constexpr unsigned int LIBLOOT_VERSION_MINOR = 25; + +/** @brief libloot's patch version number. */ +inline constexpr unsigned int LIBLOOT_VERSION_PATCH = 3; + +/** + * @brief Get the library version. + * @return A string of the form "major.minor.patch". + */ +LOOT_API std::string GetLiblootVersion(); + +/** + * @brief Get the source control revision that libloot was built from. + * @return A string containing the revision ID. + */ +LOOT_API std::string GetLiblootRevision(); +} + +#endif diff --git a/cxx/include/loot/metadata/conditional_metadata.h b/cxx/include/loot/metadata/conditional_metadata.h new file mode 100644 index 00000000..cd47658b --- /dev/null +++ b/cxx/include/loot/metadata/conditional_metadata.h @@ -0,0 +1,69 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#ifndef LOOT_METADATA_CONDITIONAL_METADATA +#define LOOT_METADATA_CONDITIONAL_METADATA + +#include + +#include "loot/api_decorator.h" + +namespace loot { +/** + * A base class for metadata that can be conditional based on the result of + * evaluating a condition string. + */ +class ConditionalMetadata { +public: + /** + * Construct a ConditionalMetadata object with an empty condition string. + * @return A ConditionalMetadata object. + */ + LOOT_API ConditionalMetadata() = default; + + /** + * Construct a ConditionalMetadata object with a given condition string. + * @param condition + * A condition string, as defined in the LOOT metadata syntax + * documentation. + * @return A ConditionalMetadata object. + */ + LOOT_API explicit ConditionalMetadata(const std::string& condition); + + /** + * Check if the condition string is non-empty. + * @return True if the condition string is not empty, false otherwise. + */ + LOOT_API bool IsConditional() const; + + /** + * Get the condition string. + * @return The object's condition string. + */ + LOOT_API std::string GetCondition() const; + +private: + std::string condition_; +}; +} +#endif diff --git a/cxx/include/loot/metadata/file.h b/cxx/include/loot/metadata/file.h new file mode 100644 index 00000000..e7d1934d --- /dev/null +++ b/cxx/include/loot/metadata/file.h @@ -0,0 +1,137 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#ifndef LOOT_METADATA_FILE +#define LOOT_METADATA_FILE + +#include + +#include "loot/api_decorator.h" +#include "loot/metadata/conditional_metadata.h" +#include "loot/metadata/filename.h" +#include "loot/metadata/message_content.h" + +namespace loot { +/** + * Represents a file in a game's Data folder, including files in subdirectories. + */ +class File : public ConditionalMetadata { +public: + /** + * Construct a File with blank name, display and condition strings. + * @return A File object. + */ + LOOT_API File() = default; + + /** + * Construct a File with the given name, display name and condition strings. + * @param name + * The filename of the file. + * @param display + * The name to be displayed for the file in messages, formatted using + * CommonMark. + * @param condition + * The File's condition string. + * @param detail + * The detail message content, which may be appended to any messages + * generated for this file. If multilingual, one language must be + * English. + * @return A File object. + */ + LOOT_API explicit File(const std::string& name, + const std::string& display = "", + const std::string& condition = "", + const std::vector& detail = {}); + + /** + * Get the filename of the file. + * @return The file's filename. + */ + LOOT_API Filename GetName() const; + + /** + * Get the display name of the file. + * @return The file's display name. + */ + LOOT_API std::string GetDisplayName() const; + + /** + * Get the detail message content of the file. + * + * If this file causes an error message to be displayed, the detail message + * content should be appended to that message, as it provides more detail + * about the error (e.g. suggestions for how to resolve it). + */ + LOOT_API std::vector GetDetail() const; + +private: + Filename name_; + std::string display_; + std::vector detail_; +}; + +/** + * Check if two File objects are equal by comparing their fields. + * @returns True if the objects' fields are equal, false otherwise. + */ +LOOT_API bool operator==(const File& lhs, const File& rhs); + +/** + * Check if two File objects are not equal. + * @returns True if the File objects are not equal, false otherwise. + */ +LOOT_API bool operator!=(const File& lhs, const File& rhs); + +/** + * A less-than operator implemented with no semantics so that File objects can + * be stored in sets. + * @returns True if the first File is less than the second File, false + * otherwise. + */ +LOOT_API bool operator<(const File& lhs, const File& rhs); + +/** + * Check if the first File object is greater than the second File object. + * @returns True if the second File object is less than the first File object, + * false otherwise. + */ +LOOT_API bool operator>(const File& lhs, const File& rhs); + +/** + * Check if the first File object is less than or equal to the second File + * object. + * @returns True if the first File object is not greater than the second File + * object, false otherwise. + */ +LOOT_API bool operator<=(const File& lhs, const File& rhs); + +/** + * Check if the first File object is greater than or equal to the second File + * object. + * @returns True if the first File object is not less than the second File + * object, false otherwise. + */ +LOOT_API bool operator>=(const File& lhs, const File& rhs); +} + +#endif diff --git a/cxx/include/loot/metadata/filename.h b/cxx/include/loot/metadata/filename.h new file mode 100644 index 00000000..b09fa2be --- /dev/null +++ b/cxx/include/loot/metadata/filename.h @@ -0,0 +1,104 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#ifndef LOOT_METADATA_FILENAME +#define LOOT_METADATA_FILENAME + +#include + +#include "loot/api_decorator.h" + +namespace loot { +/** + * Represents a case-insensitive filename. + */ +class Filename { +public: + /** + * Construct a Filename using an empty string. + * @return A Filename object. + */ + LOOT_API Filename() = default; + + /** + * Construct a Filename using the given string. + * @return A Filename object. + */ + LOOT_API explicit Filename(const std::string& filename); + + /** + * Get this Filename as a string. + */ + LOOT_API explicit operator std::string() const; + +private: + std::string filename_; +}; + +/** + * Check if two Filename objects are equal by comparing their fields. + * @returns True if the filenames are case-insensitively equal and all other + * fields are case-sensitively equal, false otherwise. + */ +LOOT_API bool operator==(const Filename& lhs, const Filename& rhs); + +/** + * Check if two Filename objects are not equal. + * @returns True if the Filename objects are not equal, false otherwise. + */ +LOOT_API bool operator!=(const Filename& lhs, const Filename& rhs); + +/** + * A less-than operator implemented with no semantics so that Filename objects + * can be stored in sets. + * @returns True if this Filename is less than the given Filename, false + * otherwise. + */ +LOOT_API bool operator<(const Filename& lhs, const Filename& rhs); + +/** + * Check if the first Filename object is greater than the second Filename + * object. + * @returns True if the second Filename object is less than the first Filename + * object, false otherwise. + */ +LOOT_API bool operator>(const Filename& lhs, const Filename& rhs); + +/** + * Check if the first Filename object is less than or equal to the second + * Filename object. + * @returns True if the first Filename object is not greater than the second + * Filename object, false otherwise. + */ +LOOT_API bool operator<=(const Filename& lhs, const Filename& rhs); + +/** + * Check if the first Filename object is greater than or equal to the second + * Filename object. + * @returns True if the first Filename object is not less than the second + * Filename object, false otherwise. + */ +LOOT_API bool operator>=(const Filename& lhs, const Filename& rhs); +} + +#endif diff --git a/cxx/include/loot/metadata/group.h b/cxx/include/loot/metadata/group.h new file mode 100644 index 00000000..adf78ce3 --- /dev/null +++ b/cxx/include/loot/metadata/group.h @@ -0,0 +1,134 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2018 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#ifndef LOOT_METADATA_GROUP +#define LOOT_METADATA_GROUP + +#include +#include + +#include "loot/api_decorator.h" + +namespace loot { +/** + * Represents a group to which plugin metadata objects can belong. + */ +class Group { +public: + /** + * The name of the group to which all plugins belong by default. + */ + static constexpr const char* DEFAULT_NAME = "default"; + + /** + * Construct a Group with the name "default" and an empty set of groups to + * load after. + * @return A Group object. + */ + LOOT_API Group() = default; + + /** + * Construct a Group with the given name, description and set of groups to + * load after. + * @param name + * The group name. + * @param afterGroups + * The names of groups this group loads after. + * @param description + * A description of the group. + * @return A Group object. + */ + LOOT_API explicit Group(const std::string& name, + const std::vector& afterGroups = {}, + const std::string& description = ""); + + /** + * Get the name of the group. + * @return The group's name. + */ + LOOT_API std::string GetName() const; + + /** + * Get the description of the group. + * @return The group's description. + */ + LOOT_API std::string GetDescription() const; + + /** + * Get the set of groups this group loads after. + * @return A set of group names. + */ + LOOT_API std::vector GetAfterGroups() const; + +private: + std::string name_{DEFAULT_NAME}; + std::string description_; + std::vector afterGroups_; +}; + +/** + * Check if two Group objects are equal by comparing their names. + * @returns True if the objects' fields are equal, false otherwise. + */ +LOOT_API bool operator==(const Group& lhs, const Group& rhs); + +/** + * Check if two Group objects are not equal. + * @returns True if the Group objects are not equal, false otherwise. + */ +LOOT_API bool operator!=(const Group& lhs, const Group& rhs); + +/** + * A less-than operator implemented with no semantics so that Group objects + * can be stored in sets. + * @returns True if the first Group is less than the second Group, false + * otherwise. + */ +LOOT_API bool operator<(const Group& lhs, const Group& rhs); + +/** + * Check if the first Group object is greater than the second Group + * object. + * @returns True if the second Group object is less than the first Group + * object, false otherwise. + */ +LOOT_API bool operator>(const Group& lhs, const Group& rhs); + +/** + * Check if the first Group object is less than or equal to the second + * Group object. + * @returns True if the first Group object is not greater than the second + * Group object, false otherwise. + */ +LOOT_API bool operator<=(const Group& lhs, const Group& rhs); + +/** + * Check if the first Group object is greater than or equal to the second + * Group object. + * @returns True if the first Group object is not less than the second + * Group object, false otherwise. + */ +LOOT_API bool operator>=(const Group& lhs, const Group& rhs); +} + +#endif diff --git a/cxx/include/loot/metadata/location.h b/cxx/include/loot/metadata/location.h new file mode 100644 index 00000000..66b319f7 --- /dev/null +++ b/cxx/include/loot/metadata/location.h @@ -0,0 +1,117 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#ifndef LOOT_METADATA_LOCATION +#define LOOT_METADATA_LOCATION + +#include +#include + +#include "loot/api_decorator.h" + +namespace loot { +/** + * Represents a URL at which the parent plugin can be found. + */ +class Location { +public: + /** + * Construct a Location with empty URL and name strings. + * @return A Location object. + */ + LOOT_API Location() = default; + + /** + * Construct a Location with the given URL and name. + * @param url + * The URL at which the plugin can be found. + * @param name + * A name for the URL, eg. the page or site name. + * @return A Location object. + */ + LOOT_API explicit Location(const std::string& url, + const std::string& name = ""); + + /** + * Get the object's URL. + * @return A URL string. + */ + LOOT_API std::string GetURL() const; + + /** + * Get the object's name. + * @return The name of the location. + */ + LOOT_API std::string GetName() const; + +private: + std::string url_; + std::string name_; +}; + +/** + * Check if two Location objects are equal by comparing their fields. + * @returns True if the objects' fields are equal, false otherwise. + */ +LOOT_API bool operator==(const Location& lhs, const Location& rhs); + +/** + * Check if two Location objects are not equal. + * @returns True if the Location objects are not equal, false otherwise. + */ +LOOT_API bool operator!=(const Location& lhs, const Location& rhs); + +/** + * A less-than operator implemented with no semantics so that Location objects + * can be stored in sets. + * @returns True if the first Location is less than the second Location, false + * otherwise. + */ +LOOT_API bool operator<(const Location& lhs, const Location& rhs); + +/** + * Check if the first Location object is greater than the second Location + * object. + * @returns True if the second Location object is less than the first Location + * object, false otherwise. + */ +LOOT_API bool operator>(const Location& lhs, const Location& rhs); + +/** + * Check if the first Location object is less than or equal to the second + * Location object. + * @returns True if the first Location object is not greater than the second + * Location object, false otherwise. + */ +LOOT_API bool operator<=(const Location& lhs, const Location& rhs); + +/** + * Check if the first Location object is greater than or equal to the second + * Location object. + * @returns True if the first Location object is not less than the second + * Location object, false otherwise. + */ +LOOT_API bool operator>=(const Location& lhs, const Location& rhs); +} + +#endif diff --git a/cxx/include/loot/metadata/message.h b/cxx/include/loot/metadata/message.h new file mode 100644 index 00000000..735d59b1 --- /dev/null +++ b/cxx/include/loot/metadata/message.h @@ -0,0 +1,139 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#ifndef LOOT_METADATA_MESSAGE +#define LOOT_METADATA_MESSAGE + +#include +#include + +#include "loot/api_decorator.h" +#include "loot/enum/message_type.h" +#include "loot/metadata/conditional_metadata.h" +#include "loot/metadata/message_content.h" + +namespace loot { +/** + * Represents a message with localisable text content. + */ +class Message : public ConditionalMetadata { +public: + /** + * Construct a Message object of type 'say' with blank content and condition + * strings. + * @return A Message object. + */ + LOOT_API Message() = default; + + /** + * Construct a Message object with the given type, English content and + * condition string. + * @param type + * The message type. + * @param content + * The English message content text. + * @param condition + * A condition string. + * @return A Message object. + */ + LOOT_API explicit Message(const MessageType type, + const std::string& content, + const std::string& condition = ""); + + /** + * Construct a Message object with the given type, content and condition + * string. + * @param type + * The message type. + * @param content + * The message content. If multilingual, one language must be English. + * @param condition + * A condition string. + * @return A Message object. + */ + LOOT_API explicit Message(const MessageType type, + const std::vector& content, + const std::string& condition = ""); + + /** + * Get the message type. + * @return The message type. + */ + LOOT_API MessageType GetType() const; + + /** + * Get the message content. + * @return The message's MessageContent objects. + */ + LOOT_API std::vector GetContent() const; + +private: + MessageType type_{MessageType::say}; + std::vector content_; +}; + +/** + * Check if two Message objects are equal by comparing their fields. + * @returns True if the objects' fields are equal, false otherwise. + */ +LOOT_API bool operator==(const Message& lhs, const Message& rhs); + +/** + * Check if two Message objects are not equal. + * @returns True if the Message objects are not equal, false otherwise. + */ +LOOT_API bool operator!=(const Message& lhs, const Message& rhs); + +/** + * A less-than operator implemented with no semantics so that Message objects + * can be stored in sets. + * @returns Returns true if the first Message is less than the second Message, + * and false otherwise. + */ +LOOT_API bool operator<(const Message& lhs, const Message& rhs); + +/** + * Check if the first Message object is greater than the second Message object. + * @returns True if the second Message object is less than the first Message + * object, false otherwise. + */ +LOOT_API bool operator>(const Message& lhs, const Message& rhs); + +/** + * Check if the first Message object is less than or equal to the second + * Message object. + * @returns True if the first Message object is not greater than the second + * Message object, false otherwise. + */ +LOOT_API bool operator<=(const Message& lhs, const Message& rhs); + +/** + * Check if the first Message object is greater than or equal to the second + * Message object. + * @returns True if the first Message object is not less than the second + * Message object, false otherwise. + */ +LOOT_API bool operator>=(const Message& lhs, const Message& rhs); +} + +#endif diff --git a/cxx/include/loot/metadata/message_content.h b/cxx/include/loot/metadata/message_content.h new file mode 100644 index 00000000..9a3b4c07 --- /dev/null +++ b/cxx/include/loot/metadata/message_content.h @@ -0,0 +1,150 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#ifndef LOOT_METADATA_MESSAGE_CONTENT +#define LOOT_METADATA_MESSAGE_CONTENT + +#include +#include +#include + +#include "loot/api_decorator.h" + +namespace loot { +/** + * Represents a message's localised text content. + */ +class MessageContent { +public: + /** + * The code for the default language assumed for message content, which is + * "en" (English). + */ + static constexpr const char* DEFAULT_LANGUAGE = "en"; + + /** + * Construct a MessageContent object with an empty English message string. + * @return A MessageContent object. + */ + LOOT_API MessageContent() = default; + + /** + * Construct a Message object with the given text in the given language. + * @param text + * The message text. + * @param language + * The language that the message is written in. + * @return A MessageContent object. + */ + LOOT_API explicit MessageContent( + const std::string& text, + const std::string& language = DEFAULT_LANGUAGE); + + /** + * Get the message text. + * @return A string containing the message text. + */ + LOOT_API std::string GetText() const; + + /** + * Get the message language. + * @return A code representing the language that the message is written in. + */ + LOOT_API std::string GetLanguage() const; + +private: + std::string text_; + std::string language_{DEFAULT_LANGUAGE}; +}; + +/** + * Check if two MessageContent objects are equal by comparing their fields. + * @returns True if the objects' fields are equal, false otherwise. + */ +LOOT_API bool operator==(const MessageContent& lhs, const MessageContent& rhs); + +/** + * Check if two MessageContent objects are not equal. + * @returns True if the MessageContent objects are not equal, false otherwise. + */ +LOOT_API bool operator!=(const MessageContent& lhs, const MessageContent& rhs); + +/** + * A less-than operator implemented with no semantics so that MessageContent + * objects can be stored in sets. + * @returns True if the first MessageContent is less than the second + * MessageContent, false otherwise. + */ +LOOT_API bool operator<(const MessageContent& lhs, const MessageContent& rhs); + +/** + * Check if the first MessageContent object is greater than the second + * MessageContent object. + * @returns True if the second MessageContent object is less than the first + * MessageContent object, false otherwise. + */ +LOOT_API bool operator>(const MessageContent& lhs, const MessageContent& rhs); + +/** + * Check if the first MessageContent object is less than or equal to the second + * MessageContent object. + * @returns True if the first MessageContent object is not greater than the + * second MessageContent object, false otherwise. + */ +LOOT_API bool operator<=(const MessageContent& lhs, const MessageContent& rhs); + +/** + * Check if the first MessageContent object is greater than or equal to the + * second MessageContent object. + * @returns True if the first MessageContent object is not less than the second + * MessageContent object, false otherwise. + */ +LOOT_API bool operator>=(const MessageContent& lhs, const MessageContent& rhs); + +/** + * Choose a MessageContent object from a vector given a language. + * @param content + * The MessageContent objects to choose between. + * @param language + * The locale or language code for the preferred language to select. + * Locale codes are of the form `[language code]_[country code]`. + * @return A MessageContent object. + * * If the vector only contains a single element, that element is + * returned. + * * If content with a language that exactly matches the given locale + * or language code is present, that content is returned. + * * If a locale code is given and there is no exact match but content + * for that locale's language is present, that content is returned. + * * If a language code is given and there is no exact match but + * content for a locale in that langauge is present, that content is + * returned. + * * If no locale or language code matches are found and content in + * the default language is present, that content is returned. + * * Otherwise, an empty optional is returned. + */ +LOOT_API std::optional SelectMessageContent( + const std::vector content, + const std::string& language); +} + +#endif diff --git a/cxx/include/loot/metadata/plugin_cleaning_data.h b/cxx/include/loot/metadata/plugin_cleaning_data.h new file mode 100644 index 00000000..8ed59662 --- /dev/null +++ b/cxx/include/loot/metadata/plugin_cleaning_data.h @@ -0,0 +1,188 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#ifndef LOOT_METADATA_PLUGIN_CLEANING_DATA +#define LOOT_METADATA_PLUGIN_CLEANING_DATA + +#include +#include + +#include "loot/api_decorator.h" +#include "loot/metadata/message.h" + +namespace loot { +/** + * Represents data identifying the plugin under which it is stored as dirty or + * clean. + */ +class PluginCleaningData { +public: + /** + * Construct a PluginCleaningData object with zero CRC, ITM count, deleted + * reference count and deleted navmesh count values, an empty utility string + * and no detail. + * @return A PluginCleaningData object. + */ + LOOT_API PluginCleaningData() = default; + + /** + * Construct a PluginCleaningData object with the given CRC and utility, + * zero ITM count, deleted reference count and deleted navmesh count + * values and no detail. + * @param crc + * The CRC of a plugin. + * @param utility + * The utility that the plugin cleanliness was checked with. + * @return A PluginCleaningData object. + */ + LOOT_API explicit PluginCleaningData(uint32_t crc, + const std::string& utility); + + /** + * Construct a PluginCleaningData object with the given values. + * @param crc + * A clean or dirty plugin's CRC. + * @param utility + * The utility that the plugin cleanliness was checked with. + * @param detail + * A vector of localised information message strings about the plugin + * cleanliness. + * @param itm + * The number of Identical To Master records found in the plugin. + * @param ref + * The number of deleted references found in the plugin. + * @param nav + * The number of deleted navmeshes found in the plugin. + * @return A PluginCleaningData object. + */ + LOOT_API explicit PluginCleaningData( + uint32_t crc, + const std::string& utility, + const std::vector& detail, + unsigned int itm, + unsigned int ref, + unsigned int nav); + + /** + * Get the CRC that identifies the plugin that the cleaning data is for. + * @return A CRC-32 checksum. + */ + LOOT_API uint32_t GetCRC() const; + + /** + * Get the number of Identical To Master records in the plugin. + * @return The number of Identical To Master records in the plugin. + */ + LOOT_API unsigned int GetITMCount() const; + + /** + * Get the number of deleted references in the plugin. + * @return The number of deleted references in the plugin. + */ + LOOT_API unsigned int GetDeletedReferenceCount() const; + + /** + * Get the number of deleted navmeshes in the plugin. + * @return The number of deleted navmeshes in the plugin. + */ + LOOT_API unsigned int GetDeletedNavmeshCount() const; + + /** + * Get the name of the cleaning utility that was used to check the plugin. + * @return A cleaning utility name, possibly related information such as + * a version number and/or a CommonMark-formatted URL to the utility's + * download location. + */ + LOOT_API std::string GetCleaningUtility() const; + + /** + * Get any additional informative message content supplied with the cleaning + * data, eg. a link to a cleaning guide or information on wild edits or manual + * cleaning steps. + * @return A vector of localised MessageContent objects. + */ + LOOT_API std::vector GetDetail() const; + +private: + uint32_t crc_{0}; + unsigned int itm_{0}; + unsigned int ref_{0}; + unsigned int nav_{0}; + std::string utility_; + std::vector detail_; +}; + +/** + * Check if two PluginCleaningData objects are equal by comparing their + * fields. + * @returns True if the objects' fields are equal, false otherwise. + */ +LOOT_API bool operator==(const PluginCleaningData& lhs, + const PluginCleaningData& rhs); + +/** + * Check if two MessageContent objects are not equal. + * @returns True if the MessageContent objects are not equal, false otherwise. + */ +LOOT_API bool operator!=(const PluginCleaningData& lhs, + const PluginCleaningData& rhs); + +/** + * A less-than operator implemented with no semantics so that + * PluginCleaningData objects can be stored in sets. + * @returns True if the first PluginCleaningData is less than the second + * PluginCleaningData, false otherwise. + */ +LOOT_API bool operator<(const PluginCleaningData& lhs, + const PluginCleaningData& rhs); + +/** + * Check if the first PluginCleaningData object is greater than the second + * PluginCleaningData object. + * @returns True if the second PluginCleaningData object is less than the first + * PluginCleaningData object, false otherwise. + */ +LOOT_API bool operator>(const PluginCleaningData& lhs, + const PluginCleaningData& rhs); + +/** + * Check if the first PluginCleaningData object is less than or equal to the + * second PluginCleaningData object. + * @returns True if the first PluginCleaningData object is not greater than the + * second PluginCleaningData object, false otherwise. + */ +LOOT_API bool operator<=(const PluginCleaningData& lhs, + const PluginCleaningData& rhs); + +/** + * Check if the first PluginCleaningData object is greater than or equal to the + * second PluginCleaningData object. + * @returns True if the first PluginCleaningData object is not less than the + * second PluginCleaningData object, false otherwise. + */ +LOOT_API bool operator>=(const PluginCleaningData& lhs, + const PluginCleaningData& rhs); +} + +#endif diff --git a/cxx/include/loot/metadata/plugin_metadata.h b/cxx/include/loot/metadata/plugin_metadata.h new file mode 100644 index 00000000..da821a90 --- /dev/null +++ b/cxx/include/loot/metadata/plugin_metadata.h @@ -0,0 +1,254 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#ifndef LOOT_METADATA_PLUGIN_METADATA +#define LOOT_METADATA_PLUGIN_METADATA + +#include +#include +#include +#include +#include +#include +#include + +#include "loot/api_decorator.h" +#include "loot/metadata/file.h" +#include "loot/metadata/location.h" +#include "loot/metadata/message.h" +#include "loot/metadata/plugin_cleaning_data.h" +#include "loot/metadata/tag.h" + +namespace loot { +/** + * Represents a plugin's metadata. + */ +class PluginMetadata { +public: + /** + * Construct a PluginMetadata object with a blank plugin name and no metadata. + * @return A PluginMetadata object. + */ + LOOT_API PluginMetadata() = default; + + /** + * Construct a PluginMetadata object with no metadata for a plugin with the + * given filename. + * @param name + * The filename of the plugin that the object is constructed for. + * @return A PluginMetadata object. + */ + LOOT_API explicit PluginMetadata(const std::string& name); + + /** + * Merge metadata from the given PluginMetadata object into this object. + * + * If an equal metadata object already exists in this PluginMetadata object, + * it is not duplicated. This object's group is replaced by the given object's + * group if the latter is explicit. + * @param plugin + * The plugin metadata to merge. + */ + LOOT_API void MergeMetadata(const PluginMetadata& plugin); + + /** + * Get the plugin name. + * @return The plugin name. + */ + LOOT_API std::string GetName() const; + + /** + * Get the plugin's group. + * @return An optional containing the name of the group this plugin belongs to + * if it was explicitly set, otherwise an optional containing no + * value. + */ + LOOT_API std::optional GetGroup() const; + + /** + * Get the plugins that the plugin must load after. + * @return The plugins that the plugin must load after. + */ + LOOT_API std::vector GetLoadAfterFiles() const; + + /** + * Get the files that the plugin requires to be installed. + * @return The files that the plugin requires to be installed. + */ + LOOT_API std::vector GetRequirements() const; + + /** + * Get the files that the plugin is incompatible with. + * @return The files that the plugin is incompatible with. + */ + LOOT_API std::vector GetIncompatibilities() const; + + /** + * Get the plugin's messages. + * @return The plugin's messages. + */ + LOOT_API std::vector GetMessages() const; + + /** + * Get the plugin's Bash Tag suggestions. + * @return The plugin's Bash Tag suggestions. + */ + LOOT_API std::vector GetTags() const; + + /** + * Get the plugin's dirty plugin information. + * @return The PluginCleaningData objects that identify the plugin as dirty. + */ + LOOT_API std::vector GetDirtyInfo() const; + + /** + * Get the plugin's clean plugin information. + * @return The PluginCleaningData objects that identify the plugin as clean. + */ + LOOT_API std::vector GetCleanInfo() const; + + /** + * Get the locations at which this plugin can be found. + * @return The locations at which this plugin can be found. + */ + LOOT_API std::vector GetLocations() const; + + /** + * Set the plugin's group. + * @param group + * The name of the group this plugin belongs to. + */ + LOOT_API void SetGroup(const std::string& group); + + /** + * Unsets the plugin's group. + */ + LOOT_API void UnsetGroup(); + + /** + * Set the files that the plugin must load after. + * @param after + * The files to set. + */ + LOOT_API void SetLoadAfterFiles(const std::vector& after); + + /** + * Set the files that the plugin requires to be installed. + * @param requirements + * The files to set. + */ + LOOT_API void SetRequirements(const std::vector& requirements); + + /** + * Set the files that the plugin must load after. + * @param incompatibilities + * The files to set. + */ + LOOT_API void SetIncompatibilities( + const std::vector& incompatibilities); + + /** + * Set the plugin's messages. + * @param messages + * The messages to set. + */ + LOOT_API void SetMessages(const std::vector& messages); + + /** + * Set the plugin's Bash Tag suggestions. + * @param tags + * The Bash Tag suggestions to set. + */ + LOOT_API void SetTags(const std::vector& tags); + + /** + * Set the plugin's dirty information. + * @param info + * The dirty information to set. + */ + LOOT_API void SetDirtyInfo(const std::vector& info); + + /** + * Set the plugin's clean information. + * @param info + * The clean information to set. + */ + LOOT_API void SetCleanInfo(const std::vector& info); + + /** + * Set the plugin's locations. + * @param locations + * The locations to set. + */ + LOOT_API void SetLocations(const std::vector& locations); + + /** + * Check if no plugin metadata is set. + * @return True if the group is implicit and the metadata containers are all + * empty, false otherwise. + */ + LOOT_API bool HasNameOnly() const; + + /** + * Check if the plugin name is a regular expression. + * @return True if the plugin name contains any of the characters `:\*?|`, + * false otherwise. + */ + LOOT_API bool IsRegexPlugin() const; + + /** + * Check if the given plugin name matches this plugin metadata object's + * name field. + * + * If the name field is a regular expression, the given plugin name will be + * matched against it, otherwise the strings will be compared + * case-insensitively. The given plugin name must be literal, i.e. not a + * regular expression. + * @returns True if the given plugin name matches this metadata's plugin + * name, false otherwise. + */ + LOOT_API bool NameMatches(const std::string& pluginName) const; + + /** + * @brief Serialises the plugin metadata as YAML. + * @returns The serialised plugin metadata. + */ + LOOT_API std::string AsYaml() const; + +private: + std::string name_; + std::optional nameRegex_; + + std::optional group_; + std::vector loadAfter_; + std::vector requirements_; + std::vector incompatibilities_; + std::vector messages_; + std::vector tags_; + std::vector dirtyInfo_; + std::vector cleanInfo_; + std::vector locations_; +}; +} + +#endif diff --git a/cxx/include/loot/metadata/tag.h b/cxx/include/loot/metadata/tag.h new file mode 100644 index 00000000..1138b3cc --- /dev/null +++ b/cxx/include/loot/metadata/tag.h @@ -0,0 +1,120 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#ifndef LOOT_METADATA_TAG +#define LOOT_METADATA_TAG + +#include + +#include "loot/api_decorator.h" +#include "loot/metadata/conditional_metadata.h" + +namespace loot { +/** + * Represents a Bash Tag suggestion for a plugin. + */ +class Tag : public ConditionalMetadata { +public: + /** + * Construct a Tag object with an empty tag name suggested for addition, with + * an empty condition string. + * @return A Tag object. + */ + LOOT_API explicit Tag() = default; + + /** + * Construct a Tag object with the given name, for addition or removal, with + * the given condition string. + * @param tag + * The name of the Bash Tag. + * @param isAddition + * True if the tag should be added, false if it should be removed. + * @param condition + * A condition string. + * @return A Tag object. + */ + LOOT_API explicit Tag(const std::string& tag, + const bool isAddition = true, + const std::string& condition = ""); + + /** + * Check if the tag should be added. + * @return True if the tag should be added, false if it should be removed. + */ + LOOT_API bool IsAddition() const; + + /** + * Get the tag's name. + * @return The tag's name. + */ + LOOT_API std::string GetName() const; + +private: + std::string name_; + bool addTag_{true}; +}; + +/** + * Check if two Tag objects are equal. + * @returns True if the objects' fields are equal, false otherwise. + */ +LOOT_API bool operator==(const Tag& lhs, const Tag& rhs); + +/** + * Check if two Tag objects are not equal. + * @returns True if the Tag objects are not equal, false otherwise. + */ +LOOT_API bool operator!=(const Tag& lhs, const Tag& rhs); + +/** + * A less-than operator implemented with no semantics so that Tag objects + * can be stored in sets. + * @returns True if the first Tag is less than the second Tag, false otherwise. + */ +LOOT_API bool operator<(const Tag& lhs, const Tag& rhs); + +/** + * Check if the first Tag object is greater than the second Tag object. + * @returns True if the second Tag object is less than the first Tag object, + * false otherwise. + */ +LOOT_API bool operator>(const Tag& lhs, const Tag& rhs); + +/** + * Check if the first Tag object is less than or equal to the second Tag + * object. + * @returns True if the first Tag object is not greater than the second Tag + * object, false otherwise. + */ +LOOT_API bool operator<=(const Tag& lhs, const Tag& rhs); + +/** + * Check if the first Tag object is greater than or equal to the second Tag + * object. + * @returns True if the first Tag object is not less than the second Tag + * object, false otherwise. + */ +LOOT_API bool operator>=(const Tag& lhs, const Tag& rhs); +} + +#endif diff --git a/cxx/include/loot/plugin_interface.h b/cxx/include/loot/plugin_interface.h new file mode 100644 index 00000000..4bec165d --- /dev/null +++ b/cxx/include/loot/plugin_interface.h @@ -0,0 +1,178 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#ifndef LOOT_PLUGIN_INTERFACE +#define LOOT_PLUGIN_INTERFACE + +#include +#include +#include +#include +#include + +#include "loot/metadata/message.h" +#include "loot/metadata/tag.h" + +namespace loot { +/** + * Represents a plugin file that has been parsed by LOOT. + */ +class PluginInterface { +public: + virtual ~PluginInterface() = default; + + /** + * Get the plugin's filename. + * @return The plugin filename. If the plugin was ghosted when it was loaded, + * this filename will be without the .ghost suffix, unless the game is + * OpenMW, in which case ghosted plugins are not supported. + */ + virtual std::string GetName() const = 0; + + /** + * Get the value of the version field in the HEDR subrecord of the plugin's + * TES4 record. + * @return The value of the version field, or an empty optional if that value + * is NaN or could not be found. + */ + virtual std::optional GetHeaderVersion() const = 0; + + /** + * Get the plugin's version number from its description field. + * + * The description field may not contain a version number, or LOOT may be + * unable to detect it. The description field parsing may fail to extract the + * version number correctly, though it functions correctly in all known cases. + * @return An optional containing a version string if one is found, otherwise + * an optional containing no value. + */ + virtual std::optional GetVersion() const = 0; + + /** + * Get the plugin's masters. + * @return The plugin's masters in the same order they are listed in the file. + */ + virtual std::vector GetMasters() const = 0; + + /** + * Get any Bash Tags found in the plugin's description field. + * @return A set of Bash Tags. The order of elements in the set holds no + * semantics. + */ + virtual std::vector GetBashTags() const = 0; + + /** + * Get the plugin's CRC-32 checksum. + * @return An optional containing the plugin's CRC-32 checksum if the plugin + * has been fully loaded, otherwise an optional containing no value. + */ + virtual std::optional GetCRC() const = 0; + + /** + * Check if the plugin is a master plugin. + * + * What causes a plugin to be a master plugin varies by game, but is usually + * indicated by the plugin having its master flag set and/or by its file + * extension. However, OpenMW uses neither for determining plugins' load order + * so all OpenMW plugins are treated as non-masters. + * + * The term "master" is potentially confusing: a plugin A may not be a *master + * plugin*, but may still be a *master of* another plugin by being listed as + * such in that plugin's header record. Master plugins are sometimes referred + * to as *master files* or simply *masters*, while the other meaning is always + * referenced in relation to another plugin. + * @return True if the plugin is a master plugin, false otherwise. + */ + virtual bool IsMaster() const = 0; + + /** + * Check if the plugin is a light plugin. + * @return True if plugin is a light plugin, false otherwise. + */ + virtual bool IsLightPlugin() const = 0; + + /** + * Check if the plugin is a medium plugin. + * @return True if plugin is a medium plugin, false otherwise. + */ + virtual bool IsMediumPlugin() const = 0; + + /** + * Check if the plugin is an update plugin. + * @return True if plugin is an update plugin, false otherwise. + */ + virtual bool IsUpdatePlugin() const = 0; + + /** + * Check if the plugin is a blueprint plugin. + * @return True if plugin is a blueprint plugin, false otherwise. + */ + virtual bool IsBlueprintPlugin() const = 0; + + /** + * Check if the plugin is or would be valid as a light plugin. + * @return True if the plugin is a valid light plugin or would be a valid + * light plugin, false otherwise. + */ + virtual bool IsValidAsLightPlugin() const = 0; + + /** + * Check if the plugin is or would be valid as a medium plugin. + * @return True if the plugin is a valid medium plugin or would be a valid + * medium plugin, false otherwise. + */ + virtual bool IsValidAsMediumPlugin() const = 0; + + /** + * Check if the plugin is or would be valid as an update plugin. + * @return True if the plugin is a valid update plugin or would be a valid + * update plugin, false otherwise. + */ + virtual bool IsValidAsUpdatePlugin() const = 0; + + /** + * Check if the plugin contains any records other than its TES4 header. + * @return True if the plugin only contains a TES4 header, false otherwise. + */ + virtual bool IsEmpty() const = 0; + + /** + * Check if the plugin loads an archive (BSA/BA2 depending on the game). + * @return True if the plugin loads an archive, false otherwise. + */ + virtual bool LoadsArchive() const = 0; + + /** + * Check if two plugins contain a record with the same ID. + * @param plugin + * The other plugin to check for overlap with. + * @return True if the plugins both contain at least one record with the same + * ID, false otherwise. FormIDs are compared for all games apart from + * Morrowind, which doesn't have FormIDs and so has other identifying + * data compared. + */ + virtual bool DoRecordsOverlap(const PluginInterface& plugin) const = 0; +}; +} + +#endif diff --git a/cxx/include/loot/vertex.h b/cxx/include/loot/vertex.h new file mode 100644 index 00000000..861d652c --- /dev/null +++ b/cxx/include/loot/vertex.h @@ -0,0 +1,74 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_VERTEX +#define LOOT_VERTEX + +#include +#include + +#include "loot/api_decorator.h" +#include "loot/enum/edge_type.h" + +namespace loot { +/** + * @brief A class representing a plugin or group vertex in a path, and the + type of the edge to the next vertex in the path if one exists. + */ +class Vertex { +public: + /** + * @brief Construct a Vertex with the given name and no out edge. + * @param name The name of the plugin or group that this vertex represents. + */ + LOOT_API explicit Vertex(std::string name); + + /** + * @brief Construct a Vertex with the given name and out edge type. + * @param name The name of the plugin or group that this vertex represents. + * @param outEdgeType The type of the edge going out from this vertex. + */ + LOOT_API explicit Vertex(std::string name, EdgeType outEdgeType); + + /** + * @brief Get the name of the plugin or group. + * @return The name of the plugin or group. + */ + LOOT_API std::string GetName() const; + + /** + * @brief Get the type of the edge going to the next vertex. + * @details Each edge goes from the vertex that loads earlier to the vertex + * that loads later. + * @return The edge type. + */ + LOOT_API std::optional GetTypeOfEdgeToNextVertex() const; + +private: + std::string name_; + std::optional outEdgeType_; +}; +} + +#endif From 6ea0c47f37d3fd4c092deeb32b56960b5659db12 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 12 Mar 2025 17:25:33 +0000 Subject: [PATCH 030/206] Add libloot v0.25.3 public API tests --- .../api/interface/api_game_operations_test.h | 132 +++ .../api/interface/create_game_handle_test.h | 157 ++++ .../api/interface/database_interface_test.h | 818 ++++++++++++++++++ .../tests/api/interface/game_interface_test.h | 383 ++++++++ .../tests/api/interface/is_compatible_test.h | 61 ++ cxx/src/tests/api/interface/main.cpp | 126 +++ cxx/src/tests/common_game_test_fixture.h | 507 +++++++++++ cxx/src/tests/printers.h | 102 +++ cxx/src/tests/test_helpers.h | 84 ++ 9 files changed, 2370 insertions(+) create mode 100644 cxx/src/tests/api/interface/api_game_operations_test.h create mode 100644 cxx/src/tests/api/interface/create_game_handle_test.h create mode 100644 cxx/src/tests/api/interface/database_interface_test.h create mode 100644 cxx/src/tests/api/interface/game_interface_test.h create mode 100644 cxx/src/tests/api/interface/is_compatible_test.h create mode 100644 cxx/src/tests/api/interface/main.cpp create mode 100644 cxx/src/tests/common_game_test_fixture.h create mode 100644 cxx/src/tests/printers.h create mode 100644 cxx/src/tests/test_helpers.h diff --git a/cxx/src/tests/api/interface/api_game_operations_test.h b/cxx/src/tests/api/interface/api_game_operations_test.h new file mode 100644 index 00000000..98cab2ae --- /dev/null +++ b/cxx/src/tests/api/interface/api_game_operations_test.h @@ -0,0 +1,132 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2013-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERFACE_API_GAME_OPERATIONS_TEST +#define LOOT_TESTS_API_INTERFACE_API_GAME_OPERATIONS_TEST + +#include + +#include "loot/api.h" +#include "tests/common_game_test_fixture.h" + +namespace loot { +namespace test { +class ApiGameOperationsTest : public CommonGameTestFixture, + public testing::WithParamInterface { +protected: + ApiGameOperationsTest() : + CommonGameTestFixture(GetParam()), + handle_(nullptr), + masterlistPath(localPath / "masterlist.yaml"), + noteMessage( + "Do not clean ITM records, they are intentional and required for " + "the mod to function."), + warningMessage( + "Check you are using v2+. If not, Update. v1 has a severe bug with " + "the Mystic Emporium disappearing."), + errorMessage("Obsolete. Remove this and install Enhanced Weather."), + generalMasterlistMessage("A general masterlist message.") {} + + void SetUp() override { + CommonGameTestFixture::SetUp(); + + ASSERT_FALSE(std::filesystem::exists(masterlistPath)); + + handle_ = CreateGameHandle(GetParam(), gamePath, localPath); + } + + void GenerateMasterlist() { + using std::endl; + + std::ofstream masterlist(masterlistPath); + masterlist << "bash_tags:" << endl + << " - Actors.ACBS" << endl + << " - C.Climate" << endl + << "globals:" << endl + << " - type: say" << endl + << " content: '" << generalMasterlistMessage << "'" << endl + << " condition: 'file(\"" << missingEsp << "\")'" << endl + << "groups:" << endl + << " - name: group1" << endl + << " - name: group2" << endl + << " after:" << endl + << " - group1" << endl + << "plugins:" << endl + << " - name: " << blankEsm << endl + << " after:" << endl + << " - " << masterFile << endl + << " msg:" << endl + << " - type: say" << endl + << " content: '" << noteMessage << "'" << endl + << " condition: 'file(\"" << missingEsp << "\")'" << endl + << " tag:" << endl + << " - Actors.ACBS" << endl + << " - Actors.AIData" << endl + << " - '-C.Water'" << endl + << " - name: " << blankDifferentEsm << endl + << " after:" << endl + << " - " << blankMasterDependentEsm << endl + << " msg:" << endl + << " - type: warn" << endl + << " content: '" << warningMessage << "'" << endl + << " dirty:" << endl + << " - crc: 0x7d22f9df" << endl + << " util: TES4Edit" << endl + << " udr: 4" << endl + << " - name: " << blankDifferentEsp << endl + << " after:" << endl + << " - " << blankPluginDependentEsp << endl + << " msg:" << endl + << " - type: error" << endl + << " content: '" << errorMessage << "'" << endl + << " - name: " << blankEsp << endl + << " after:" << endl + << " - " << blankDifferentMasterDependentEsp << endl + << " - name: " << blankDifferentMasterDependentEsp << endl + << " after:" << endl + << " - " << blankMasterDependentEsp << endl + << " msg:" << endl + << " - type: say" << endl + << " content: '" << noteMessage << "'" << endl + << " - type: warn" << endl + << " content: '" << warningMessage << "'" << endl + << " - type: error" << endl + << " content: '" << errorMessage << "'" << endl; + + masterlist.close(); + } + + std::unique_ptr handle_; + + const std::filesystem::path masterlistPath; + + const std::string noteMessage; + const std::string warningMessage; + const std::string errorMessage; + const std::string generalMasterlistMessage; +}; +} +} + +#endif diff --git a/cxx/src/tests/api/interface/create_game_handle_test.h b/cxx/src/tests/api/interface/create_game_handle_test.h new file mode 100644 index 00000000..07b7a01b --- /dev/null +++ b/cxx/src/tests/api/interface/create_game_handle_test.h @@ -0,0 +1,157 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERFACE_CREATE_GAME_HANDLE_TEST +#define LOOT_TESTS_API_INTERFACE_CREATE_GAME_HANDLE_TEST + +#include + +#include "loot/api.h" +#include "tests/common_game_test_fixture.h" + +namespace loot { +namespace test { +class CreateGameHandleTest : public CommonGameTestFixture, + public testing::WithParamInterface { +protected: + CreateGameHandleTest() : + CommonGameTestFixture(GetParam()), + handle_(nullptr), + gamePathSymlink(gamePath.string() + ".symlink"), + localPathSymlink(localPath.string() + ".symlink"), + gamePathJunctionLink(gamePath.string() + ".junction"), + localPathJunctionLink(localPath.string() + ".junction"), + originalWorkingDirectory(std::filesystem::current_path()) {} + + void SetUp() override { + using std::filesystem::file_type; + using std::filesystem::status; + CommonGameTestFixture::SetUp(); + + std::filesystem::create_directory_symlink(gamePath, gamePathSymlink); + ASSERT_EQ(file_type::directory, status(gamePathSymlink).type()); + + std::filesystem::create_directory_symlink(localPath, localPathSymlink); + ASSERT_EQ(file_type::directory, status(localPathSymlink).type()); + +#ifdef _WIN32 + system(("mklink /J \"" + + std::filesystem::absolute(gamePathJunctionLink).string() + "\" \"" + + std::filesystem::absolute(dataPath).parent_path().string() + "\"") + .c_str()); + system(("mklink /J \"" + + std::filesystem::absolute(localPathJunctionLink).string() + + "\" \"" + std::filesystem::absolute(localPath).string() + "\"") + .c_str()); +#endif + + // relative() doesn't work when the current working directory and the given + // path are on separate drives, so ensure that's not the case. + std::filesystem::current_path(gamePath.parent_path()); + } + + void TearDown() override { + std::filesystem::current_path(originalWorkingDirectory); + } + + std::unique_ptr handle_; + + const std::filesystem::path gamePathSymlink; + const std::filesystem::path localPathSymlink; + const std::filesystem::path gamePathJunctionLink; + const std::filesystem::path localPathJunctionLink; + const std::filesystem::path originalWorkingDirectory; +}; + +// Pass an empty first argument, as it's a prefix for the test instantation, +// but we only have the one so no prefix is necessary. +INSTANTIATE_TEST_SUITE_P(, + CreateGameHandleTest, + ::testing::Values(GameType::tes4, + GameType::tes5, + GameType::fo3, + GameType::fonv, + GameType::fo4, + GameType::tes5se, + GameType::fo4vr, + GameType::tes5vr, + GameType::tes3, + GameType::starfield, + GameType::openmw)); + +TEST_P(CreateGameHandleTest, + shouldSucceedIfPassedValidParametersWithRelativePaths) { + using std::filesystem::relative; + EXPECT_NO_THROW(handle_ = CreateGameHandle( + GetParam(), relative(gamePath), relative(localPath))); + EXPECT_TRUE(handle_); +} + +TEST_P(CreateGameHandleTest, + shouldSucceedIfPassedValidParametersWithAbsolutePaths) { + EXPECT_NO_THROW(handle_ = CreateGameHandle(GetParam(), gamePath, localPath)); + EXPECT_TRUE(handle_); +} + +TEST_P(CreateGameHandleTest, shouldThrowIfPassedAGamePathThatDoesNotExist) { + EXPECT_THROW(CreateGameHandle(GetParam(), missingPath, localPath), + std::invalid_argument); +} + +TEST_P(CreateGameHandleTest, shouldSucceedIfPassedALocalPathThatDoesNotExist) { + EXPECT_NO_THROW(handle_ = + CreateGameHandle(GetParam(), gamePath, missingPath)); + EXPECT_TRUE(handle_); +} + +TEST_P(CreateGameHandleTest, shouldThrowIfPassedALocalPathThatIsNotADirectory) { + EXPECT_THROW(CreateGameHandle(GetParam(), gamePath, dataPath / blankEsm), + std::invalid_argument); +} + +#ifdef _WIN32 +TEST_P(CreateGameHandleTest, shouldReturnOkIfPassedAnEmptyLocalPathString) { + EXPECT_NO_THROW(handle_ = CreateGameHandle(GetParam(), gamePath, "")); + EXPECT_TRUE(handle_); +} +#endif + +TEST_P(CreateGameHandleTest, shouldReturnOkIfPassedGameAndLocalPathSymlinks) { + EXPECT_NO_THROW(handle_ = CreateGameHandle( + GetParam(), gamePathSymlink, localPathSymlink)); + EXPECT_TRUE(handle_); +} + +#ifdef _WIN32 +TEST_P(CreateGameHandleTest, + shouldReturnOkIfPassedGameAndLocalPathJunctionLinks) { + EXPECT_NO_THROW(handle_ = CreateGameHandle( + GetParam(), gamePathJunctionLink, localPathJunctionLink)); + EXPECT_TRUE(handle_); +} +#endif +} +} + +#endif diff --git a/cxx/src/tests/api/interface/database_interface_test.h b/cxx/src/tests/api/interface/database_interface_test.h new file mode 100644 index 00000000..2a4511b2 --- /dev/null +++ b/cxx/src/tests/api/interface/database_interface_test.h @@ -0,0 +1,818 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERFACE_DATABASE_INTERFACE_TEST +#define LOOT_TESTS_API_INTERFACE_DATABASE_INTERFACE_TEST + +#include "loot/api.h" +#include "tests/api/interface/api_game_operations_test.h" + +namespace loot { +namespace test { +class DatabaseInterfaceTest : public ApiGameOperationsTest { +protected: + DatabaseInterfaceTest() : + userlistPath_(localPath / "userlist.yaml"), + minimalOutputPath_(localPath / "minimal.yml"), + generalUserlistMessage("A general userlist message.") {} + + void SetUp() override { + ApiGameOperationsTest::SetUp(); + + ASSERT_FALSE(std::filesystem::exists(minimalOutputPath_)); + } + + std::string GetFileContent(const std::filesystem::path& file) { + std::ifstream stream(file); + std::stringstream content; + content << stream.rdbuf(); + + return content.str(); + } + + void GenerateUserlist() { + using std::endl; + + std::ofstream userlist(userlistPath_); + userlist << "bash_tags:" << endl + << " - RaceRelations" << endl + << " - C.Lighting" << endl + << "groups:" << endl + << " - name: group2" << endl + << " after:" << endl + << " - default" << endl + << " - name: group3" << endl + << " after:" << endl + << " - group1" << endl + << "globals:" << endl + << " - type: say" << endl + << " content: '" << generalUserlistMessage << "'" << endl + << "plugins:" << endl + << " - name: " << blankEsm << endl + << " after:" << endl + << " - " << blankDifferentEsm << endl + << " tag:" << endl + << " - name: Actors.ACBS" << endl + << " condition: 'file(\"" << missingEsp << "\")'" << endl + << " - name: " << blankDifferentEsp << endl + << " inc:" << endl + << " - " << blankEsp << endl + << " tag:" << endl + << " - name: C.Climate" << endl + << " condition: 'file(\"" << missingEsp << "\")'" << endl; + + userlist.close(); + } + + const std::filesystem::path userlistPath_; + const std::filesystem::path minimalOutputPath_; + const std::string generalUserlistMessage; +}; + +// Pass an empty first argument, as it's a prefix for the test instantation, +// but we only have the one so no prefix is necessary. +INSTANTIATE_TEST_SUITE_P(, + DatabaseInterfaceTest, + ::testing::Values(GameType::tes4, + GameType::tes5, + GameType::fo3, + GameType::fonv, + GameType::fo4, + GameType::tes5se)); + +TEST_P(DatabaseInterfaceTest, + loadListsShouldSucceedEvenIfGameHandleIsDiscarded) { + handle_ = CreateGameHandle(GetParam(), gamePath, localPath); + + ASSERT_NO_THROW(GenerateMasterlist()); + + EXPECT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); +} + +TEST_P(DatabaseInterfaceTest, loadListsShouldThrowIfNoMasterlistIsPresent) { + EXPECT_THROW(handle_->GetDatabase().LoadLists(masterlistPath, ""), + FileAccessError); +} + +TEST_P( + DatabaseInterfaceTest, + loadListsShouldThrowIfAMasterlistIsPresentButAUserlistDoesNotExistAtTheGivenPath) { + ASSERT_NO_THROW(GenerateMasterlist()); + EXPECT_THROW(handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_), + FileAccessError); +} + +TEST_P( + DatabaseInterfaceTest, + loadListsShouldSucceedIfTheMasterlistIsPresentAndTheUserlistPathIsAnEmptyString) { + ASSERT_NO_THROW(GenerateMasterlist()); + + EXPECT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); +} + +TEST_P(DatabaseInterfaceTest, + loadListsShouldSucceedIfTheMasterlistAndUserlistAreBothPresent) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(std::filesystem::copy(masterlistPath, userlistPath_)); + + EXPECT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); +} + +TEST_P( + DatabaseInterfaceTest, + loadListsShouldThrowIfAMasterlistIsPresentButAPreludeDoesNotExistAtTheGivenPath) { + ASSERT_NO_THROW(GenerateMasterlist()); + + auto preludePath = localPath / "prelude.yaml"; + + EXPECT_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, "", preludePath), + FileAccessError); +} + +TEST_P(DatabaseInterfaceTest, + loadListsShouldSucceedIfTheMasterlistAndPreludeAreBothPresent) { + using std::endl; + + std::ofstream out(masterlistPath); + out << "prelude:" << endl + << " - &ref" << endl + << " type: say" << endl + << " content: Loaded from same file" << endl + << "globals:" << endl + << " - *ref" << endl; + + out.close(); + + auto preludePath = localPath / "prelude.yaml"; + out.open(preludePath); + out << "common:" << endl + << " - &ref" << endl + << " type: say" << endl + << " content: Loaded from prelude" << endl; + + EXPECT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, "", preludePath)); + + auto messages = handle_->GetDatabase().GetGeneralMessages(); + ASSERT_EQ(1, messages.size()); + EXPECT_EQ(MessageType::say, messages[0].GetType()); + ASSERT_EQ(1, messages[0].GetContent().size()); + EXPECT_EQ("Loaded from prelude", messages[0].GetContent()[0].GetText()); +} + +TEST_P( + DatabaseInterfaceTest, + writeUserMetadataShouldThrowIfTheFileAlreadyExistsAndTheOverwriteArgumentIsFalse) { + ASSERT_NO_THROW( + handle_->GetDatabase().WriteUserMetadata(minimalOutputPath_, false)); + ASSERT_TRUE(std::filesystem::exists(minimalOutputPath_)); + + EXPECT_THROW( + handle_->GetDatabase().WriteUserMetadata(minimalOutputPath_, false), + FileAccessError); +} + +TEST_P( + DatabaseInterfaceTest, + writeUserMetadataShouldReturnOkAndWriteToFileIfTheArgumentsAreValidAndTheOverwriteArgumentIsTrue) { + EXPECT_NO_THROW( + handle_->GetDatabase().WriteUserMetadata(minimalOutputPath_, true)); + EXPECT_TRUE(std::filesystem::exists(minimalOutputPath_)); +} + +TEST_P( + DatabaseInterfaceTest, + writeUserMetadataShouldReturnOkIfTheFileAlreadyExistsAndTheOverwriteArgumentIsTrue) { + ASSERT_NO_THROW( + handle_->GetDatabase().WriteUserMetadata(minimalOutputPath_, false)); + ASSERT_TRUE(std::filesystem::exists(minimalOutputPath_)); + + EXPECT_NO_THROW( + handle_->GetDatabase().WriteUserMetadata(minimalOutputPath_, true)); +} + +TEST_P(DatabaseInterfaceTest, + writeUserMetadataShouldThrowIfPathGivenExistsAndIsReadOnly) { + ASSERT_NO_THROW( + handle_->GetDatabase().WriteUserMetadata(minimalOutputPath_, false)); + ASSERT_TRUE(std::filesystem::exists(minimalOutputPath_)); + + std::filesystem::permissions(minimalOutputPath_, + std::filesystem::perms::owner_read, + std::filesystem::perm_options::replace); + + EXPECT_THROW( + handle_->GetDatabase().WriteUserMetadata(minimalOutputPath_, true), + FileAccessError); +} + +TEST_P(DatabaseInterfaceTest, + writeUserMetadataShouldShouldNotWriteMasterlistMetadata) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); + + EXPECT_NO_THROW( + handle_->GetDatabase().WriteUserMetadata(minimalOutputPath_, true)); + + EXPECT_EQ("{}", GetFileContent(minimalOutputPath_)); +} + +TEST_P(DatabaseInterfaceTest, writeUserMetadataShouldShouldWriteUserMetadata) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(std::filesystem::copy(masterlistPath, userlistPath_)); + + std::ofstream masterlist(masterlistPath); + masterlist << "bash_tags:\n []\nglobals:\n []\nplugins:\n []"; + masterlist.close(); + + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + EXPECT_NO_THROW( + handle_->GetDatabase().WriteUserMetadata(minimalOutputPath_, true)); + + EXPECT_FALSE(GetFileContent(minimalOutputPath_).empty()); +} + +TEST_P(DatabaseInterfaceTest, + getGroupsShouldReturnAllGroupsListedInTheLoadedMetadata) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + auto groups = handle_->GetDatabase().GetGroups(); + + ASSERT_EQ(4, groups.size()); + + EXPECT_EQ("default", groups[0].GetName()); + EXPECT_TRUE(groups[0].GetAfterGroups().empty()); + + EXPECT_EQ("group1", groups[1].GetName()); + EXPECT_TRUE(groups[1].GetAfterGroups().empty()); + + EXPECT_EQ("group2", groups[2].GetName()); + EXPECT_EQ(std::vector({"group1", "default"}), + groups[2].GetAfterGroups()); + + EXPECT_EQ("group3", groups[3].GetName()); + EXPECT_EQ(std::vector({"group1"}), groups[3].GetAfterGroups()); +} + +TEST_P(DatabaseInterfaceTest, + getGroupsShouldReturnDefaultGroupEvenIfNoMetadataIsLoaded) { + auto groups = handle_->GetDatabase().GetGroups(); + + ASSERT_EQ(1, groups.size()); + + EXPECT_EQ("default", groups[0].GetName()); + EXPECT_TRUE(groups[0].GetAfterGroups().empty()); +} + +TEST_P(DatabaseInterfaceTest, + getGroupsShouldNotIncludeUserlistMetadataIfParameterIsFalse) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + auto groups = handle_->GetDatabase().GetGroups(false); + + ASSERT_EQ(3, groups.size()); + + EXPECT_EQ("default", groups[0].GetName()); + EXPECT_TRUE(groups[0].GetAfterGroups().empty()); + + EXPECT_EQ("group1", groups[1].GetName()); + EXPECT_TRUE(groups[1].GetAfterGroups().empty()); + + EXPECT_EQ("group2", groups[2].GetName()); + EXPECT_EQ(std::vector({"group1"}), groups[2].GetAfterGroups()); +} + +TEST_P( + DatabaseInterfaceTest, + getGroupsShouldReturnDefaultGroupIfNoMasterlistIsLoadedAndUserlistMetadataIsNotIncluded) { + auto groups = handle_->GetDatabase().GetGroups(false); + + EXPECT_EQ(1, groups.size()); + + EXPECT_EQ("default", groups.begin()->GetName()); + EXPECT_TRUE(groups.begin()->GetAfterGroups().empty()); +} + +TEST_P(DatabaseInterfaceTest, + getUserGroupsShouldReturnOnlyGroupMetadataFromTheUserlist) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + auto groups = handle_->GetDatabase().GetUserGroups(); + + ASSERT_EQ(3, groups.size()); + + EXPECT_EQ("default", groups[0].GetName()); + EXPECT_TRUE(groups[0].GetAfterGroups().empty()); + + EXPECT_EQ("group2", groups[1].GetName()); + EXPECT_EQ(std::vector({"default"}), groups[1].GetAfterGroups()); + + EXPECT_EQ("group3", groups[2].GetName()); + EXPECT_EQ(std::vector({"group1"}), groups[2].GetAfterGroups()); +} + +TEST_P( + DatabaseInterfaceTest, + setUserGroupsShouldReplaceExistingUserGroupMetadataWithTheGivenMetadata) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + handle_->GetDatabase().SetUserGroups(std::vector({ + Group("group4"), + })); + + auto groups = handle_->GetDatabase().GetUserGroups(); + + ASSERT_EQ(2, groups.size()); + + EXPECT_EQ("default", groups[0].GetName()); + EXPECT_TRUE(groups[0].GetAfterGroups().empty()); + + EXPECT_EQ("group4", groups[1].GetName()); + EXPECT_TRUE(groups[1].GetAfterGroups().empty()); +} + +TEST_P(DatabaseInterfaceTest, + getGroupsPathShouldReturnTheShortestPathBetweenTheGivenGroups) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + auto path = handle_->GetDatabase().GetGroupsPath("group1", "group3"); + + ASSERT_EQ(2, path.size()); + EXPECT_EQ("group1", path[0].GetName()); + EXPECT_EQ(EdgeType::userLoadAfter, path[0].GetTypeOfEdgeToNextVertex()); + EXPECT_EQ("group3", path[1].GetName()); + EXPECT_FALSE(path[1].GetTypeOfEdgeToNextVertex().has_value()); +} + +TEST_P(DatabaseInterfaceTest, + getKnownBashTagsShouldReturnAllBashTagsListedInLoadedMetadata) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + auto tags = handle_->GetDatabase().GetKnownBashTags(); + + std::vector expectedTags({ + "Actors.ACBS", + "C.Climate", + "RaceRelations", + "C.Lighting", + }); + EXPECT_EQ(expectedTags, tags); +} + +TEST_P(DatabaseInterfaceTest, + getGeneralMessagesShouldGetGeneralMessagesFromTheMasterlistAndUserlist) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + auto messages = handle_->GetDatabase().GetGeneralMessages(); + + std::vector expectedMessages({ + Message(MessageType::say, + generalMasterlistMessage, + "file(\"" + missingEsp + "\")"), + Message(MessageType::say, generalUserlistMessage), + }); + EXPECT_EQ(expectedMessages, messages); +} + +TEST_P( + DatabaseInterfaceTest, + getGeneralMessagesShouldReturnOnlyValidMessagesIfConditionsAreEvaluated) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); + + auto messages = handle_->GetDatabase().GetGeneralMessages(true); + + EXPECT_TRUE(messages.empty()); +} + +TEST_P(DatabaseInterfaceTest, + getPluginMetadataShouldReturnAnEmptyOptionalIfThePluginHasNoMetadata) { + EXPECT_FALSE(handle_->GetDatabase().GetPluginMetadata(blankEsm)); +} + +TEST_P( + DatabaseInterfaceTest, + getPluginMetadataShouldReturnMergedMasterAndUserMetadataForTheGivenPluginIfIncludeUserMetadataIsTrue) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + auto metadata = + handle_->GetDatabase().GetPluginMetadata(blankEsm, true).value(); + + std::vector expectedLoadAfter({ + File(blankDifferentEsm), + File(masterFile), + }); + EXPECT_EQ(expectedLoadAfter, metadata.GetLoadAfterFiles()); +} + +TEST_P(DatabaseInterfaceTest, + getPluginMetadataShouldPreferUserMetadataWhenMergingMetadata) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + auto metadata = + handle_->GetDatabase().GetPluginMetadata(blankEsm, true).value(); + + std::vector expectedTags({ + Tag("Actors.ACBS", true, "file(\"" + missingEsp + "\")"), + Tag("Actors.ACBS"), + Tag("Actors.AIData"), + Tag("C.Water", false), + }); + EXPECT_EQ(expectedTags, metadata.GetTags()); + EXPECT_EQ("file(\"" + missingEsp + "\")", + metadata.GetTags()[0].GetCondition()); +} + +TEST_P( + DatabaseInterfaceTest, + getPluginMetadataShouldReturnOnlyMasterlistMetadataForTheGivenPluginIfIncludeUserMetadataIsFalse) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + auto metadata = + handle_->GetDatabase().GetPluginMetadata(blankEsm, false).value(); + + std::vector expectedLoadAfter({ + File(masterFile), + }); + EXPECT_EQ(expectedLoadAfter, metadata.GetLoadAfterFiles()); +} + +TEST_P( + DatabaseInterfaceTest, + getPluginMetadataShouldReturnOnlyValidMetadataForTheGivenPluginIfConditionsAreEvaluated) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); + + auto metadata = + handle_->GetDatabase().GetPluginMetadata(blankEsm, false, true).value(); + + EXPECT_TRUE(metadata.GetMessages().empty()); +} + +TEST_P( + DatabaseInterfaceTest, + getPluginUserMetadataShouldReturnAnEmptyPluginMetadataObjectIfThePluginHasNoUserMetadata) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + EXPECT_FALSE(handle_->GetDatabase().GetPluginUserMetadata(blankDifferentEsm)); +} + +TEST_P(DatabaseInterfaceTest, + getPluginUserMetadataShouldReturnOnlyUserMetadataForTheGivenPlugin) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + auto metadata = + handle_->GetDatabase().GetPluginUserMetadata(blankEsm).value(); + + std::vector expectedLoadAfter({ + File(blankDifferentEsm), + }); + EXPECT_EQ(expectedLoadAfter, metadata.GetLoadAfterFiles()); +} + +TEST_P( + DatabaseInterfaceTest, + getPluginUserMetadataShouldReturnOnlyValidMetadataForTheGivenPluginIfConditionsAreEvaluated) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + auto metadata = + handle_->GetDatabase().GetPluginMetadata(blankEsm, false, true).value(); + + EXPECT_TRUE(metadata.GetMessages().empty()); +} + +TEST_P( + DatabaseInterfaceTest, + setPluginUserMetadataShouldReplaceExistingUserMetadataWithTheGivenMetadata) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + PluginMetadata newMetadata(blankDifferentEsp); + newMetadata.SetRequirements(std::vector({File(masterFile)})); + + handle_->GetDatabase().SetPluginUserMetadata(newMetadata); + + auto metadata = + handle_->GetDatabase().GetPluginUserMetadata(blankDifferentEsp).value(); + + std::set expectedLoadAfter({ + File(blankDifferentEsm), + }); + EXPECT_TRUE(metadata.GetIncompatibilities().empty()); + EXPECT_EQ(newMetadata.GetRequirements(), metadata.GetRequirements()); +} + +TEST_P(DatabaseInterfaceTest, + setPluginUserMetadataShouldNotAffectExistingMasterlistMetadata) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + PluginMetadata newMetadata(blankEsm); + newMetadata.SetRequirements(std::vector({File(masterFile)})); + + handle_->GetDatabase().SetPluginUserMetadata(newMetadata); + + auto metadata = handle_->GetDatabase().GetPluginMetadata(blankEsm).value(); + + std::vector expectedLoadAfter({ + File(masterFile), + }); + EXPECT_EQ(expectedLoadAfter, metadata.GetLoadAfterFiles()); +} + +TEST_P(DatabaseInterfaceTest, + discardPluginUserMetadataShouldDiscardAllUserMetadataForTheGivenPlugin) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + handle_->GetDatabase().DiscardPluginUserMetadata(blankEsm); + + EXPECT_FALSE(handle_->GetDatabase().GetPluginUserMetadata(blankEsm)); +} + +TEST_P( + DatabaseInterfaceTest, + discardPluginUserMetadataShouldNotDiscardMasterlistMetadataForTheGivenPlugin) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + handle_->GetDatabase().DiscardPluginUserMetadata(blankEsm); + + auto metadata = handle_->GetDatabase().GetPluginMetadata(blankEsm).value(); + + std::vector expectedLoadAfter({ + File(masterFile), + }); + EXPECT_EQ(expectedLoadAfter, metadata.GetLoadAfterFiles()); +} + +TEST_P(DatabaseInterfaceTest, + discardPluginUserMetadataShouldNotDiscardUserMetadataForOtherPlugins) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + handle_->GetDatabase().DiscardPluginUserMetadata(blankEsm); + + auto metadata = + handle_->GetDatabase().GetPluginUserMetadata(blankDifferentEsp); + + EXPECT_TRUE(metadata); +} + +TEST_P(DatabaseInterfaceTest, + discardPluginUserMetadataShouldNotDiscardGeneralMessages) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + handle_->GetDatabase().DiscardPluginUserMetadata(blankEsm); + + auto messages = handle_->GetDatabase().GetGeneralMessages(); + + std::vector expectedMessages({ + Message(MessageType::say, + generalMasterlistMessage, + "file(\"" + missingEsp + "\")"), + Message(MessageType::say, generalUserlistMessage), + }); + EXPECT_EQ(expectedMessages, messages); +} + +TEST_P(DatabaseInterfaceTest, + discardPluginUserMetadataShouldNotDiscardKnownBashTags) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + handle_->GetDatabase().DiscardPluginUserMetadata(blankEsm); + + auto tags = handle_->GetDatabase().GetKnownBashTags(); + + std::vector expectedTags({ + "Actors.ACBS", + "C.Climate", + "RaceRelations", + "C.Lighting", + }); + EXPECT_EQ(expectedTags, tags); +} + +TEST_P( + DatabaseInterfaceTest, + discardAllUserMetadataShouldDiscardAllUserMetadataAndNoMasterlistMetadata) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(GenerateUserlist()); + ASSERT_NO_THROW( + handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + + handle_->GetDatabase().DiscardAllUserMetadata(); + + EXPECT_FALSE(handle_->GetDatabase().GetPluginUserMetadata(blankEsm)); + EXPECT_FALSE(handle_->GetDatabase().GetPluginUserMetadata(blankDifferentEsp)); + + auto metadata = handle_->GetDatabase().GetPluginMetadata(blankEsm).value(); + + std::vector expectedLoadAfter({ + File(masterFile), + }); + EXPECT_EQ(expectedLoadAfter, metadata.GetLoadAfterFiles()); + + auto messages = handle_->GetDatabase().GetGeneralMessages(); + + std::vector expectedMessages({ + Message(MessageType::say, + generalMasterlistMessage, + "file(\"" + missingEsp + "\")"), + }); + EXPECT_EQ(expectedMessages, messages); + + auto tags = handle_->GetDatabase().GetKnownBashTags(); + + std::vector expectedTags({ + "Actors.ACBS", + "C.Climate", + }); + EXPECT_EQ(expectedTags, tags); +} + +TEST_P(DatabaseInterfaceTest, + writeMinimalListShouldReturnOkAndWriteToFileIfArgumentsGivenAreValid) { + EXPECT_NO_THROW( + handle_->GetDatabase().WriteMinimalList(minimalOutputPath_, false)); + EXPECT_TRUE(std::filesystem::exists(minimalOutputPath_)); +} + +TEST_P( + DatabaseInterfaceTest, + writeMinimalListShouldThrowIfTheFileAlreadyExistsAndTheOverwriteArgumentIsFalse) { + ASSERT_NO_THROW( + handle_->GetDatabase().WriteMinimalList(minimalOutputPath_, false)); + ASSERT_TRUE(std::filesystem::exists(minimalOutputPath_)); + + EXPECT_THROW( + handle_->GetDatabase().WriteMinimalList(minimalOutputPath_, false), + FileAccessError); +} + +TEST_P( + DatabaseInterfaceTest, + writeMinimalListShouldReturnOkAndWriteToFileIfTheArgumentsAreValidAndTheOverwriteArgumentIsTrue) { + EXPECT_NO_THROW( + handle_->GetDatabase().WriteMinimalList(minimalOutputPath_, true)); + EXPECT_TRUE(std::filesystem::exists(minimalOutputPath_)); +} + +TEST_P( + DatabaseInterfaceTest, + writeMinimalListShouldReturnOkIfTheFileAlreadyExistsAndTheOverwriteArgumentIsTrue) { + ASSERT_NO_THROW( + handle_->GetDatabase().WriteMinimalList(minimalOutputPath_, false)); + ASSERT_TRUE(std::filesystem::exists(minimalOutputPath_)); + + EXPECT_NO_THROW( + handle_->GetDatabase().WriteMinimalList(minimalOutputPath_, true)); +} + +TEST_P(DatabaseInterfaceTest, + writeMinimalListShouldThrowIfPathGivenExistsAndIsReadOnly) { + ASSERT_NO_THROW( + handle_->GetDatabase().WriteMinimalList(minimalOutputPath_, false)); + ASSERT_TRUE(std::filesystem::exists(minimalOutputPath_)); + + std::filesystem::permissions(minimalOutputPath_, + std::filesystem::perms::owner_read, + std::filesystem::perm_options::replace); + + EXPECT_THROW( + handle_->GetDatabase().WriteMinimalList(minimalOutputPath_, true), + FileAccessError); +} + +TEST_P(DatabaseInterfaceTest, + writeMinimalListShouldWriteOnlyBashTagsAndDirtyInfo) { + using std::endl; + + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); + + EXPECT_NO_THROW( + handle_->GetDatabase().WriteMinimalList(minimalOutputPath_, true)); + + const auto content = GetFileContent(minimalOutputPath_); + + // Plugin entries are unordered. + std::stringstream expectedContent; + if (content.find(blankDifferentEsm) < content.find(blankEsm)) { + expectedContent << "plugins:" << endl + << " - name: '" << blankDifferentEsm << "'" << endl + << " dirty:" << endl + << " - crc: 0x7D22F9DF" << endl + << " util: 'TES4Edit'" << endl + << " udr: 4" << endl + << " - name: '" << blankEsm << "'" << endl + << " tag:" << endl + << " - Actors.ACBS" << endl + << " - Actors.AIData" << endl + << " - -C.Water"; + } else { + expectedContent << "plugins:" << endl + << " - name: '" << blankEsm << "'" << endl + << " tag:" << endl + << " - Actors.ACBS" << endl + << " - Actors.AIData" << endl + << " - -C.Water" << endl + << " - name: '" << blankDifferentEsm << "'" << endl + << " dirty:" << endl + << " - crc: 0x7D22F9DF" << endl + << " util: 'TES4Edit'" << endl + << " udr: 4"; + } + + EXPECT_EQ(expectedContent.str(), content); +} +} +} + +#endif diff --git a/cxx/src/tests/api/interface/game_interface_test.h b/cxx/src/tests/api/interface/game_interface_test.h new file mode 100644 index 00000000..4545d3dd --- /dev/null +++ b/cxx/src/tests/api/interface/game_interface_test.h @@ -0,0 +1,383 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERFACE_GAME_INTERFACE_TEST +#define LOOT_TESTS_API_INTERFACE_GAME_INTERFACE_TEST + +#include "loot/api.h" +#include "tests/api/interface/api_game_operations_test.h" + +namespace loot { +namespace test { +class GameInterfaceTest : public ApiGameOperationsTest { +protected: + GameInterfaceTest() : + emptyFile("EmptyFile.esm"), nonAsciiEsm(u8"non\u00C1scii.esm") { + // Make sure the plugin with a non-ASCII filename exists. + std::filesystem::copy_file(dataPath / blankEsm, + dataPath / std::filesystem::u8path(nonAsciiEsm)); + + if (GetParam() == GameType::starfield) { + pluginsToLoad = { + masterFile, + blankEsm, + blankFullEsm, + blankMasterDependentEsm, + blankEsp, + blankMasterDependentEsp, + }; + } else { + pluginsToLoad = { + // These are all ASCII filenames. + masterFile, + blankEsm, + blankDifferentEsm, + blankMasterDependentEsm, + blankDifferentMasterDependentEsm, + blankEsp, + blankDifferentEsp, + blankMasterDependentEsp, + blankDifferentMasterDependentEsp, + blankPluginDependentEsp, + blankDifferentPluginDependentEsp, + }; + } + } + + const std::string emptyFile; + const std::string nonAsciiEsm; + std::vector pluginsToLoad; +}; + +// Pass an empty first argument, as it's a prefix for the test instantation, +// but we only have the one so no prefix is necessary. +INSTANTIATE_TEST_SUITE_P(, + GameInterfaceTest, + ::testing::Values(GameType::tes4, + GameType::tes5, + GameType::fo3, + GameType::fonv, + GameType::fo4, + GameType::tes5se, + GameType::fo4vr, + GameType::tes5vr, + GameType::tes3, + GameType::starfield, + GameType::openmw)); + +TEST_P(GameInterfaceTest, setAdditionalDataPathsShouldDoThat) { + const auto paths = std::vector{ + localPath, localPath.parent_path() / "other"}; + + handle_->SetAdditionalDataPaths(paths); + + EXPECT_EQ(paths, handle_->GetAdditionalDataPaths()); +} + +TEST_P(GameInterfaceTest, isValidPluginShouldReturnTrueForAValidPlugin) { + EXPECT_TRUE(handle_->IsValidPlugin(blankEsm)); +} + +TEST_P(GameInterfaceTest, + isValidPluginShouldReturnTrueForAValidNonAsciiPlugin) { + EXPECT_TRUE(handle_->IsValidPlugin(std::filesystem::u8path(nonAsciiEsm))); +} + +TEST_P(GameInterfaceTest, isValidPluginShouldReturnFalseForANonPluginFile) { + EXPECT_FALSE(handle_->IsValidPlugin(nonPluginFile)); +} + +TEST_P(GameInterfaceTest, isValidPluginShouldReturnFalseForAnEmptyFile) { + // Write out an empty file. + touch(dataPath / emptyFile); + ASSERT_TRUE(std::filesystem::exists(dataPath / emptyFile)); + + EXPECT_FALSE(handle_->IsValidPlugin(emptyFile)); +} + +TEST_P( + GameInterfaceTest, + loadPluginsWithHeadersOnlyTrueShouldLoadTheHeadersOfAllGivenPlugins) { + handle_->LoadPlugins(pluginsToLoad, true); + if (GetParam() == GameType::starfield) { + EXPECT_EQ(6, handle_->GetLoadedPlugins().size()); + } else { + EXPECT_EQ(11, handle_->GetLoadedPlugins().size()); + } + + // Check that one plugin's header has been read. + ASSERT_NO_THROW(handle_->GetPlugin(masterFile)); + auto plugin = handle_->GetPlugin(masterFile); + EXPECT_EQ("5.0", plugin->GetVersion().value()); + + // Check that only the header has been read. + EXPECT_FALSE(plugin->GetCRC()); +} + +TEST_P(GameInterfaceTest, loadPluginsShouldTrimDotGhostFileExtensions) { + if (GetParam() == GameType::openmw) { + // Ghosting is not supported for OpenMW. + EXPECT_THROW( + handle_->LoadPlugins({blankMasterDependentEsm + ".ghost"}, true), + std::invalid_argument); + return; + } else { + handle_->LoadPlugins({blankMasterDependentEsm + ".ghost"}, true); + } + + EXPECT_EQ(1, handle_->GetLoadedPlugins().size()); + + ASSERT_NO_THROW(handle_->GetPlugin(blankMasterDependentEsm)); + const auto plugin = handle_->GetPlugin(blankMasterDependentEsm); + ASSERT_NE(nullptr, plugin); + EXPECT_EQ(blankMasterDependentEsm, plugin->GetName()); +} + +TEST_P(GameInterfaceTest, + loadPluginsWithHeadersOnlyFalseShouldFullyLoadAllInstalledPlugins) { + handle_->LoadPlugins(pluginsToLoad, false); + if (GetParam() == GameType::starfield) { + EXPECT_EQ(6, handle_->GetLoadedPlugins().size()); + } else { + EXPECT_EQ(11, handle_->GetLoadedPlugins().size()); + } + + // Check that one plugin's header has been read. + ASSERT_NO_THROW(handle_->GetPlugin(masterFile)); + auto plugin = handle_->GetPlugin(masterFile); + EXPECT_EQ("5.0", plugin->GetVersion().value()); + + // Check that not only the header has been read. + EXPECT_EQ(blankEsmCrc, plugin->GetCRC().value()); +} + +TEST_P(GameInterfaceTest, loadPluginsWithANonAsciiPluginShouldLoadIt) { + handle_->LoadPlugins({std::filesystem::u8path(nonAsciiEsm)}, false); + EXPECT_EQ(1, handle_->GetLoadedPlugins().size()); + + // Check that one plugin's header has been read. + auto plugin = handle_->GetPlugin(nonAsciiEsm); + EXPECT_EQ("5.0", plugin->GetVersion().value()); + + // Check that not only the header has been read. + EXPECT_EQ(blankEsmCrc, plugin->GetCRC().value()); +} + +TEST_P(GameInterfaceTest, clearLoadedPluginsShouldClearThePluginsCache) { + handle_->LoadPlugins({std::filesystem::u8path(blankEsm)}, true); + const auto pointer = handle_->GetPlugin(blankEsm); + ASSERT_NE(nullptr, pointer); + + handle_->ClearLoadedPlugins(); + + EXPECT_EQ(nullptr, handle_->GetPlugin(blankEsm)); +} + +TEST_P(GameInterfaceTest, getPluginThatIsNotCachedShouldReturnANullPointer) { + EXPECT_FALSE(handle_->GetPlugin(blankEsm)); +} + +TEST_P(GameInterfaceTest, + gettingPluginsShouldReturnAnEmptySetIfNoneHaveBeenLoaded) { + EXPECT_TRUE(handle_->GetLoadedPlugins().empty()); +} + +TEST_P(GameInterfaceTest, sortPluginsShouldSucceedIfPassedValidArguments) { + std::vector expectedOrder; + if (GetParam() == GameType::starfield) { + expectedOrder = { + masterFile, + blankEsm, + blankFullEsm, + blankMasterDependentEsm, + blankEsp, + blankMasterDependentEsp, + }; + } else { + expectedOrder = { + masterFile, + blankEsm, + blankMasterDependentEsm, + blankDifferentEsm, + blankDifferentMasterDependentEsm, + blankMasterDependentEsp, + blankDifferentMasterDependentEsp, + blankEsp, + blankPluginDependentEsp, + blankDifferentEsp, + blankDifferentPluginDependentEsp, + }; + } + + if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { + expectedOrder.insert(expectedOrder.begin() + 5, blankEsl); + } + + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); + + if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { + pluginsToLoad.push_back(blankEsl); + } + + handle_->LoadCurrentLoadOrderState(); + handle_->LoadPlugins(pluginsToLoad, false); + + std::vector pluginsToSort; + for (const auto& plugin : pluginsToLoad) { + pluginsToSort.push_back(plugin.filename().u8string()); + } + + std::vector actualOrder = handle_->SortPlugins(pluginsToSort); + + EXPECT_EQ(expectedOrder, actualOrder); +} + +TEST_P(GameInterfaceTest, + isPluginActiveShouldReturnFalseIfTheGivenPluginIsNotActive) { + handle_->LoadCurrentLoadOrderState(); + + EXPECT_TRUE(handle_->IsPluginActive(blankEsm)); +} + +TEST_P(GameInterfaceTest, + isPluginActiveShouldReturnTrueIfTheGivenPluginIsActive) { + handle_->LoadCurrentLoadOrderState(); + EXPECT_FALSE(handle_->IsPluginActive(blankEsp)); +} + +TEST_P(GameInterfaceTest, getLoadOrderShouldReturnTheCurrentLoadOrder) { + // Remove the non-ASCII duplicate plugin. + std::filesystem::remove(dataPath / std::filesystem::u8path(nonAsciiEsm)); + + // Set no additional data paths to avoid picking up non-test plugins on PCs + // which have Starfield or Fallout 4 installed. Don't clear the additional + // data paths for OpenMW because they come from test config. + if (GetParam() != GameType::openmw) { + handle_->SetAdditionalDataPaths({}); + } + + handle_->LoadCurrentLoadOrderState(); + + if (GetParam() == GameType::openmw) { + ASSERT_EQ(std::vector({ + blankDifferentEsm, + blankDifferentMasterDependentEsm, + blankDifferentEsp, + blankDifferentPluginDependentEsp, + blankMasterDependentEsm, + blankMasterDependentEsp, + blankEsp, + blankPluginDependentEsp, + masterFile, + blankEsm, + blankDifferentMasterDependentEsp, + }), + handle_->GetLoadOrder()); + } else { + ASSERT_EQ(getLoadOrder(), handle_->GetLoadOrder()); + } +} + +TEST_P(GameInterfaceTest, setLoadOrderShouldSetTheLoadOrder) { + // Remove the non-ASCII duplicate plugin. + std::filesystem::remove(dataPath / std::filesystem::u8path(nonAsciiEsm)); + + // Set no additional data paths to avoid picking up non-test plugins on PCs + // which have Starfield or Fallout 4 installed. Don't clear the additional + // data paths for OpenMW because they come from test config. + if (GetParam() != GameType::openmw) { + handle_->SetAdditionalDataPaths({}); + } + + handle_->LoadCurrentLoadOrderState(); + + const auto gameSupportsEsl = + GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::tes5se || GetParam() == GameType::tes5vr || + GetParam() == GameType::starfield; + + std::vector loadOrder; + if (GetParam() == GameType::starfield) { + loadOrder = { + masterFile, + blankEsm, + blankMasterDependentEsm, + blankDifferentEsm, + blankDifferentEsp, + blankEsp, + blankMasterDependentEsp, + }; + } else if (GetParam() == GameType::openmw) { + loadOrder = { + blankDifferentMasterDependentEsm, + blankDifferentPluginDependentEsp, + blankDifferentEsm, + blankDifferentEsp, + blankMasterDependentEsm, + blankMasterDependentEsp, + blankPluginDependentEsp, + blankEsp, + masterFile, + blankDifferentMasterDependentEsp, + blankEsm, + }; + } else { + loadOrder = { + masterFile, + blankEsm, + blankMasterDependentEsm, + blankDifferentEsm, + blankDifferentMasterDependentEsm, + blankDifferentEsp, + blankDifferentPluginDependentEsp, + blankEsp, + blankMasterDependentEsp, + blankDifferentMasterDependentEsp, + blankPluginDependentEsp, + }; + + if (gameSupportsEsl) { + loadOrder.insert(loadOrder.begin() + 5, blankEsl); + } + } + + EXPECT_NO_THROW(handle_->SetLoadOrder(loadOrder)); + + EXPECT_EQ(loadOrder, handle_->GetLoadOrder()); + + // It's not possible to persist the load order of inactive plugins for + // OpenMW. + if (GetParam() != GameType::openmw) { + if (gameSupportsEsl) { + loadOrder.erase(std::begin(loadOrder)); + } + + EXPECT_EQ(loadOrder, getLoadOrder()); + } +} +} +} + +#endif diff --git a/cxx/src/tests/api/interface/is_compatible_test.h b/cxx/src/tests/api/interface/is_compatible_test.h new file mode 100644 index 00000000..30a48411 --- /dev/null +++ b/cxx/src/tests/api/interface/is_compatible_test.h @@ -0,0 +1,61 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERFACE_IS_COMPATIBLE_TEST +#define LOOT_TESTS_API_INTERFACE_IS_COMPATIBLE_TEST + +#include + +#include "loot/api.h" + +namespace loot { +namespace test { +TEST(IsCompatible, + shouldReturnTrueWithEqualMajorAndMinorVersionsAndUnequalPatchVersion) { + EXPECT_TRUE(IsCompatible( + LIBLOOT_VERSION_MAJOR, LIBLOOT_VERSION_MINOR, LIBLOOT_VERSION_PATCH)); +} + +TEST(IsCompatible, + shouldReturnFalseWithEqualMajorVersionAndUnequalMinorAndPatchVersions) { + EXPECT_FALSE(IsCompatible(LIBLOOT_VERSION_MAJOR, + LIBLOOT_VERSION_MINOR + 1, + LIBLOOT_VERSION_PATCH + 1)); +} + +TEST(GetLiblootRevision, shouldReturnANonEmptyString) { + EXPECT_FALSE(GetLiblootRevision().empty()); +} + +TEST(GetVersion, shouldConcatenateMajorMinorAndPatchVersionNumbersWithPeriods) { + auto expectedVersion = std::to_string(LIBLOOT_VERSION_MAJOR) + "." + + std::to_string(LIBLOOT_VERSION_MINOR) + "." + + std::to_string(LIBLOOT_VERSION_PATCH); + + EXPECT_EQ(expectedVersion, GetLiblootVersion()); +} +} +} + +#endif diff --git a/cxx/src/tests/api/interface/main.cpp b/cxx/src/tests/api/interface/main.cpp new file mode 100644 index 00000000..405173f4 --- /dev/null +++ b/cxx/src/tests/api/interface/main.cpp @@ -0,0 +1,126 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2014-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#include + +#include "loot/api.h" +#include "tests/api/interface/create_game_handle_test.h" +#include "tests/api/interface/database_interface_test.h" +#include "tests/api/interface/game_interface_test.h" +#include "tests/api/interface/is_compatible_test.h" + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} + +namespace loot { +namespace test { +void testLoggingCallback(LogLevel, const char *) { + // Do nothing. +} + +struct TestLogger { + void callback(LogLevel, const char *message) { + loggedMessages += std::string(message); + } + + std::string loggedMessages; +}; + +TEST(SetLoggingCallback, shouldAcceptAFreeFunction) { + SetLoggingCallback(testLoggingCallback); + + try { + CreateGameHandle(GameType::tes4, "dummy"); + FAIL(); + } catch (...) { + SetLoggingCallback([](LogLevel, const char *) {}); + } +} + +TEST(SetLoggingCallback, shouldAcceptAMemberFunction) { + TestLogger testLogger; + auto boundCallback = std::bind(&TestLogger::callback, + &testLogger, + std::placeholders::_1, + std::placeholders::_2); + SetLoggingCallback(boundCallback); + + try { + CreateGameHandle(GameType::tes4, "dummy"); + FAIL(); + } catch (...) { + EXPECT_EQ( + "Attempting to create a game handle for game type \"The Elder Scrolls " + "IV: Oblivion\" with game path \"dummy\" and game local path \"\"", + testLogger.loggedMessages); + + SetLoggingCallback([](LogLevel, const char *) {}); + } +} + +TEST(SetLoggingCallback, shouldAcceptALambdaFunction) { + std::string loggedMessages; + auto callback = [&](LogLevel, const char *string) { + loggedMessages += std::string(string); + }; + SetLoggingCallback(callback); + + try { + CreateGameHandle(GameType::tes4, "dummy"); + FAIL(); + } catch (...) { + EXPECT_EQ( + "Attempting to create a game handle for game type \"The Elder Scrolls " + "IV: Oblivion\" with game path \"dummy\" and game local path \"\"", + loggedMessages); + + SetLoggingCallback([](LogLevel, const char *) {}); + } +} + +TEST(SetLoggingCallback, + shouldNotBreakLoggingIfPassedLambdaFunctionGoesOutOfScope) { + std::string loggedMessages; + { + SetLoggingCallback([&](LogLevel, const char *string) { + loggedMessages += std::string(string); + }); + } + + try { + CreateGameHandle(GameType::tes4, "dummy"); + FAIL(); + } catch (...) { + EXPECT_EQ( + "Attempting to create a game handle for game type \"The Elder Scrolls " + "IV: Oblivion\" with game path \"dummy\" and game local path \"\"", + loggedMessages); + + SetLoggingCallback([](LogLevel, const char *) {}); + } +} +} +} diff --git a/cxx/src/tests/common_game_test_fixture.h b/cxx/src/tests/common_game_test_fixture.h new file mode 100644 index 00000000..8db9c037 --- /dev/null +++ b/cxx/src/tests/common_game_test_fixture.h @@ -0,0 +1,507 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_COMMON_GAME_TEST_FIXTURE +#define LOOT_TESTS_COMMON_GAME_TEST_FIXTURE + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "loot/enum/game_type.h" +#include "tests/test_helpers.h" + +namespace loot { +namespace test { +static const std::array ALL_GAME_TYPES = { + GameType::tes3, + GameType::tes4, + GameType::tes5, + GameType::tes5se, + GameType::tes5vr, + GameType::fo3, + GameType::fonv, + GameType::fo4, + GameType::fo4vr, + GameType::starfield, + GameType::openmw, +}; + +class CommonGameTestFixture : public ::testing::Test { +protected: + CommonGameTestFixture(GameType gameType) : + gameType_(gameType), + rootTestPath(getRootTestPath()), + french("fr"), + german("de"), + missingPath(rootTestPath / "missing"), + gamePath(rootTestPath / "games" / "game"), + dataPath(gamePath / getPluginsFolder()), + localPath(rootTestPath / "local" / "game"), + metadataFilesPath(rootTestPath / "metadata"), + masterFile(getMasterFile()), + missingEsp("Blank.missing.esp"), + nonPluginFile("NotAPlugin.esm"), + invalidPlugin("Invalid.esm"), + blankEsm("Blank.esm"), + blankFullEsm("Blank.full.esm"), + blankMediumEsm("Blank.medium.esm"), + blankDifferentEsm("Blank - Different.esm"), + blankMasterDependentEsm("Blank - Master Dependent.esm"), + blankDifferentMasterDependentEsm( + "Blank - Different Master Dependent.esm"), + blankEsl("Blank.esl"), + blankEsp("Blank.esp"), + blankDifferentEsp("Blank - Different.esp"), + blankMasterDependentEsp("Blank - Master Dependent.esp"), + blankDifferentMasterDependentEsp( + "Blank - Different Master Dependent.esp"), + blankPluginDependentEsp("Blank - Plugin Dependent.esp"), + blankDifferentPluginDependentEsp( + "Blank - Different Plugin Dependent.esp"), + blankEsmCrc(getBlankEsmCrc()) { + assertInitialState(); + } + + void assertInitialState() { + using std::filesystem::create_directories; + using std::filesystem::exists; + + create_directories(dataPath); + ASSERT_TRUE(exists(dataPath)); + + create_directories(localPath); + ASSERT_TRUE(exists(localPath)); + + create_directories(metadataFilesPath); + ASSERT_TRUE(exists(metadataFilesPath)); + + auto sourcePluginsPath = getSourcePluginsPath(); + + if (gameType_ == GameType::starfield) { + copyPlugin(sourcePluginsPath, blankFullEsm); + copyPlugin(sourcePluginsPath, blankMediumEsm); + + std::filesystem::copy_file(sourcePluginsPath / blankFullEsm, + dataPath / blankEsm); + ASSERT_TRUE(exists(dataPath / blankEsm)); + + std::filesystem::copy_file(sourcePluginsPath / blankFullEsm, + dataPath / blankDifferentEsm); + ASSERT_TRUE(exists(dataPath / blankDifferentEsm)); + + std::filesystem::copy_file( + sourcePluginsPath / "Blank - Override.full.esm", + dataPath / blankMasterDependentEsm); + ASSERT_TRUE(exists(dataPath / blankMasterDependentEsm)); + + std::filesystem::copy_file(sourcePluginsPath / "Blank.esp", + dataPath / blankEsp); + ASSERT_TRUE(exists(dataPath / blankEsp)); + + std::filesystem::copy_file(sourcePluginsPath / blankEsp, + dataPath / blankDifferentEsp); + ASSERT_TRUE(exists(dataPath / blankDifferentEsp)); + + std::filesystem::copy_file(sourcePluginsPath / "Blank - Override.esp", + dataPath / blankMasterDependentEsp); + ASSERT_TRUE(exists(dataPath / blankMasterDependentEsp)); + } else { + copyPlugin(sourcePluginsPath, blankEsm); + copyPlugin(sourcePluginsPath, blankDifferentEsm); + copyPlugin(sourcePluginsPath, blankMasterDependentEsm); + copyPlugin(sourcePluginsPath, blankDifferentMasterDependentEsm); + copyPlugin(sourcePluginsPath, blankEsp); + copyPlugin(sourcePluginsPath, blankDifferentEsp); + copyPlugin(sourcePluginsPath, blankMasterDependentEsp); + copyPlugin(sourcePluginsPath, blankDifferentMasterDependentEsp); + copyPlugin(sourcePluginsPath, blankPluginDependentEsp); + copyPlugin(sourcePluginsPath, blankDifferentPluginDependentEsp); + } + + if (supportsLightPlugins(gameType_)) { + if (gameType_ == GameType::starfield) { + std::filesystem::copy_file(sourcePluginsPath / "Blank.small.esm", + dataPath / blankEsl); + ASSERT_TRUE(exists(dataPath / blankEsl)); + } else { + copyPlugin(sourcePluginsPath, blankEsl); + } + } + + // Make sure the game master file exists. + ASSERT_NO_THROW( + std::filesystem::copy_file(dataPath / blankEsm, dataPath / masterFile)); + ASSERT_TRUE(exists(dataPath / masterFile)); + + // Set initial load order and active plugins. + setLoadOrder(getInitialLoadOrder()); + + // Ghost a plugin, except for OpenMW. + if (gameType_ != GameType::openmw) { + ASSERT_NO_THROW(std::filesystem::rename( + dataPath / blankMasterDependentEsm, + dataPath / (blankMasterDependentEsm + ".ghost"))); + ASSERT_FALSE(exists(dataPath / blankMasterDependentEsm)); + ASSERT_TRUE(exists(dataPath / (blankMasterDependentEsm + ".ghost"))); + } else { + touch(gamePath / "openmw.cfg"); + } + + // Write out an non-empty, non-plugin file. + std::ofstream out(dataPath / nonPluginFile); + out << "This isn't a valid plugin file."; + out.close(); + ASSERT_TRUE(exists(dataPath / nonPluginFile)); + + ASSERT_FALSE(exists(missingPath)); + ASSERT_FALSE(exists(dataPath / missingEsp)); + } + + void copyPlugin(const std::filesystem::path& sourceParentPath, + const std::string& filename) { + std::filesystem::copy_file(sourceParentPath / filename, + dataPath / filename); + ASSERT_TRUE(std::filesystem::exists(dataPath / filename)); + } + + void TearDown() override { + // Grant write permissions to everything in rootTestPath + // in case the test made anything read only. + for (const auto& path : + std::filesystem::recursive_directory_iterator(rootTestPath)) { + std::filesystem::permissions(path, std::filesystem::perms::all); + } + std::filesystem::remove_all(rootTestPath); + } + + std::vector readFileLines(const std::filesystem::path& file) { + std::ifstream in(file); + + std::vector lines; + while (in) { + std::string line; + std::getline(in, line); + + if (!line.empty()) { + lines.push_back(line); + } + } + + return lines; + } + + std::vector getLoadOrder() { + std::vector actual; + if (isLoadOrderTimestampBased(gameType_)) { + std::map loadOrder; + for (std::filesystem::directory_iterator it(dataPath); + it != std::filesystem::directory_iterator(); + ++it) { + if (std::filesystem::is_regular_file(it->status())) { + std::string filename = it->path().filename().u8string(); + if (filename == nonPluginFile) + continue; + if (boost::ends_with(filename, ".ghost")) + filename = it->path().stem().u8string(); + if (boost::ends_with(filename, ".esp") || + boost::ends_with(filename, ".esm")) + loadOrder.emplace(std::filesystem::last_write_time(it->path()), + filename); + } + } + for (const auto& plugin : loadOrder) actual.push_back(plugin.second); + } else if (gameType_ == GameType::tes5) { + std::ifstream in(localPath / "loadorder.txt"); + while (in) { + std::string line; + std::getline(in, line); + + if (!line.empty()) + actual.push_back(line); + } + } else if (gameType_ == GameType::openmw) { + throw std::runtime_error( + "OpenMW's load order derivation is too complicated to replicate " + "accurately just for a test."); + } else { + actual = readFileLines(localPath / "Plugins.txt"); + for (auto& line : actual) { + if (line[0] == '*') + line = line.substr(1); + } + } + + return actual; + } + + std::vector> getInitialLoadOrder() const { + std::vector> loadOrder; + + if (gameType_ == GameType::starfield) { + loadOrder = { + {masterFile, true}, + {blankEsm, true}, + {blankDifferentEsm, false}, + {blankFullEsm, false}, + {blankMasterDependentEsm, false}, + {blankMediumEsm, false}, + {blankEsl, false}, + {blankEsp, false}, + {blankDifferentEsp, false}, + {blankMasterDependentEsp, false}, + }; + } else { + loadOrder = { + {masterFile, true}, + {blankEsm, true}, + {blankDifferentEsm, false}, + {blankMasterDependentEsm, false}, + {blankDifferentMasterDependentEsm, false}, + {blankEsp, false}, + {blankDifferentEsp, false}, + {blankMasterDependentEsp, false}, + {blankDifferentMasterDependentEsp, true}, + {blankPluginDependentEsp, false}, + {blankDifferentPluginDependentEsp, false}, + }; + + if (supportsLightPlugins(gameType_)) { + loadOrder.insert(loadOrder.begin() + 5, + std::make_pair(blankEsl, false)); + } + } + + return loadOrder; + } + + std::filesystem::path getSourcePluginsPath() const { + return loot::test::getSourcePluginsPath(gameType_); + } + + void touch(const std::filesystem::path& path) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream out(path); + out.close(); + } + + std::vector ReadFile(const std::filesystem::path& path) { + std::vector bytes; + std::ifstream in(path, std::ios::binary); + + std::copy(std::istreambuf_iterator(in), + std::istreambuf_iterator(), + std::back_inserter(bytes)); + + return bytes; + } + + void WriteFile(const std::filesystem::path& path, + const std::vector& content) { + std::ofstream out(path, std::ios::binary); + + out.write(content.data(), content.size()); + } + + std::vector GetInstalledPlugins() { + if (gameType_ == GameType::starfield) { + return { + masterFile, + blankEsm, + blankDifferentEsm, + blankFullEsm, + blankMasterDependentEsm, + blankMediumEsm, + blankEsl, + blankEsp, + blankDifferentEsp, + blankMasterDependentEsp, + }; + } else { + return { + masterFile, + blankEsm, + blankDifferentEsm, + blankMasterDependentEsm, + blankDifferentMasterDependentEsm, + blankEsp, + blankDifferentEsp, + blankMasterDependentEsp, + blankDifferentMasterDependentEsp, + blankPluginDependentEsp, + blankDifferentPluginDependentEsp, + }; + } + } + + void SetBlueprintFlag(const std::filesystem::path& path) { + auto bytes = ReadFile(path); + bytes[9] = 0x8; + WriteFile(path, bytes); + } + +private: + GameType gameType_; + const std::filesystem::path rootTestPath; + +protected: + const std::string french; + const std::string german; + + const std::filesystem::path missingPath; + const std::filesystem::path gamePath; + const std::filesystem::path dataPath; + const std::filesystem::path localPath; + const std::filesystem::path metadataFilesPath; + + const std::string masterFile; + const std::string missingEsp; + const std::string nonPluginFile; + const std::string invalidPlugin; + const std::string blankEsm; + const std::string blankFullEsm; + const std::string blankMediumEsm; + const std::string blankDifferentEsm; + const std::string blankMasterDependentEsm; + const std::string blankDifferentMasterDependentEsm; + const std::string blankEsl; + const std::string blankEsp; + const std::string blankDifferentEsp; + const std::string blankMasterDependentEsp; + const std::string blankDifferentMasterDependentEsp; + const std::string blankPluginDependentEsp; + const std::string blankDifferentPluginDependentEsp; + + const uint32_t blankEsmCrc; + +private: + std::string getMasterFile() const { + if (gameType_ == GameType::tes3 || gameType_ == GameType::openmw) + return "Morrowind.esm"; + else if (gameType_ == GameType::tes4) + return "Oblivion.esm"; + else if (gameType_ == GameType::tes5 || gameType_ == GameType::tes5se || + gameType_ == GameType::tes5vr) + return "Skyrim.esm"; + else if (gameType_ == GameType::fo3) + return "Fallout3.esm"; + else if (gameType_ == GameType::fonv) + return "FalloutNV.esm"; + else if (gameType_ == GameType::fo4 || gameType_ == GameType::fo4vr) + return "Fallout4.esm"; + else if (gameType_ == GameType::starfield) + return "Starfield.esm"; + else + throw std::logic_error("Unrecognised game type"); + } + + std::string getPluginsFolder() const { + if (gameType_ == GameType::openmw) { + return "resources/vfs"; + } else if (gameType_ == GameType::tes3) { + return "Data Files"; + } else { + return "Data"; + } + } + + uint32_t getBlankEsmCrc() const { + switch (gameType_) { + case GameType::tes3: + case GameType::openmw: + return 0x790DC6FB; + case GameType::tes4: + return 0x374E2A6F; + case GameType::starfield: + return 0xDE586309; + default: + return 0x6A1273DC; + } + } + + void setLoadOrder( + const std::vector>& loadOrder) const { + if (gameType_ == GameType::tes3) { + std::ofstream out(gamePath / "Morrowind.ini"); + for (const auto& plugin : loadOrder) { + if (plugin.second) { + out << "GameFile0=" << plugin.first << std::endl; + } + } + } else if (gameType_ == GameType::openmw) { + std::ofstream out(localPath / "openmw.cfg"); + + for (const auto& plugin : loadOrder) { + if (plugin.second) { + out << "content=" << plugin.first << std::endl; + } + } + } else { + std::ofstream out(localPath / "Plugins.txt"); + for (const auto& plugin : loadOrder) { + if (supportsLightPlugins(gameType_)) { + if (plugin.second) + out << '*'; + } else if (!plugin.second) + continue; + + out << plugin.first << std::endl; + } + } + + if (isLoadOrderTimestampBased(gameType_)) { + std::filesystem::file_time_type modificationTime = + std::filesystem::file_time_type::clock::now(); + for (const auto& plugin : loadOrder) { + if (std::filesystem::exists( + dataPath / std::filesystem::path(plugin.first + ".ghost"))) { + std::filesystem::last_write_time( + dataPath / std::filesystem::path(plugin.first + ".ghost"), + modificationTime); + } else { + std::filesystem::last_write_time(dataPath / plugin.first, + modificationTime); + } + modificationTime += std::chrono::seconds(60); + } + } else if (gameType_ == GameType::tes5) { + std::ofstream out(localPath / "loadorder.txt"); + for (const auto& plugin : loadOrder) out << plugin.first << std::endl; + } + } + + static bool isLoadOrderTimestampBased(GameType gameType) { + return gameType == GameType::tes3 || gameType == GameType::tes4 || + gameType == GameType::fo3 || gameType == GameType::fonv; + } +}; +} +} +#endif diff --git a/cxx/src/tests/printers.h b/cxx/src/tests/printers.h new file mode 100644 index 00000000..c55222e6 --- /dev/null +++ b/cxx/src/tests/printers.h @@ -0,0 +1,102 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2013-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_PRINTERS +#define LOOT_TESTS_PRINTERS + +#include + +#include + +#include "api/metadata/message_content.h" +#include "api/plugin.h" +#include "loot/metadata/file.h" +#include "loot/metadata/location.h" +#include "loot/metadata/message.h" +#include "loot/metadata/plugin_cleaning_data.h" +#include "loot/metadata/plugin_metadata.h" +#include "loot/metadata/tag.h" + +namespace loot { +namespace test { +void PrintTo(const File& value, ::std::ostream* os) { + *os << "File(\"" << std::string(value.GetName()) << "\", " + << "\"" << value.GetDisplayName() << "\", " + << "\"" << value.GetCondition() << "\"" + << ")"; +} + +void PrintTo(const Location& value, ::std::ostream* os) { + *os << "Location(\"" << value.GetURL() << "\", " + << "\"" << value.GetName() << "\", " + << ")"; +} + +void PrintTo(const Message& value, ::std::ostream* os) { + std::string type; + if (value.GetType() == MessageType::warn) + type = "warn"; + else if (value.GetType() == MessageType::error) + type = "error"; + else + type = "say"; + + *os << "Message(\"" << type << "\", " + << ::testing::PrintToString(value.GetContent()) << ", " + << "\"" << value.GetCondition() << "\"" + << ")"; +} + +void PrintTo(const MessageContent& value, ::std::ostream* os) { + *os << "MessageContent(\"" << value.GetText() << "\", " + << "\"" << Language(value.GetLanguage()).GetName() << "\"" + << ")"; +} + +void PrintTo(const PluginCleaningData& value, ::std::ostream* os) { + *os << "PluginCleaningData(0x" << std::hex << std::uppercase << value.GetCRC() + << std::nouppercase << std::dec << ", " << value.GetITMCount() << ", " + << value.GetDeletedReferenceCount() << ", " + << value.GetDeletedNavmeshCount() << ", " + << "\"" << value.GetCleaningUtility() << "\"" + << ")"; +} + +void PrintTo(const PluginMetadata& value, ::std::ostream* os) { + *os << "PluginMetadata(\"" << value.GetName() << "\")"; +} + +void PrintTo(const Tag& value, ::std::ostream* os) { + *os << "Tag(\"" << value.GetName() << "\", " << value.IsAddition() << ", " + << "\"" << value.GetCondition() << "\"" + << ")"; +} + +void PrintTo(const Plugin& value, ::std::ostream* os) { + *os << "Plugin(\"" << value.GetName() << "\")"; +} +} +} + +#endif diff --git a/cxx/src/tests/test_helpers.h b/cxx/src/tests/test_helpers.h new file mode 100644 index 00000000..a40d23c0 --- /dev/null +++ b/cxx/src/tests/test_helpers.h @@ -0,0 +1,84 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_TEST_HELPERS +#define LOOT_TESTS_TEST_HELPERS + +#include +#include +#include + +#include "loot/enum/game_type.h" + +namespace loot::test { +bool supportsLightPlugins(GameType gameType) { + return gameType == GameType::tes5se || gameType == GameType::tes5vr || + gameType == GameType::fo4 || gameType == GameType::fo4vr || + gameType == GameType::starfield; +} + +std::filesystem::path getSourcePluginsPath(GameType gameType) { + using std::filesystem::absolute; + if (gameType == GameType::tes3 || gameType == GameType::openmw) { + return absolute("./testing-plugins/Morrowind/Data Files"); + } else if (gameType == GameType::tes4) { + return absolute("./testing-plugins/Oblivion/Data"); + } else if (gameType == GameType::starfield) { + return absolute("./testing-plugins/Starfield/Data"); + } else if (supportsLightPlugins(gameType)) { + return absolute("./testing-plugins/SkyrimSE/Data"); + } else { + return absolute("./testing-plugins/Skyrim/Data"); + } +} + +std::filesystem::path getSourceArchivesPath(GameType gameType) { + if (gameType == GameType::fo4 || gameType == GameType::fo4vr || + gameType == GameType::starfield) { + return "./testing-plugins/Fallout 4/Data"; + } else { + return getSourcePluginsPath(gameType); + } +} + +std::filesystem::path getRootTestPath() { + std::random_device randomDevice; + std::default_random_engine prng(randomDevice()); + std::uniform_int_distribution dist(0x61, + 0x7A); // values of a-z in ASCII/UTF-8. + + // The non-ASCII character is there to ensure test coverage of non-ASCII path + // handling. + std::string directoryName = u8"libloot-t\u00E9st-"; + + for (int i = 0; i < 16; i += 1) { + directoryName.push_back(static_cast(dist(prng))); + } + + return std::filesystem::absolute(std::filesystem::temp_directory_path() / + std::filesystem::u8path(directoryName)); +} +} + +#endif From 17157f430eb356214c507f68e49e607a1d0ae2ab Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 12 Mar 2025 17:29:52 +0000 Subject: [PATCH 031/206] Fix a couple of includes in libloot API headers I'll also make these changes to C++ libloot. --- cxx/include/loot/exception/condition_syntax_error.h | 2 +- cxx/include/loot/game_interface.h | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cxx/include/loot/exception/condition_syntax_error.h b/cxx/include/loot/exception/condition_syntax_error.h index 105e0a86..c3f0b18d 100644 --- a/cxx/include/loot/exception/condition_syntax_error.h +++ b/cxx/include/loot/exception/condition_syntax_error.h @@ -25,7 +25,7 @@ #ifndef LOOT_EXCEPTION_CONDITION_SYNTAX_ERROR #define LOOT_EXCEPTION_CONDITION_SYNTAX_ERROR -#include +#include namespace loot { /** diff --git a/cxx/include/loot/game_interface.h b/cxx/include/loot/game_interface.h index a0115027..9e41c83a 100644 --- a/cxx/include/loot/game_interface.h +++ b/cxx/include/loot/game_interface.h @@ -25,6 +25,7 @@ #define LOOT_GAME_INTERFACE #include "loot/database_interface.h" +#include "loot/enum/game_type.h" #include "loot/plugin_interface.h" namespace loot { From d1757df904556ece50548634201b6b64de559eed Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 12 Mar 2025 20:29:41 +0000 Subject: [PATCH 032/206] Implement ABI-compatible C++ wrapper The interface tests have been tweaked slightly to expect different log messages, but that's the only intentional behavioural change. --- Cargo.lock | 1 + cxx/.clang-format | 15 + cxx/CMakeLists.txt | 325 ++++++++++++++++-- cxx/Cargo.toml | 1 + cxx/README.md | 75 ++-- cxx/cbindgen.toml | 1 - cxx/cmake/Config.cmake.in | 5 + cxx/cmake/tests.cmake | 200 +++++++++++ cxx/src/api/api.cpp | 88 +++++ cxx/src/api/convert.cpp | 247 +++++++++++++ cxx/src/api/convert.h | 88 +++++ cxx/src/api/database.cpp | 100 ++++++ cxx/src/api/database.h | 56 +++ cxx/src/api/error_categories.cpp | 80 +++++ cxx/src/api/game.cpp | 223 ++++++++++++ cxx/src/api/game.h | 67 ++++ cxx/src/api/metadata/conditional_metadata.cpp | 34 ++ cxx/src/api/metadata/file.cpp | 84 +++++ cxx/src/api/metadata/filename.cpp | 59 ++++ cxx/src/api/metadata/group.cpp | 72 ++++ cxx/src/api/metadata/location.cpp | 64 ++++ cxx/src/api/metadata/message.cpp | 93 +++++ cxx/src/api/metadata/message_content.cpp | 117 +++++++ cxx/src/api/metadata/plugin_cleaning_data.cpp | 130 +++++++ cxx/src/api/metadata/plugin_metadata.cpp | 210 +++++++++++ cxx/src/api/metadata/tag.cpp | 66 ++++ cxx/src/api/plugin.cpp | 91 +++++ cxx/src/api/plugin.h | 46 +++ cxx/src/api/resource.rc | 30 ++ .../api/sorting/cyclic_interaction_error.cpp | 85 +++++ cxx/src/api/sorting/undefined_group_error.cpp | 32 ++ cxx/src/api/vertex.cpp | 37 ++ cxx/src/lib.rs | 70 +++- cxx/src/plugin.rs | 4 + cxx/src/tests/api/interface/main.cpp | 6 +- cxx/src/tests/api/internals/main.cpp | 61 ++++ cxx/src/tests/common_game_test_fixture.h | 17 +- 37 files changed, 2908 insertions(+), 72 deletions(-) create mode 100644 cxx/.clang-format delete mode 100644 cxx/cbindgen.toml create mode 100644 cxx/cmake/Config.cmake.in create mode 100644 cxx/cmake/tests.cmake create mode 100644 cxx/src/api/api.cpp create mode 100644 cxx/src/api/convert.cpp create mode 100644 cxx/src/api/convert.h create mode 100644 cxx/src/api/database.cpp create mode 100644 cxx/src/api/database.h create mode 100644 cxx/src/api/error_categories.cpp create mode 100644 cxx/src/api/game.cpp create mode 100644 cxx/src/api/game.h create mode 100644 cxx/src/api/metadata/conditional_metadata.cpp create mode 100644 cxx/src/api/metadata/file.cpp create mode 100644 cxx/src/api/metadata/filename.cpp create mode 100644 cxx/src/api/metadata/group.cpp create mode 100644 cxx/src/api/metadata/location.cpp create mode 100644 cxx/src/api/metadata/message.cpp create mode 100644 cxx/src/api/metadata/message_content.cpp create mode 100644 cxx/src/api/metadata/plugin_cleaning_data.cpp create mode 100644 cxx/src/api/metadata/plugin_metadata.cpp create mode 100644 cxx/src/api/metadata/tag.cpp create mode 100644 cxx/src/api/plugin.cpp create mode 100644 cxx/src/api/plugin.h create mode 100644 cxx/src/api/resource.rc create mode 100644 cxx/src/api/sorting/cyclic_interaction_error.cpp create mode 100644 cxx/src/api/sorting/undefined_group_error.cpp create mode 100644 cxx/src/api/vertex.cpp create mode 100644 cxx/src/tests/api/internals/main.cpp diff --git a/Cargo.lock b/Cargo.lock index 04a5fa64..3545f348 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -589,6 +589,7 @@ dependencies = [ "cxx-build", "delegate", "libloot", + "unicase", ] [[package]] diff --git a/cxx/.clang-format b/cxx/.clang-format new file mode 100644 index 00000000..755e693b --- /dev/null +++ b/cxx/.clang-format @@ -0,0 +1,15 @@ +--- +Language: Cpp +BasedOnStyle: Google + +AccessModifierOffset: -2 +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortIfStatementsOnASingleLine: false +BinPackArguments: false +BinPackParameters: false +BreakConstructorInitializers: AfterColon +ConstructorInitializerAllOnOneLineOrOnePerLine: true +FixNamespaceComments: false +SpaceAfterTemplateKeyword: false +Standard: Cpp11 +... diff --git a/cxx/CMakeLists.txt b/cxx/CMakeLists.txt index 1eab6eb3..78e4576c 100644 --- a/cxx/CMakeLists.txt +++ b/cxx/CMakeLists.txt @@ -1,41 +1,300 @@ -cmake_minimum_required(VERSION 2.8) -project(ffi_tests CXX) - -include_directories("${CMAKE_SOURCE_DIR}/../target/cxxbridge") - -set(CMAKE_CXX_STANDARD 17) - +cmake_minimum_required(VERSION 3.24) +if(POLICY CMP0135) + cmake_policy(SET CMP0135 NEW) +endif() +if(POLICY CMP0167) + cmake_policy(SET CMP0167 NEW) +endif() +project(libloot) +include(ExternalProject) include(FetchContent) -include(GoogleTest) +include(CMakePackageConfigHelpers) +include(GNUInstallDirs) -set(BUILD_GMOCK OFF) -set(gtest_force_shared_crt ON) -set(INSTALL_GTEST OFF) -# Google Test doesn't have a scoped equivalent to BUILD_SHARED_LIBS, so set the -# global value and then set it back to the original value once Google Test has -# been configured. -set(BUILD_SHARED_LIBS_INITIAL ${BUILD_SHARED_LIBS}) -set(BUILD_SHARED_LIBS OFF) -FetchContent_Declare( - GTest - URL "https://github.com/google/googletest/archive/refs/tags/v1.15.2.tar.gz" - URL_HASH "SHA256=7b42b4d6ed48810c5362c265a17faebe90dc2373c885e5216439d37927f02926" - FIND_PACKAGE_ARGS) +option(BUILD_SHARED_LIBS "Build a shared library" ON) +option(RUN_CLANG_TIDY "Whether or not to run clang-tidy during build. Has no effect when using CMake's MSVC generator." OFF) +option(LIBLOOT_BUILD_TESTS "Whether or not to build libloot's tests." ON) +option(LIBLOOT_INSTALL_DOCS "Whether or not to install libloot's docs (which need to be built separately)." ON) -FetchContent_MakeAvailable(GTest) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) -if (CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") - set (SYSTEM_LIBS pthread dl) -endif () +############################## +# External Projects +############################## -if (MSVC) - set (SYSTEM_LIBS ntdll ws2_32 Userenv bcrypt) -endif () +add_library(libloot-cxx STATIC IMPORTED) +# TODO: Define the Rust libloot library path here for Linux builds. +if(MSVC) + set_target_properties(libloot-cxx PROPERTIES IMPORTED_LOCATION "${CMAKE_SOURCE_DIR}/../target/release/libloot_cxx.lib") +endif() +target_include_directories(libloot-cxx INTERFACE "${CMAKE_SOURCE_DIR}/../target/cxxbridge") -set (FFI_LIBRARY "${CMAKE_SOURCE_DIR}/../target/debug/libloot_cxx.lib") -add_executable(ffi_cpp_tests "${CMAKE_SOURCE_DIR}/tests/ffi.cpp") -target_link_libraries(ffi_cpp_tests ${FFI_LIBRARY} ${SYSTEM_LIBS} GTest::gtest_main) +############################## +# General Settings +############################## -enable_testing() -gtest_discover_tests(ffi_cpp_tests DISCOVERY_TIMEOUT 10) +set(LIBLOOT_SRC_API_CPP_FILES + "${CMAKE_SOURCE_DIR}/src/api/api.cpp" + "${CMAKE_SOURCE_DIR}/src/api/convert.cpp" + "${CMAKE_SOURCE_DIR}/src/api/database.cpp" + "${CMAKE_SOURCE_DIR}/src/api/error_categories.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/conditional_metadata.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/file.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/filename.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/group.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/location.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/message.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/message_content.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/plugin_cleaning_data.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/plugin_metadata.cpp" + "${CMAKE_SOURCE_DIR}/src/api/metadata/tag.cpp" + "${CMAKE_SOURCE_DIR}/src/api/game.cpp" + "${CMAKE_SOURCE_DIR}/src/api/plugin.cpp" + "${CMAKE_SOURCE_DIR}/src/api/sorting/cyclic_interaction_error.cpp" + "${CMAKE_SOURCE_DIR}/src/api/sorting/undefined_group_error.cpp" + "${CMAKE_SOURCE_DIR}/src/api/vertex.cpp" + ) + +set(LIBLOOT_INCLUDE_H_FILES + "${CMAKE_SOURCE_DIR}/include/loot/api.h" + "${CMAKE_SOURCE_DIR}/include/loot/api_decorator.h" + "${CMAKE_SOURCE_DIR}/include/loot/database_interface.h" + "${CMAKE_SOURCE_DIR}/include/loot/exception/error_categories.h" + "${CMAKE_SOURCE_DIR}/include/loot/exception/condition_syntax_error.h" + "${CMAKE_SOURCE_DIR}/include/loot/exception/cyclic_interaction_error.h" + "${CMAKE_SOURCE_DIR}/include/loot/exception/file_access_error.h" + "${CMAKE_SOURCE_DIR}/include/loot/exception/undefined_group_error.h" + "${CMAKE_SOURCE_DIR}/include/loot/enum/edge_type.h" + "${CMAKE_SOURCE_DIR}/include/loot/enum/game_type.h" + "${CMAKE_SOURCE_DIR}/include/loot/enum/log_level.h" + "${CMAKE_SOURCE_DIR}/include/loot/enum/message_type.h" + "${CMAKE_SOURCE_DIR}/include/loot/game_interface.h" + "${CMAKE_SOURCE_DIR}/include/loot/loot_version.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/conditional_metadata.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/file.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/filename.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/group.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/location.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/message.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/message_content.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/plugin_cleaning_data.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/plugin_metadata.h" + "${CMAKE_SOURCE_DIR}/include/loot/metadata/tag.h" + "${CMAKE_SOURCE_DIR}/include/loot/plugin_interface.h" + "${CMAKE_SOURCE_DIR}/include/loot/vertex.h") + +set(LIBLOOT_SRC_API_H_FILES + "${CMAKE_SOURCE_DIR}/src/api/convert.h" + "${CMAKE_SOURCE_DIR}/src/api/database.h" + "${CMAKE_SOURCE_DIR}/src/api/game.h" + "${CMAKE_SOURCE_DIR}/src/api/plugin.h" + ) + +source_group(TREE "${CMAKE_SOURCE_DIR}/src/api" + PREFIX "Source Files" + FILES ${LIBLOOT_SRC_API_CPP_FILES}) + +source_group(TREE "${CMAKE_SOURCE_DIR}/include" + PREFIX "Header Files" + FILES ${LIBLOOT_INCLUDE_H_FILES}) + +source_group(TREE "${CMAKE_SOURCE_DIR}/src/api" + PREFIX "Header Files" + FILES ${LIBLOOT_SRC_API_H_FILES}) + +set(LIBLOOT_ALL_SOURCES + ${LIBLOOT_SRC_API_CPP_FILES} + ${LIBLOOT_INCLUDE_H_FILES} + ${LIBLOOT_SRC_API_H_FILES} + "${CMAKE_SOURCE_DIR}/src/api/resource.rc") + +############################## +# Define Targets +############################## + +# Build API. +add_library(loot ${LIBLOOT_ALL_SOURCES}) +target_link_libraries(loot PRIVATE libloot-cxx) + +############################## +# Set Target-Specific Flags +############################## + +set(LIBLOOT_INCLUDE_DIRS + "${CMAKE_SOURCE_DIR}/src" + "${CMAKE_SOURCE_DIR}/include") + +set(LIBLOOT_COMMON_SYSTEM_INCLUDE_DIRS + ${LIBLOADORDER_INCLUDE_DIRS} + ${ESPLUGIN_INCLUDE_DIRS} + ${LCI_INCLUDE_DIRS}) + +target_include_directories(loot PUBLIC + "$" + "$") +target_include_directories(loot PRIVATE ${LIBLOOT_INCLUDE_DIRS}) +target_include_directories(loot SYSTEM PRIVATE + ${LIBLOOT_COMMON_SYSTEM_INCLUDE_DIRS}) + +if(CMAKE_SYSTEM_NAME STREQUAL "Windows") + target_compile_definitions(loot PRIVATE + UNICODE _UNICODE LOOT_EXPORT YAML_CPP_STATIC_DEFINE) + + set(LOOT_LIBS ntdll ws2_32 bcrypt) + + if(NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") + set(LOOT_LIBS ${LOOT_LIBS} tbb_static) + endif() + + target_link_libraries(loot PRIVATE ${LOOT_LIBS}) +else() + set(LOOT_LIBS ICU::data ICU::uc pthread TBB::tbb) + + target_link_libraries(loot PUBLIC ${LOOT_LIBS}) +endif() + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + target_compile_options(loot PRIVATE "-Wall" "-Wextra") +endif() + +if(MSVC) + # Turn off permissive mode to be more standards-compliant and avoid compiler errors. + target_compile_options(loot PRIVATE "/permissive-" "/W4") +endif() + +############################## +# Configure clang-tidy +############################## + +if(RUN_CLANG_TIDY) + set(CLANG_TIDY_COMMON_CHECKS + "cppcoreguidelines-avoid-c-arrays" + "cppcoreguidelines-c-copy-assignment-signature" + "cppcoreguidelines-explicit-virtual-functions" + "cppcoreguidelines-init-variables" + "cppcoreguidelines-interfaces-global-init" + "cppcoreguidelines-macro-usage" + "cppcoreguidelines-narrowing-conventions" + "cppcoreguidelines-no-malloc" + "cppcoreguidelines-pro-bounds-array-to-pointer-decay" + "cppcoreguidelines-pro-bounds-constant-array-index" + "cppcoreguidelines-pro-bounds-pointer-arithmetic" + "cppcoreguidelines-pro-type-const-cast" + "cppcoreguidelines-pro-type-cstyle-cast" + "cppcoreguidelines-pro-type-member-init" + "cppcoreguidelines-pro-type-reinterpret-cast" + "cppcoreguidelines-pro-type-static-cast-downcast" + "cppcoreguidelines-pro-type-union-access" + "cppcoreguidelines-pro-type-vararg" + "cppcoreguidelines-pro-type-slicing") + + set(CLANG_TIDY_LIB_CHECKS + ${CLANG_TIDY_COMMON_CHECKS} + "cppcoreguidelines-avoid-goto" + "cppcoreguidelines-avoid-magic-numbers" + "cppcoreguidelines-non-private-member-variables-in-classes" + "cppcoreguidelines-special-member-functions") + + list(JOIN CLANG_TIDY_LIB_CHECKS "," CLANG_TIDY_LIB_CHECKS_JOINED) + + set(CLANG_TIDY_LIB + clang-tidy "-header-filter=.*" "-checks=${CLANG_TIDY_LIB_CHECKS_JOINED}") + + set_target_properties(loot + PROPERTIES + CXX_CLANG_TIDY "${CLANG_TIDY_LIB}") +endif() + +############################## +# Tests +############################## + +if(LIBLOOT_BUILD_TESTS) + include("cmake/tests.cmake") +endif() + +######################################## +# Install +######################################## + +set(LIBLOOT_VERSION "0.25.4") + +set_property(TARGET loot PROPERTY VERSION ${LIBLOOT_VERSION}) +set_property(TARGET loot PROPERTY SOVERSION 0) +set_property(TARGET loot PROPERTY INTERFACE_libloot_MAJOR_VERSION 0) +set_property(TARGET loot APPEND PROPERTY COMPATIBLE_INTERFACE_STRING libloot_MAJOR_VERSION) + +configure_package_config_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/Config.cmake.in + "${CMAKE_CURRENT_BINARY_DIR}/liblootConfig.cmake" + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/libloot +) + +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/liblootConfigVersion.cmake" + VERSION "${LIBLOOT_VERSION}" + COMPATIBILITY AnyNewerVersion +) + +install(TARGETS loot + EXPORT liblootTargets + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +if(MSVC) + install(FILES $ + DESTINATION ${CMAKE_INSTALL_LIBDIR} + OPTIONAL + CONFIGURATIONS RelWithDebInfo) +endif() + +install(DIRECTORY "${CMAKE_SOURCE_DIR}/include/" + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +# TODO: Copy across the docs into this repository? +# if(LIBLOOT_INSTALL_DOCS) +# install(DIRECTORY "${CMAKE_BINARY_DIR}/docs/html/" +# DESTINATION ${CMAKE_INSTALL_DOCDIR}) +# endif() + +install(EXPORT liblootTargets + FILE liblootTargets.cmake + NAMESPACE libloot:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/libloot) + +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/liblootConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/liblootConfigVersion.cmake" + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/libloot) + +######################################## +# CPack +######################################## + +if(NOT DEFINED CPACK_PACKAGE_VERSION) + if(GIT_FOUND) + execute_process(COMMAND ${GIT_EXECUTABLE} describe --tags --long --always --abbrev=7 + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + OUTPUT_VARIABLE GIT_DESCRIBE_STRING + OUTPUT_STRIP_TRAILING_WHITESPACE) + else() + set(GIT_DESCRIBE_STRING "unknown-version") + endif() + + set(CPACK_PACKAGE_VERSION ${GIT_DESCRIBE_STRING}) +endif() + +if(CMAKE_SYSTEM_NAME STREQUAL "Windows") + set(CPACK_GENERATOR "7Z") +else() + set(CPACK_GENERATOR "TXZ") +endif() + +set(CPACK_PACKAGE_DIRECTORY "${CMAKE_BINARY_DIR}/package") + +include(CPack) diff --git a/cxx/Cargo.toml b/cxx/Cargo.toml index b6c80298..82e5480a 100644 --- a/cxx/Cargo.toml +++ b/cxx/Cargo.toml @@ -8,6 +8,7 @@ license = "GPL-3.0" cxx = { version = "1.0", features = ["c++17"] } delegate = "0.13.2" libloot = { path = ".." } +unicase = "2.8.1" [build-dependencies] cxx-build = "1.0" diff --git a/cxx/README.md b/cxx/README.md index 5f1bf278..5dadc604 100644 --- a/cxx/README.md +++ b/cxx/README.md @@ -1,44 +1,69 @@ # libloot-rs C++ wrapper -This is an **imcomplete** and **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface. +This is an **imcomplete** and **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface that's ABI-compatible with libloot v0.25.3. ## Current status -- [x] Expose all API types, aside from `LogLevel` and error types -- [x] Expose all getters and setters on metadata types -- [x] Expose all other methods on `PluginMetadata` -- [x] Expose creator functions (`new_*`) for all metadata types -- [x] Expose `Group::DEFAULT_NAME` (using an accessor function) -- [x] Expose `MessageContent::DEFAULT_LANGUAGE` (using an accessor function) -- [x] Expose `select_message_content()` -- [x] Expose `is_compatible()` -- [x] Expose `libloot_revision()` -- [x] Expose `libloot_version()` -- [x] Expose `LIBLOOT_VERSION_*` constants (as unmangled extern C statics) -- [ ] Expose `LogLevel` -- [ ] Expose `set_logging_callback()` -- [ ] Write tests to cover API usage patterns -- [ ] Write a C++ layer that provides the same API as the C++ implementation of libloot +- [x] `EdgeType` +- [x] `GameType` +- [x] `LogLevel` +- [x] `MessageType` +- [ ] `ConditionSyntaxError` +- [ ] `CyclicInteractionError` +- [ ] `FileAccessError` +- [ ] `UndefinedGroupError` +- [ ] `std::system_error` with esplugin `std::error_category` +- [ ] `std::system_error` with libloadorder `std::error_category` +- [ ] `std::system_error` with loot-condition-interpreter `std::error_category` +- [x] `ConditionalMetadata` +- [x] `File` +- [x] `Filename` +- [x] `Group` +- [x] `Location` +- [x] `MessageContent` +- [x] `SelectMessageContent` +- [x] `Message` +- [x] `PluginCleaningData` +- [x] `PluginMetadata` +- [x] `Tag` +- [x] `SetLoggingCallback` +- [x] `IsCompatible` +- [x] `CreateGameHandle` +- [x] `DatabaseInterface` +- [x] `GameInterface` +- [x] `LIBLOOT_VERSION_MAJOR` +- [x] `LIBLOOT_VERSION_MINOR` +- [x] `LIBLOOT_VERSION_PATCH` +- [x] `GetLiblootVersion` +- [x] `GetLiblootRevision` +- [x] `PluginInterface` +- [x] `Vertex` + +The error types are defined but currently unused, causing behavioural differences when errors occur - see the implementation notes below for details. ## Building -The wrapper is currently built as a static library using Cargo and cbindgen. +The wrapper is currently built in two layers: + +- a static library built using Cargo, which provides a C++ interface +- a shared library built using CMake, which wraps that C++ interface to provide another that is ABI-compatible with C++ libloot. + +To build the wrapper: ``` cargo build --release -cbindgen -o include/libloot.h -``` - -cbindgen is needed to create a header that contains the C FFI exports. It needs to be v0.28.0 or higher to support the Rust 2024 edition's unsafe attributes. The header needs to be included into C++ code separately from the CXX-generated header. While CXX does support including headers, it produces the wrong include path in the C++ it generates due to this crate being a member of a workspace, so the C++ side expects the header in a subdirectory that doesn't exist. - -There are also a small number of C++ tests that can be built and run using CMake: - -``` cmake -B build . cmake --build build --config RelWithDebInfo +``` + +This also builds a copy of the public API tests from C++ libloot v0.25.3, which can be run using: + +``` ctest --test-dir build --output-on-failure -V ``` +Some of the tests currently fail: they are all expecting the exception classes that aren't yet used. + To package the build: ``` diff --git a/cxx/cbindgen.toml b/cxx/cbindgen.toml deleted file mode 100644 index 87df20f0..00000000 --- a/cxx/cbindgen.toml +++ /dev/null @@ -1 +0,0 @@ -namespace = "loot" diff --git a/cxx/cmake/Config.cmake.in b/cxx/cmake/Config.cmake.in new file mode 100644 index 00000000..27c9016d --- /dev/null +++ b/cxx/cmake/Config.cmake.in @@ -0,0 +1,5 @@ +@PACKAGE_INIT@ + +include("${CMAKE_CURRENT_LIST_DIR}/liblootTargets.cmake") + +check_required_components(libloot) diff --git a/cxx/cmake/tests.cmake b/cxx/cmake/tests.cmake new file mode 100644 index 00000000..efdc73fd --- /dev/null +++ b/cxx/cmake/tests.cmake @@ -0,0 +1,200 @@ +############################## +# Dependencies +############################## + +include(FetchContent) +include(GoogleTest) + +set(BUILD_GMOCK OFF) +set(gtest_force_shared_crt ON) +set(INSTALL_GTEST OFF) +# Google Test doesn't have a scoped equivalent to BUILD_SHARED_LIBS, so set the +# global value and then set it back to the original value once Google Test has +# been configured. +set(BUILD_SHARED_LIBS_INITIAL ${BUILD_SHARED_LIBS}) +set(BUILD_SHARED_LIBS OFF) +FetchContent_Declare( + GTest + URL "https://github.com/google/googletest/archive/refs/tags/v1.15.2.tar.gz" + URL_HASH "SHA256=7b42b4d6ed48810c5362c265a17faebe90dc2373c885e5216439d37927f02926" + FIND_PACKAGE_ARGS) + +FetchContent_Declare( + testing-plugins + URL "https://github.com/Ortham/testing-plugins/archive/1.6.2.tar.gz" + URL_HASH "SHA256=f6e5b55e2669993ab650ba470424b725d1fab71ace979134a77de3373bd55620") + +FetchContent_MakeAvailable(GTest testing-plugins) + +set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_INITIAL}) + + +############################## +# General Settings +############################## + +set(LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES + "${CMAKE_SOURCE_DIR}/src/tests/api/internals/main.cpp") + +# set(LIBLOOT_SRC_TESTS_INTERNALS_H_FILES ) + +set(LIBLOOT_SRC_TESTS_INTERFACE_CPP_FILES + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/main.cpp") + +set(LIBLOOT_SRC_TESTS_INTERFACE_H_FILES + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/api_game_operations_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/create_game_handle_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/database_interface_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/game_interface_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/is_compatible_test.h") + +source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" + PREFIX "Source Files" + FILES ${LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES}) + +# source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" +# PREFIX "Header Files" +# FILES ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES}) + +source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/interface" + PREFIX "Source Files" + FILES ${LIBLOOT_SRC_TESTS_INTERFACE_CPP_FILES}) + +source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/interface" + PREFIX "Header Files" + FILES ${LIBLOOT_SRC_TESTS_INTERFACE_H_FILES}) + + +set(LIBLOOT_INTERNALS_TESTS_ALL_SOURCES + ${LIBLOOT_ALL_SOURCES} + ${LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES} + # ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES} + "${CMAKE_SOURCE_DIR}/src/tests/common_game_test_fixture.h" + "${CMAKE_SOURCE_DIR}/src/tests/test_helpers.h" + "${CMAKE_SOURCE_DIR}/src/tests/printers.h") + +set(LIBLOOT_INTERFACE_TESTS_ALL_SOURCES + ${LIBLOOT_SRC_TESTS_INTERFACE_CPP_FILES} + ${LIBLOOT_SRC_TESTS_INTERFACE_H_FILES} + "${CMAKE_SOURCE_DIR}/src/tests/common_game_test_fixture.h" + "${CMAKE_SOURCE_DIR}/src/tests/test_helpers.h" + "${CMAKE_SOURCE_DIR}/src/tests/printers.h") + + +############################## +# Define Targets +############################## + +# Build tests. +add_executable(libloot_internals_tests ${LIBLOOT_INTERNALS_TESTS_ALL_SOURCES}) +target_link_libraries(libloot_internals_tests PRIVATE + libloot-cxx + GTest::gtest_main) + +# Build API tests. +add_executable(libloot_tests ${LIBLOOT_INTERFACE_TESTS_ALL_SOURCES}) +add_dependencies(libloot_tests loot) +target_link_libraries(libloot_tests PRIVATE loot GTest::gtest_main) + +enable_testing() +gtest_discover_tests(libloot_tests DISCOVERY_TIMEOUT 10) + +############################## +# Set Target-Specific Flags +############################## + +target_include_directories(libloot_internals_tests PRIVATE +${LIBLOOT_INCLUDE_DIRS}) + +target_include_directories(libloot_internals_tests SYSTEM PRIVATE +${LIBLOOT_COMMON_SYSTEM_INCLUDE_DIRS}) + +target_include_directories(libloot_tests PRIVATE ${LIBLOOT_INCLUDE_DIRS}) +target_include_directories(libloot_tests SYSTEM PRIVATE + ${LIBLOOT_COMMON_SYSTEM_INCLUDE_DIRS}) + +if(CMAKE_SYSTEM_NAME STREQUAL "Windows") +target_compile_definitions(libloot_internals_tests PRIVATE + UNICODE _UNICODE LOOT_STATIC) + target_compile_definitions(libloot_tests PRIVATE UNICODE _UNICODE) + + if(NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") + target_compile_definitions(libloot_tests PRIVATE LOOT_STATIC) + endif() +endif() + +target_link_libraries(libloot_internals_tests PRIVATE ${LOOT_LIBS}) +target_link_libraries(libloot_tests PRIVATE ${LOOT_LIBS}) + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + target_compile_options(libloot_internals_tests PRIVATE "-Wall" "-Wextra") + target_compile_options(libloot_tests PRIVATE "-Wall" "-Wextra") +endif() + +if(MSVC) + # Turn off permissive mode to be more standards-compliant and avoid compiler errors. + target_compile_options(libloot_tests PRIVATE "/permissive-" "/W4") + + # Set /bigobj to allow building Debug and RelWithDebInfo tests + target_compile_options(libloot_internals_tests PRIVATE + "/permissive-" + "/W4" + "$<$,$>:/bigobj>") +endif() + + +############################## +# Configure clang-tidy +############################## + +if(RUN_CLANG_TIDY) + set(CLANG_TIDY_COMMON_CHECKS + "cppcoreguidelines-avoid-c-arrays" + "cppcoreguidelines-c-copy-assignment-signature" + "cppcoreguidelines-explicit-virtual-functions" + "cppcoreguidelines-init-variables" + "cppcoreguidelines-interfaces-global-init" + "cppcoreguidelines-macro-usage" + "cppcoreguidelines-narrowing-conventions" + "cppcoreguidelines-no-malloc" + "cppcoreguidelines-pro-bounds-array-to-pointer-decay" + "cppcoreguidelines-pro-bounds-constant-array-index" + "cppcoreguidelines-pro-bounds-pointer-arithmetic" + "cppcoreguidelines-pro-type-const-cast" + "cppcoreguidelines-pro-type-cstyle-cast" + "cppcoreguidelines-pro-type-member-init" + "cppcoreguidelines-pro-type-reinterpret-cast" + "cppcoreguidelines-pro-type-static-cast-downcast" + "cppcoreguidelines-pro-type-union-access" + "cppcoreguidelines-pro-type-vararg" + "cppcoreguidelines-pro-type-slicing") + + # Skip some checks for tests because they're not worth the noise (e.g. GTest + # happens to use goto). + set(CLANG_TIDY_TEST_CHECKS ${CLANG_TIDY_COMMON_CHECKS}) + + list(JOIN CLANG_TIDY_TEST_CHECKS "," CLANG_TIDY_TEST_CHECKS_JOINED) + + set(CLANG_TIDY_TEST + clang-tidy "-header-filter=.*" "-checks=${CLANG_TIDY_TEST_CHECKS_JOINED}") + + set_target_properties(libloot_internals_tests libloot_tests + PROPERTIES + CXX_CLANG_TIDY "${CLANG_TIDY_TEST}") +endif() + + +############################## +# Post-Build Steps +############################## + +# Copy testing plugins +add_custom_command(TARGET libloot_internals_tests POST_BUILD +COMMAND ${CMAKE_COMMAND} -E copy_directory + ${testing-plugins_SOURCE_DIR} + ${CMAKE_CURRENT_BINARY_DIR}/testing-plugins) + +add_custom_command(TARGET libloot_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${testing-plugins_SOURCE_DIR} + ${CMAKE_CURRENT_BINARY_DIR}/testing-plugins) diff --git a/cxx/src/api/api.cpp b/cxx/src/api/api.cpp new file mode 100644 index 00000000..85d2f748 --- /dev/null +++ b/cxx/src/api/api.cpp @@ -0,0 +1,88 @@ + + +#include "loot/api.h" + +#include "api/game.h" +#include "libloot-cxx/src/lib.rs.h" +#include "rust/cxx.h" + +extern "C" { +extern const unsigned int LIBLOOT_VERSION_MAJOR; + +extern const unsigned int LIBLOOT_VERSION_MINOR; + +extern const unsigned int LIBLOOT_VERSION_PATCH; + +extern const uint8_t LIBLOOT_LOG_LEVEL_TRACE; + +extern const uint8_t LIBLOOT_LOG_LEVEL_DEBUG; + +extern const uint8_t LIBLOOT_LOG_LEVEL_INFO; + +extern const uint8_t LIBLOOT_LOG_LEVEL_WARNING; + +extern const uint8_t LIBLOOT_LOG_LEVEL_ERROR; + +extern const uint8_t LIBLOOT_LOG_LEVEL_FATAL; + +void libloot_set_logging_callback(void (*callback)(uint8_t, const char*, void*), + void* context); +} + +namespace { +using loot::LogLevel; + +static std::function STORED_CALLBACK; + +LogLevel convert(uint8_t level) { + if (level == LIBLOOT_LOG_LEVEL_TRACE) { + return LogLevel::trace; + } else if (level == LIBLOOT_LOG_LEVEL_DEBUG) { + return LogLevel::debug; + } else if (level == LIBLOOT_LOG_LEVEL_INFO) { + return LogLevel::info; + } else if (level == LIBLOOT_LOG_LEVEL_WARNING) { + return LogLevel::warning; + } else if (level == LIBLOOT_LOG_LEVEL_ERROR) { + return LogLevel::error; + } else { + return LogLevel::fatal; + } +} + +void logging_callback(uint8_t level, const char* message, void* context) { + auto callbackPtr = + static_cast*>(context); + + (*callbackPtr)(convert(level), message); +} +} + +namespace loot { +LOOT_API void SetLoggingCallback( + std::function callback) { + STORED_CALLBACK = callback; + libloot_set_logging_callback(logging_callback, &STORED_CALLBACK); +} + +LOOT_API bool IsCompatible(const unsigned int versionMajor, + const unsigned int versionMinor, + const unsigned int versionPatch) { + return loot::rust::is_compatible(versionMajor, versionMinor, versionPatch); +} + +LOOT_API std::unique_ptr CreateGameHandle( + const GameType game, + const std::filesystem::path& gamePath, + const std::filesystem::path& gameLocalPath) { + return std::make_unique(game, gamePath, gameLocalPath); +} + +LOOT_API std::string GetLiblootVersion() { + return std::string(loot::rust::libloot_version()); +} + +LOOT_API std::string GetLiblootRevision() { + return std::string(loot::rust::libloot_revision()); +} +} diff --git a/cxx/src/api/convert.cpp b/cxx/src/api/convert.cpp new file mode 100644 index 00000000..a2480ec3 --- /dev/null +++ b/cxx/src/api/convert.cpp @@ -0,0 +1,247 @@ +#include "api/convert.h" + +namespace loot { +// To public types +///////////////////// + +std::string convert(const ::rust::String& string) { + return std::string(string); +} + +loot::Group convert(const loot::rust::Group& group) { + return loot::Group(std::string(group.name()), + convert(group.after_groups()), + std::string(group.description())); +} + +loot::File convert(const loot::rust::File& file) { + return loot::File(std::string(file.filename().as_str()), + std::string(file.display_name()), + std::string(file.condition()), + convert(file.detail())); +} + +loot::MessageType convert(loot::rust::MessageType messageType) { + switch (messageType) { + case loot::rust::MessageType::say: + return loot::MessageType::say; + case loot::rust::MessageType::warn: + return loot::MessageType::warn; + case loot::rust::MessageType::error: + return loot::MessageType::error; + default: + throw std::logic_error("Unsupported MessageType value"); + } +} + +loot::MessageContent convert(const loot::rust::MessageContent& content) { + return loot::MessageContent(std::string(content.text()), + std::string(content.language())); +} + +loot::Message convert(const loot::rust::Message& message) { + return loot::Message(convert(message.message_type()), + convert(message.content()), + std::string(message.condition())); +} + +loot::Tag convert(const loot::rust::Tag& tag) { + return loot::Tag( + std::string(tag.name()), tag.is_addition(), std::string(tag.condition())); +} + +loot::PluginCleaningData convert(const loot::rust::PluginCleaningData& data) { + return loot::PluginCleaningData(data.crc(), + std::string(data.cleaning_utility()), + convert(data.detail()), + data.itm_count(), + data.deleted_reference_count(), + data.deleted_navmesh_count()); +} + +loot::Location convert(const loot::rust::Location& location) { + return loot::Location(std::string(location.url()), + std::string(location.name())); +} + +loot::PluginMetadata convert(const loot::rust::PluginMetadata& metadata) { + auto output = loot::PluginMetadata(std::string(metadata.name())); + + if (!metadata.group().empty()) { + output.SetGroup(std::string(metadata.group())); + } + + output.SetLoadAfterFiles(convert(metadata.load_after_files())); + output.SetRequirements(convert(metadata.requirements())); + output.SetIncompatibilities( + convert(metadata.incompatibilities())); + output.SetMessages(convert(metadata.messages())); + output.SetTags(convert(metadata.tags())); + output.SetDirtyInfo(convert(metadata.dirty_info())); + output.SetCleanInfo(convert(metadata.clean_info())); + output.SetLocations(convert(metadata.locations())); + + return output; +} + +std::optional convert(uint8_t edgeType) { + switch (edgeType) { + case static_cast(loot::rust::EdgeType::hardcoded): + return loot::EdgeType::hardcoded; + case static_cast(loot::rust::EdgeType::masterFlag): + return loot::EdgeType::masterFlag; + case static_cast(loot::rust::EdgeType::master): + return loot::EdgeType::master; + case static_cast(loot::rust::EdgeType::masterlistRequirement): + return loot::EdgeType::masterlistRequirement; + case static_cast(loot::rust::EdgeType::userRequirement): + return loot::EdgeType::userRequirement; + case static_cast(loot::rust::EdgeType::masterlistLoadAfter): + return loot::EdgeType::masterlistLoadAfter; + case static_cast(loot::rust::EdgeType::userLoadAfter): + return loot::EdgeType::userLoadAfter; + case static_cast(loot::rust::EdgeType::masterlistGroup): + return loot::EdgeType::masterlistGroup; + case static_cast(loot::rust::EdgeType::userGroup): + return loot::EdgeType::userGroup; + case static_cast(loot::rust::EdgeType::recordOverlap): + return loot::EdgeType::recordOverlap; + case static_cast(loot::rust::EdgeType::assetOverlap): + return loot::EdgeType::assetOverlap; + case static_cast(loot::rust::EdgeType::tieBreak): + return loot::EdgeType::tieBreak; + case static_cast(loot::rust::EdgeType::blueprintMaster): + return loot::EdgeType::blueprintMaster; + default: + return std::nullopt; + } +} + +loot::Vertex convert(const loot::rust::Vertex& vertex) { + const auto outEdgeType = convert(vertex.out_edge_type()); + if (outEdgeType.has_value()) { + return loot::Vertex(std::string(vertex.name()), outEdgeType.value()); + } else { + return loot::Vertex(std::string(vertex.name())); + } +} + +// From public types +/////////////////////// + +::rust::Box convert(const loot::Group& group) { + auto output = loot::rust::new_group(group.GetName()); + output->set_after_groups(convert(group.GetAfterGroups())); + output->set_description(group.GetDescription()); + + return output; +} + +::rust::Box convert(const loot::File& file) { + auto output = loot::rust::new_file(std::string(file.GetName())); + output->set_display_name(file.GetDisplayName()); + output->set_detail( + ::rust::Slice(convert(file.GetDetail()))); + output->set_condition(file.GetCondition()); + + return output; +} + +loot::rust::MessageType convert(loot::MessageType messageType) { + switch (messageType) { + case loot::MessageType::say: + return loot::rust::MessageType::say; + case loot::MessageType::warn: + return loot::rust::MessageType::warn; + case loot::MessageType::error: + return loot::rust::MessageType::error; + default: + throw std::logic_error("Unsupported MessageType value"); + } +} + +::rust::Box convert( + const loot::MessageContent& content) { + auto output = loot::rust::new_message_content(content.GetText()); + output->set_language(content.GetLanguage()); + + return output; +} + +::rust::Box convert(const loot::Message& message) { + auto output = loot::rust::multilingual_message( + convert(message.GetType()), + ::rust::Slice(convert(message.GetContent()))); + output->set_condition(message.GetCondition()); + + return output; +} + +::rust::Box convert(const loot::Tag& tag) { + const auto suggestion = tag.IsAddition() ? loot::rust::TagSuggestion::Addition + : loot::rust::TagSuggestion::Removal; + auto output = loot::rust::new_tag(tag.GetName(), suggestion); + output->set_condition(tag.GetCondition()); + + return output; +} + +::rust::Box convert( + const loot::PluginCleaningData& data) { + auto output = loot::rust::new_plugin_cleaning_data(data.GetCRC(), + data.GetCleaningUtility()); + output->set_detail( + ::rust::Slice(convert(data.GetDetail()))); + output->set_itm_count(data.GetITMCount()); + output->set_deleted_reference_count(data.GetDeletedReferenceCount()); + output->set_deleted_navmesh_count(data.GetDeletedNavmeshCount()); + + return output; +} + +::rust::Box convert(const loot::Location& location) { + auto output = loot::rust::new_location(location.GetURL()); + output->set_name(location.GetName()); + + return output; +} + +::rust::Box convert( + const loot::PluginMetadata& metadata) { + auto output = loot::rust::new_plugin_metadata(metadata.GetName()); + + if (metadata.GetGroup().has_value()) { + output->set_group(metadata.GetGroup().value()); + } + + output->set_load_after_files( + ::rust::Slice(convert(metadata.GetLoadAfterFiles()))); + output->set_requirements( + ::rust::Slice(convert(metadata.GetRequirements()))); + output->set_incompatibilities(::rust::Slice( + convert(metadata.GetIncompatibilities()))); + output->set_messages( + ::rust::Slice(convert(metadata.GetMessages()))); + output->set_tags( + ::rust::Slice(convert(metadata.GetTags()))); + output->set_dirty_info( + ::rust::Slice(convert(metadata.GetDirtyInfo()))); + output->set_clean_info( + ::rust::Slice(convert(metadata.GetCleanInfo()))); + output->set_locations(::rust::Slice(convert(metadata.GetLocations()))); + + return output; +} + +// Between containers +//////////////////////// + +::rust::Vec<::rust::String> convert(const std::vector& vector) { + ::rust::Vec<::rust::String> strings; + for (const auto& str : vector) { + strings.push_back(str); + } + + return strings; +} +} diff --git a/cxx/src/api/convert.h b/cxx/src/api/convert.h new file mode 100644 index 00000000..7b2c9034 --- /dev/null +++ b/cxx/src/api/convert.h @@ -0,0 +1,88 @@ +#ifndef LOOT_API_CONVERT +#define LOOT_API_CONVERT + +#include "libloot-cxx/src/lib.rs.h" +#include "loot/metadata/group.h" +#include "loot/metadata/plugin_metadata.h" +#include "loot/vertex.h" + +namespace loot { +// To public types +///////////////////// + +std::string convert(const ::rust::String& string); + +loot::Group convert(const loot::rust::Group& group); + +loot::File convert(const loot::rust::File& file); + +loot::MessageType convert(loot::rust::MessageType messageType); + +loot::MessageContent convert(const loot::rust::MessageContent& content); + +loot::Message convert(const loot::rust::Message& message); + +loot::Tag convert(const loot::rust::Tag& tag); + +loot::PluginCleaningData convert(const loot::rust::PluginCleaningData& data); + +loot::Location convert(const loot::rust::Location& location); + +loot::PluginMetadata convert(const loot::rust::PluginMetadata& metadata); + +std::optional convert(uint8_t edgeType); + +loot::Vertex convert(const loot::rust::Vertex& vertex); + +// From public types +/////////////////////// + +::rust::Box convert(const loot::Group& group); + +::rust::Box convert(const loot::File& file); + +loot::rust::MessageType convert(loot::MessageType messageType); + +::rust::Box convert( + const loot::MessageContent& content); + +::rust::Box convert(const loot::Message& message); + +::rust::Box convert(const loot::Tag& tag); + +::rust::Box convert( + const loot::PluginCleaningData& data); + +::rust::Box convert(const loot::Location& location); + +::rust::Box convert( + const loot::PluginMetadata& metadata); + +// Between containers +//////////////////////// + +::rust::Vec<::rust::String> convert(const std::vector& vector); + +template +std::vector convert(const ::rust::Slice& slice) { + std::vector output; + for (const auto& element : slice) { + output.push_back(convert(element)); + } + + return output; +} + +template +std::vector convert(const ::rust::Vec& vec) { + return convert(::rust::Slice(vec)); +} + +template +const std::vector<::rust::Box> convert(const std::vector& vec) { + return convert<::rust::Box, U>(::rust::Slice(vec)); +} + +} + +#endif diff --git a/cxx/src/api/database.cpp b/cxx/src/api/database.cpp new file mode 100644 index 00000000..2150596f --- /dev/null +++ b/cxx/src/api/database.cpp @@ -0,0 +1,100 @@ + +#include "api/database.h" + +#include "api/convert.h" + +namespace loot { +Database::Database(::rust::Box&& database) : + database_(std::move(database)) {} + +void Database::LoadLists(const std::filesystem::path& masterlistPath, + const std::filesystem::path& userlistPath, + const std::filesystem::path& masterlistPreludePath) { + if (!masterlistPath.empty()) { + if (!masterlistPreludePath.empty()) { + database_->load_masterlist_with_prelude(masterlistPath.u8string(), + masterlistPreludePath.u8string()); + } else { + database_->load_masterlist(masterlistPath.u8string()); + } + } + + if (!userlistPath.empty()) { + database_->load_userlist(userlistPath.u8string()); + } +} + +void Database::WriteUserMetadata(const std::filesystem::path& outputFile, + const bool overwrite) const { + database_->write_user_metadata(outputFile.u8string(), overwrite); +} + +std::vector Database::GetKnownBashTags() const { + return convert(database_->known_bash_tags()); +} + +std::vector Database::GetGeneralMessages( + bool evaluateConditions) const { + return convert(database_->general_messages(evaluateConditions)); +} + +std::vector Database::GetGroups(bool includeUserMetadata) const { + return convert(database_->groups(includeUserMetadata)); +} + +std::vector Database::GetUserGroups() const { + return convert(database_->user_groups()); +} + +void Database::SetUserGroups(const std::vector& groups) { + database_->set_user_groups(::rust::Slice(convert(groups))); +} + +std::vector Database::GetGroupsPath( + const std::string& fromGroupName, + const std::string& toGroupName) const { + return convert(database_->groups_path(fromGroupName, toGroupName)); +} + +std::optional Database::GetPluginMetadata( + const std::string& plugin, + bool includeUserMetadata, + bool evaluateConditions) const { + const auto metadata = database_->plugin_metadata( + plugin, includeUserMetadata, evaluateConditions); + if (metadata->is_some()) { + return convert(metadata->as_ref()); + } else { + return std::nullopt; + } +} + +std::optional Database::GetPluginUserMetadata( + const std::string& plugin, + bool evaluateConditions) const { + const auto metadata = + database_->plugin_user_metadata(plugin, evaluateConditions); + if (metadata->is_some()) { + return convert(metadata->as_ref()); + } else { + return std::nullopt; + } +} + +void Database::SetPluginUserMetadata(const PluginMetadata& pluginMetadata) { + database_->set_plugin_user_metadata(convert(pluginMetadata)); +} + +void Database::DiscardPluginUserMetadata(const std::string& plugin) { + database_->discard_plugin_user_metadata(plugin); +} + +void Database::DiscardAllUserMetadata() { + database_->discard_all_user_metadata(); +} + +void Database::WriteMinimalList(const std::filesystem::path& outputFile, + const bool overwrite) const { + database_->write_minimal_list(outputFile.u8string(), overwrite); +} +} diff --git a/cxx/src/api/database.h b/cxx/src/api/database.h new file mode 100644 index 00000000..84c0f2d4 --- /dev/null +++ b/cxx/src/api/database.h @@ -0,0 +1,56 @@ +#ifndef LOOT_API_DATABASE +#define LOOT_API_DATABASE + +#include "libloot-cxx/src/lib.rs.h" +#include "loot/database_interface.h" +#include "rust/cxx.h" + +namespace loot { +class Database final : public DatabaseInterface { +public: + explicit Database(::rust::Box&& database); + + void LoadLists( + const std::filesystem::path& masterlist_path, + const std::filesystem::path& userlist_path = "", + const std::filesystem::path& masterlist_prelude_path = "") override; + + void WriteUserMetadata(const std::filesystem::path& outputFile, + const bool overwrite) const override; + + void WriteMinimalList(const std::filesystem::path& outputFile, + const bool overwrite) const override; + + std::vector GetKnownBashTags() const override; + + std::vector GetGeneralMessages( + bool evaluateConditions = false) const override; + + std::vector GetGroups(bool includeUserMetadata = true) const override; + std::vector GetUserGroups() const override; + void SetUserGroups(const std::vector& groups) override; + std::vector GetGroupsPath( + const std::string& fromGroupName, + const std::string& toGroupName) const override; + + std::optional GetPluginMetadata( + const std::string& plugin, + bool includeUserMetadata = true, + bool evaluateConditions = false) const override; + + std::optional GetPluginUserMetadata( + const std::string& plugin, + bool evaluateConditions = false) const override; + + void SetPluginUserMetadata(const PluginMetadata& pluginMetadata) override; + + void DiscardPluginUserMetadata(const std::string& plugin) override; + + void DiscardAllUserMetadata() override; + +private: + ::rust::Box database_; +}; +} + +#endif diff --git a/cxx/src/api/error_categories.cpp b/cxx/src/api/error_categories.cpp new file mode 100644 index 00000000..283a81cb --- /dev/null +++ b/cxx/src/api/error_categories.cpp @@ -0,0 +1,80 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#include "loot/exception/error_categories.h" + +#include + +namespace loot { +namespace detail { +class esplugin_category : public std::error_category { + const char* name() const noexcept override { return "esplugin"; } + + std::string message(int) const override { return "esplugin error"; } + + bool equivalent(const std::error_code& code, int) const noexcept override { + return code.category().name() == name(); + } +}; + +class libloadorder_category : public std::error_category { + const char* name() const noexcept override { return "libloadorder"; } + + std::string message(int) const override { return "Libloadorder error"; } + + bool equivalent(const std::error_code& code, int) const noexcept override { + return code.category().name() == name(); + } +}; + +class loot_condition_interpreter_category : public std::error_category { + const char* name() const noexcept override { + return "loot condition interpreter"; + } + + std::string message(int) const override { + return "loot condition interpreter error"; + } + + bool equivalent(const std::error_code& code, int) const noexcept override { + return code.category().name() == name(); + } +}; +} + +LOOT_API const std::error_category& esplugin_category() { + static detail::esplugin_category instance; + return instance; +} + +LOOT_API const std::error_category& libloadorder_category() { + static detail::libloadorder_category instance; + return instance; +} + +LOOT_API const std::error_category& loot_condition_interpreter_category() { + static detail::loot_condition_interpreter_category instance; + return instance; +} +} diff --git a/cxx/src/api/game.cpp b/cxx/src/api/game.cpp new file mode 100644 index 00000000..52de97bc --- /dev/null +++ b/cxx/src/api/game.cpp @@ -0,0 +1,223 @@ + +#include "api/game.h" + +#include "api/convert.h" + +namespace { +loot::GameType convert(loot::rust::GameType gameType) { + switch (gameType) { + case loot::rust::GameType::tes3: + return loot::GameType::tes3; + case loot::rust::GameType::tes4: + return loot::GameType::tes4; + case loot::rust::GameType::tes5: + return loot::GameType::tes5; + case loot::rust::GameType::tes5se: + return loot::GameType::tes5se; + case loot::rust::GameType::tes5vr: + return loot::GameType::tes5vr; + case loot::rust::GameType::fo3: + return loot::GameType::fo3; + case loot::rust::GameType::fonv: + return loot::GameType::fonv; + case loot::rust::GameType::fo4: + return loot::GameType::fo4; + case loot::rust::GameType::fo4vr: + return loot::GameType::fo4vr; + case loot::rust::GameType::starfield: + return loot::GameType::starfield; + case loot::rust::GameType::openmw: + return loot::GameType::openmw; + default: + throw std::logic_error("Unsupported GameType value"); + } +} + +loot::rust::GameType convert(loot::GameType gameType) { + switch (gameType) { + case loot::GameType::tes3: + return loot::rust::GameType::tes3; + case loot::GameType::tes4: + return loot::rust::GameType::tes4; + case loot::GameType::tes5: + return loot::rust::GameType::tes5; + case loot::GameType::tes5se: + return loot::rust::GameType::tes5se; + case loot::GameType::tes5vr: + return loot::rust::GameType::tes5vr; + case loot::GameType::fo3: + return loot::rust::GameType::fo3; + case loot::GameType::fonv: + return loot::rust::GameType::fonv; + case loot::GameType::fo4: + return loot::rust::GameType::fo4; + case loot::GameType::fo4vr: + return loot::rust::GameType::fo4vr; + case loot::GameType::starfield: + return loot::rust::GameType::starfield; + case loot::GameType::openmw: + return loot::rust::GameType::openmw; + default: + throw std::logic_error("Unsupported GameType value"); + } +} + +rust::Box constructGame( + const loot::GameType gameType, + const std::filesystem::path& gamePath, + const std::filesystem::path& localDataPath) { + if (localDataPath.empty()) { + return std::move( + loot::rust::new_game(convert(gameType), gamePath.u8string())); + } else { + return std::move(loot::rust::new_game_with_local_path( + convert(gameType), gamePath.u8string(), localDataPath.u8string())); + } +} + +std::vector<::rust::Str> as_str_refs(const std::vector& vector) { + std::vector<::rust::Str> strings; + for (const auto& str : vector) { + strings.push_back(str); + } + + return strings; +} +} + +namespace loot { +Game::Game(const GameType gameType, + const std::filesystem::path& gamePath, + const std::filesystem::path& localDataPath) : + game_(constructGame(gameType, gamePath, localDataPath)), + database_(game_->database()) {} + +GameType Game::GetType() const { return ::convert(game_->game_type()); } + +const DatabaseInterface& Game::GetDatabase() const { return database_; } + +DatabaseInterface& Game::GetDatabase() { return database_; } + +std::vector Game::GetAdditionalDataPaths() const { + std::vector paths; + for (const auto& path_str : game_->additional_data_paths()) { + paths.push_back(std::filesystem::u8path(path_str.begin(), path_str.end())); + } + + return paths; +} + +void Game::SetAdditionalDataPaths( + const std::vector& additionalDataPaths) { + std::vector<::rust::String> path_strings; + std::vector<::rust::Str> path_strs; + for (const auto& path : additionalDataPaths) { + path_strings.push_back(path.u8string()); + path_strs.push_back(path_strings.back()); + } + + game_->set_additional_data_paths(::rust::Slice(path_strs)); +} + +bool Game::IsValidPlugin(const std::filesystem::path& pluginPath) const { + return game_->is_valid_plugin(pluginPath.u8string()); +} + +void Game::LoadPlugins(const std::vector& pluginPaths, + bool loadHeadersOnly) { + std::vector<::rust::String> path_strings; + std::vector<::rust::Str> path_strs; + for (const auto& path : pluginPaths) { + path_strings.push_back(path.u8string()); + path_strs.push_back(path_strings.back()); + } + + if (loadHeadersOnly) { + game_->load_plugin_headers(::rust::Slice(path_strs)); + } else { + game_->load_plugins(::rust::Slice(path_strs)); + } + + for (const auto& path : pluginPaths) { + auto key = Filename(path.filename().u8string()); + auto it = plugins_.find(key); + if (it != plugins_.end()) { + plugins_.erase(it); + } + } +} + +void Game::ClearLoadedPlugins() { + game_->clear_loaded_plugins(); + plugins_.clear(); +} + +const PluginInterface* Game::GetPlugin(const std::string& pluginName) const { + const auto pluginOpt = game_->plugin(pluginName); + if (!pluginOpt->is_some()) { + return nullptr; + } + + auto key = Filename(pluginName); + const auto it = plugins_.find(key); + if (it != plugins_.end()) { + return it->second.get(); + } + + auto plugin = std::make_shared(std::move(pluginOpt->as_ref())); + const auto result = plugins_.emplace(key, plugin); + + return result.first->second.get(); +} + +std::vector Game::GetLoadedPlugins() const { + std::vector plugins; + for (const auto& pluginRef : game_->loaded_plugins()) { + const auto plugin = + std::make_shared(std::move(pluginRef.boxed_clone())); + + const auto result = + plugins_.insert_or_assign(Filename(plugin->GetName()), plugin); + plugins.push_back(result.first->second.get()); + } + + return plugins; +} + +std::vector Game::SortPlugins( + const std::vector& pluginFilenames) { + const auto strs = as_str_refs(pluginFilenames); + + const auto results = + game_->sort_plugins(::rust::Slice(strs)); + + return convert(results); +} + +void Game::LoadCurrentLoadOrderState() { + game_->load_current_load_order_state(); +} + +bool Game::IsLoadOrderAmbiguous() const { + return game_->is_load_order_ambiguous(); +} + +std::filesystem::path Game::GetActivePluginsFilePath() const { + const auto path_string = game_->active_plugins_file_path(); + return std::filesystem::u8path(path_string.begin(), path_string.end()); +} + +bool Game::IsPluginActive(const std::string& pluginName) const { + return game_->is_plugin_active(pluginName); +} + +std::vector Game::GetLoadOrder() const { + return convert(game_->load_order()); +} + +void Game::SetLoadOrder(const std::vector& loadOrder) { + const auto strs = as_str_refs(loadOrder); + + game_->set_load_order(::rust::Slice(strs)); +} +} diff --git a/cxx/src/api/game.h b/cxx/src/api/game.h new file mode 100644 index 00000000..819712ba --- /dev/null +++ b/cxx/src/api/game.h @@ -0,0 +1,67 @@ +#ifndef LOOT_API_GAME +#define LOOT_API_GAME + +#include + +#include "api/database.h" +#include "api/plugin.h" +#include "libloot-cxx/src/lib.rs.h" +#include "loot/game_interface.h" +#include "loot/metadata/filename.h" +#include "rust/cxx.h" + +namespace loot { +class Game final : public GameInterface { +public: + explicit Game(const GameType gameType, + const std::filesystem::path& gamePath, + const std::filesystem::path& gameLocalDataPath = ""); + + // Game Interface Methods // + //////////////////////////// + + GameType GetType() const override; + + std::vector GetAdditionalDataPaths() const; + + void SetAdditionalDataPaths( + const std::vector& additionalDataPaths) override; + + DatabaseInterface& GetDatabase() override; + const DatabaseInterface& GetDatabase() const override; + + bool IsValidPlugin(const std::filesystem::path& pluginPath) const override; + + void LoadPlugins(const std::vector& pluginPaths, + bool loadHeadersOnly) override; + + void ClearLoadedPlugins() override; + + const PluginInterface* GetPlugin( + const std::string& pluginName) const override; + + std::vector GetLoadedPlugins() const override; + + std::vector SortPlugins( + const std::vector& pluginFilenames) override; + + void LoadCurrentLoadOrderState() override; + + bool IsLoadOrderAmbiguous() const override; + + std::filesystem::path GetActivePluginsFilePath() const override; + + bool IsPluginActive(const std::string& pluginName) const override; + + std::vector GetLoadOrder() const override; + + void SetLoadOrder(const std::vector& loadOrder) override; + +private: + ::rust::Box game_; + Database database_; + mutable std::map> plugins_; +}; +} + +#endif diff --git a/cxx/src/api/metadata/conditional_metadata.cpp b/cxx/src/api/metadata/conditional_metadata.cpp new file mode 100644 index 00000000..f778f686 --- /dev/null +++ b/cxx/src/api/metadata/conditional_metadata.cpp @@ -0,0 +1,34 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#include "loot/metadata/conditional_metadata.h" + +namespace loot { +ConditionalMetadata::ConditionalMetadata(const std::string& condition) : + condition_(condition) {} + +bool ConditionalMetadata::IsConditional() const { return !condition_.empty(); } + +std::string ConditionalMetadata::GetCondition() const { return condition_; } +} diff --git a/cxx/src/api/metadata/file.cpp b/cxx/src/api/metadata/file.cpp new file mode 100644 index 00000000..132ec0fa --- /dev/null +++ b/cxx/src/api/metadata/file.cpp @@ -0,0 +1,84 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#include "loot/metadata/file.h" + +namespace loot { +File::File(const std::string& name, + const std::string& display, + const std::string& condition, + const std::vector& detail) : + ConditionalMetadata(condition), + name_(Filename(name)), + display_(display), + detail_(detail) {} + +Filename File::GetName() const { return name_; } + +std::string File::GetDisplayName() const { return display_; } + +std::vector File::GetDetail() const { return detail_; } + +bool operator==(const File& lhs, const File& rhs) { + return lhs.GetDisplayName() == rhs.GetDisplayName() && + lhs.GetCondition() == rhs.GetCondition() && + lhs.GetName() == rhs.GetName() && lhs.GetDetail() == rhs.GetDetail(); +} + +bool operator!=(const File& lhs, const File& rhs) { return !(lhs == rhs); } + +bool operator<(const File& lhs, const File& rhs) { + if (lhs.GetDisplayName() < rhs.GetDisplayName()) { + return true; + } + + if (rhs.GetDisplayName() < lhs.GetDisplayName()) { + return false; + } + + if (lhs.GetCondition() < rhs.GetCondition()) { + return true; + } + + if (rhs.GetCondition() < lhs.GetCondition()) { + return false; + } + + if (lhs.GetName() < rhs.GetName()) { + return true; + } + + if (rhs.GetName() < lhs.GetName()) { + return false; + } + + return lhs.GetDetail() < rhs.GetDetail(); +} + +bool operator>(const File& lhs, const File& rhs) { return rhs < lhs; } + +bool operator<=(const File& lhs, const File& rhs) { return !(lhs > rhs); } + +bool operator>=(const File& lhs, const File& rhs) { return !(lhs < rhs); } +} diff --git a/cxx/src/api/metadata/filename.cpp b/cxx/src/api/metadata/filename.cpp new file mode 100644 index 00000000..34e9178e --- /dev/null +++ b/cxx/src/api/metadata/filename.cpp @@ -0,0 +1,59 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#include "loot/metadata/filename.h" + +#include + +// #include "api/helpers/text.h" +#include "libloot-cxx/src/lib.rs.h" + +namespace loot { +Filename::Filename(const std::string& filename) : + filename_(filename) {} + +Filename::operator std::string() const { return filename_; } + +bool operator==(const Filename& lhs, const Filename& rhs) { + return loot::rust::compare_filenames(std::string(lhs), std::string(rhs)) == 0; +} + +bool operator!=(const Filename& lhs, const Filename& rhs) { + return !(lhs == rhs); +} + +bool operator<(const Filename& lhs, const Filename& rhs) { + return loot::rust::compare_filenames(std::string(lhs), std::string(rhs)) < 0; +} + +bool operator>(const Filename& lhs, const Filename& rhs) { return rhs < lhs; } + +bool operator<=(const Filename& lhs, const Filename& rhs) { + return !(lhs > rhs); +} + +bool operator>=(const Filename& lhs, const Filename& rhs) { + return !(lhs < rhs); +} +} diff --git a/cxx/src/api/metadata/group.cpp b/cxx/src/api/metadata/group.cpp new file mode 100644 index 00000000..a3117f16 --- /dev/null +++ b/cxx/src/api/metadata/group.cpp @@ -0,0 +1,72 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2018 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#include "loot/metadata/group.h" + +namespace loot { +Group::Group(const std::string& name, + const std::vector& afterGroups, + const std::string& description) : + name_(name), description_(description), afterGroups_(afterGroups) {} + +std::string Group::GetName() const { return name_; } + +std::string Group::GetDescription() const { return description_; } + +std::vector Group::GetAfterGroups() const { return afterGroups_; } + +bool operator==(const Group& lhs, const Group& rhs) { + return lhs.GetName() == rhs.GetName() && + lhs.GetDescription() == rhs.GetDescription() && + lhs.GetAfterGroups() == rhs.GetAfterGroups(); +} + +bool operator!=(const Group& lhs, const Group& rhs) { return !(lhs == rhs); } + +bool operator<(const Group& lhs, const Group& rhs) { + if (lhs.GetName() < rhs.GetName()) { + return true; + } + + if (rhs.GetName() < lhs.GetName()) { + return false; + } + + if (lhs.GetDescription() < rhs.GetDescription()) { + return true; + } + + if (rhs.GetDescription() < lhs.GetDescription()) { + return false; + } + + return lhs.GetAfterGroups() < rhs.GetAfterGroups(); +} + +bool operator>(const Group& lhs, const Group& rhs) { return rhs < lhs; } + +bool operator<=(const Group& lhs, const Group& rhs) { return !(lhs > rhs); } + +bool operator>=(const Group& lhs, const Group& rhs) { return !(lhs < rhs); } +} diff --git a/cxx/src/api/metadata/location.cpp b/cxx/src/api/metadata/location.cpp new file mode 100644 index 00000000..ac669ee6 --- /dev/null +++ b/cxx/src/api/metadata/location.cpp @@ -0,0 +1,64 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#include "loot/metadata/location.h" + +namespace loot { +Location::Location(const std::string& url, const std::string& name) : + url_(url), name_(name) {} + +std::string Location::GetURL() const { return url_; } + +std::string Location::GetName() const { return name_; } + +bool operator==(const Location& lhs, const Location& rhs) { + return lhs.GetURL() == rhs.GetURL() && lhs.GetName() == rhs.GetName(); +} + +bool operator!=(const Location& lhs, const Location& rhs) { + return !(lhs == rhs); +} + +bool operator<(const Location& lhs, const Location& rhs) { + if (lhs.GetURL() < rhs.GetURL()) { + return true; + } + + if (rhs.GetURL() < lhs.GetURL()) { + return false; + } + + return lhs.GetName() < rhs.GetName(); +} + +bool operator>(const Location& lhs, const Location& rhs) { return rhs < lhs; } + +bool operator<=(const Location& lhs, const Location& rhs) { + return !(lhs > rhs); +} + +bool operator>=(const Location& lhs, const Location& rhs) { + return !(lhs < rhs); +} +} diff --git a/cxx/src/api/metadata/message.cpp b/cxx/src/api/metadata/message.cpp new file mode 100644 index 00000000..ca152ab2 --- /dev/null +++ b/cxx/src/api/metadata/message.cpp @@ -0,0 +1,93 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#include "loot/metadata/message.h" + +#include + +namespace loot { +Message::Message(const MessageType type, + const std::string& content, + const std::string& condition) : + ConditionalMetadata(condition), + type_(type), + content_({MessageContent(content)}) {} + +Message::Message(const MessageType type, + const std::vector& content, + const std::string& condition) : + ConditionalMetadata(condition), type_(type), content_(content) { + if (content.size() > 1) { + bool englishStringExists = false; + for (const auto& mc : content) { + if (mc.GetLanguage() == MessageContent::DEFAULT_LANGUAGE) + englishStringExists = true; + } + if (!englishStringExists) + throw std::invalid_argument( + "bad conversion: multilingual messages must contain an English " + "content string"); + } +} + +MessageType Message::GetType() const { return type_; } + +std::vector Message::GetContent() const { return content_; } + +bool operator==(const Message& lhs, const Message& rhs) { + return lhs.GetType() == rhs.GetType() && + lhs.GetCondition() == rhs.GetCondition() && + lhs.GetContent() == rhs.GetContent(); +} + +bool operator!=(const Message& lhs, const Message& rhs) { + return !(lhs == rhs); +} + +bool operator<(const Message& lhs, const Message& rhs) { + if (lhs.GetType() < rhs.GetType()) { + return true; + } + + if (rhs.GetType() < lhs.GetType()) { + return false; + } + + if (lhs.GetCondition() < rhs.GetCondition()) { + return true; + } + + if (rhs.GetCondition() < lhs.GetCondition()) { + return false; + } + + return lhs.GetContent() < rhs.GetContent(); +} + +bool operator>(const Message& lhs, const Message& rhs) { return rhs < lhs; } + +bool operator<=(const Message& lhs, const Message& rhs) { return !(lhs > rhs); } + +bool operator>=(const Message& lhs, const Message& rhs) { return !(lhs < rhs); } +} diff --git a/cxx/src/api/metadata/message_content.cpp b/cxx/src/api/metadata/message_content.cpp new file mode 100644 index 00000000..4458551a --- /dev/null +++ b/cxx/src/api/metadata/message_content.cpp @@ -0,0 +1,117 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#include "loot/metadata/message_content.h" + +namespace loot { +MessageContent::MessageContent(const std::string& text, + const std::string& language) : + text_(text), language_(language) {} + +std::string MessageContent::GetText() const { return text_; } + +std::string MessageContent::GetLanguage() const { return language_; } + +bool operator==(const MessageContent& lhs, const MessageContent& rhs) { + return lhs.GetText() == rhs.GetText() && + lhs.GetLanguage() == rhs.GetLanguage(); +} + +bool operator!=(const MessageContent& lhs, const MessageContent& rhs) { + return !(lhs == rhs); +} + +bool operator<(const MessageContent& lhs, const MessageContent& rhs) { + if (lhs.GetText() < rhs.GetText()) { + return true; + } + + if (rhs.GetText() < lhs.GetText()) { + return false; + } + + return lhs.GetLanguage() < rhs.GetLanguage(); +} + +bool operator>(const MessageContent& lhs, const MessageContent& rhs) { + return rhs < lhs; +} + +bool operator<=(const MessageContent& lhs, const MessageContent& rhs) { + return !(lhs > rhs); +} + +bool operator>=(const MessageContent& lhs, const MessageContent& rhs) { + return !(lhs < rhs); +} + +std::optional SelectMessageContent( + const std::vector content, + const std::string& language) { + if (content.empty()) + return std::nullopt; + else if (content.size() == 1) + return content.at(0); + else { + auto languageCode = language.substr(0, language.find("_")); + const auto isCountryCodeGiven = languageCode.length() != language.length(); + + std::optional matchedLanguage; + std::optional english; + for (const auto& mc : content) { + auto contentLanguage = mc.GetLanguage(); + + if (contentLanguage == language) { + return mc; + } else if (!matchedLanguage.has_value()) { + if (isCountryCodeGiven && contentLanguage == languageCode) { + matchedLanguage = mc; + } else if (!isCountryCodeGiven) { + const auto underscorePos = contentLanguage.find("_"); + if (underscorePos != std::string::npos) { + auto contentLanguageCode = contentLanguage.substr(0, underscorePos); + if (contentLanguageCode == language) { + matchedLanguage = mc; + } + } + } + + if (contentLanguage == MessageContent::DEFAULT_LANGUAGE) { + english = mc; + } + } + } + + if (matchedLanguage.has_value()) { + return matchedLanguage; + } + + if (english.has_value()) { + return english; + } + + return std::nullopt; + } +} +} diff --git a/cxx/src/api/metadata/plugin_cleaning_data.cpp b/cxx/src/api/metadata/plugin_cleaning_data.cpp new file mode 100644 index 00000000..8dc5d9fc --- /dev/null +++ b/cxx/src/api/metadata/plugin_cleaning_data.cpp @@ -0,0 +1,130 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#include "loot/metadata/plugin_cleaning_data.h" + +namespace loot { +PluginCleaningData::PluginCleaningData(uint32_t crc, + const std::string& utility) : + crc_(crc), utility_(utility) {} + +PluginCleaningData::PluginCleaningData( + uint32_t crc, + const std::string& utility, + const std::vector& detail, + unsigned int itm, + unsigned int ref, + unsigned int nav) : + crc_(crc), + itm_(itm), + ref_(ref), + nav_(nav), + utility_(utility), + detail_(detail) {} + +uint32_t PluginCleaningData::GetCRC() const { return crc_; } + +unsigned int PluginCleaningData::GetITMCount() const { return itm_; } + +unsigned int PluginCleaningData::GetDeletedReferenceCount() const { + return ref_; +} + +unsigned int PluginCleaningData::GetDeletedNavmeshCount() const { return nav_; } + +std::string PluginCleaningData::GetCleaningUtility() const { return utility_; } + +std::vector PluginCleaningData::GetDetail() const { + return detail_; +} + +bool operator==(const PluginCleaningData& lhs, const PluginCleaningData& rhs) { + return lhs.GetCRC() == rhs.GetCRC() && + lhs.GetITMCount() == rhs.GetITMCount() && + lhs.GetDeletedReferenceCount() == rhs.GetDeletedReferenceCount() && + lhs.GetDeletedNavmeshCount() == rhs.GetDeletedNavmeshCount() && + lhs.GetCleaningUtility() == rhs.GetCleaningUtility() && + lhs.GetDetail() == rhs.GetDetail(); +} + +bool operator!=(const PluginCleaningData& lhs, const PluginCleaningData& rhs) { + return !(lhs == rhs); +} + +bool operator<(const PluginCleaningData& lhs, const PluginCleaningData& rhs) { + if (lhs.GetCRC() < rhs.GetCRC()) { + return true; + } + + if (rhs.GetCRC() < lhs.GetCRC()) { + return false; + } + + if (lhs.GetCleaningUtility() < rhs.GetCleaningUtility()) { + return true; + } + + if (rhs.GetCleaningUtility() < lhs.GetCleaningUtility()) { + return false; + } + + if (lhs.GetITMCount() < rhs.GetITMCount()) { + return true; + } + + if (rhs.GetITMCount() < lhs.GetITMCount()) { + return false; + } + + if (lhs.GetDeletedReferenceCount() < rhs.GetDeletedReferenceCount()) { + return true; + } + + if (rhs.GetDeletedReferenceCount() < lhs.GetDeletedReferenceCount()) { + return false; + } + + if (lhs.GetDeletedNavmeshCount() < rhs.GetDeletedNavmeshCount()) { + return true; + } + + if (rhs.GetDeletedNavmeshCount() < lhs.GetDeletedNavmeshCount()) { + return false; + } + + return lhs.GetDetail() < rhs.GetDetail(); +} + +bool operator>(const PluginCleaningData& lhs, const PluginCleaningData& rhs) { + return rhs < lhs; +} + +bool operator<=(const PluginCleaningData& lhs, const PluginCleaningData& rhs) { + return !(lhs > rhs); +} + +bool operator>=(const PluginCleaningData& lhs, const PluginCleaningData& rhs) { + return !(lhs < rhs); +} +} diff --git a/cxx/src/api/metadata/plugin_metadata.cpp b/cxx/src/api/metadata/plugin_metadata.cpp new file mode 100644 index 00000000..c3c9b3f5 --- /dev/null +++ b/cxx/src/api/metadata/plugin_metadata.cpp @@ -0,0 +1,210 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#include "loot/metadata/plugin_metadata.h" + +#include +#include + +#include "api/convert.h" +#include "libloot-cxx/src/lib.rs.h" + +namespace { +// Append second to first, skipping any elements that are already present in +// first. Although this is O(U * M), both input vectors are expected to be +// small (with tens of elements being an unusually large number). +template +std::vector mergeVectors(std::vector first, + const std::vector& second) { + const auto initialSizeOfFirst = first.size(); + for (const auto& element : second) { + const auto end = first.cbegin() + initialSizeOfFirst; + + if (std::find(first.cbegin(), end, element) == end) { + first.push_back(element); + } + } + + return first; +} + +std::string TrimDotGhostExtension(const std::string& filename) { + // If the name passed ends in '.ghost', that should be trimmed. + constexpr std::string_view GHOST_FILE_EXTENSION = ".ghost"; + + if (filename.length() < GHOST_FILE_EXTENSION.length()) { + return filename; + } + + auto view = std::string_view(filename); + view.remove_prefix(filename.length() - GHOST_FILE_EXTENSION.length()); + + bool areEqual = std::equal( + view.begin(), + view.end(), + GHOST_FILE_EXTENSION.begin(), + [](unsigned char a, unsigned char b) { return std::tolower(a) == b; }); + + if (areEqual) { + return filename.substr(0, + filename.length() - GHOST_FILE_EXTENSION.length()); + } + + return filename; +} +} + +namespace loot { +PluginMetadata::PluginMetadata(const std::string& n) : name_(n) { + // If the name passed ends in '.ghost', that should be trimmed. + name_ = TrimDotGhostExtension(n); + + if (IsRegexPlugin()) { + nameRegex_ = std::regex(name_, std::regex::ECMAScript | std::regex::icase); + } +} + +void PluginMetadata::MergeMetadata(const PluginMetadata& plugin) { + if (plugin.HasNameOnly()) + return; + + if (!group_.has_value() && plugin.GetGroup()) { + group_ = plugin.GetGroup(); + } + + loadAfter_ = mergeVectors(loadAfter_, plugin.loadAfter_); + requirements_ = mergeVectors(requirements_, plugin.requirements_); + incompatibilities_ = + mergeVectors(incompatibilities_, plugin.incompatibilities_); + + tags_ = mergeVectors(tags_, plugin.tags_); + + // Messages are in an ordered list, and should be fully merged. + messages_.insert( + end(messages_), begin(plugin.messages_), end(plugin.messages_)); + + dirtyInfo_ = mergeVectors(dirtyInfo_, plugin.dirtyInfo_); + cleanInfo_ = mergeVectors(cleanInfo_, plugin.cleanInfo_); + locations_ = mergeVectors(locations_, plugin.locations_); + + return; +} + +std::string PluginMetadata::GetName() const { return name_; } + +std::optional PluginMetadata::GetGroup() const { return group_; } + +std::vector PluginMetadata::GetLoadAfterFiles() const { + return loadAfter_; +} + +std::vector PluginMetadata::GetRequirements() const { + return requirements_; +} + +std::vector PluginMetadata::GetIncompatibilities() const { + return incompatibilities_; +} + +std::vector PluginMetadata::GetMessages() const { return messages_; } + +std::vector PluginMetadata::GetTags() const { return tags_; } + +std::vector PluginMetadata::GetDirtyInfo() const { + return dirtyInfo_; +} + +std::vector PluginMetadata::GetCleanInfo() const { + return cleanInfo_; +} + +std::vector PluginMetadata::GetLocations() const { + return locations_; +} + +void PluginMetadata::SetGroup(const std::string& group) { group_ = group; } + +void PluginMetadata::UnsetGroup() { group_ = std::nullopt; } + +void PluginMetadata::SetLoadAfterFiles(const std::vector& l) { + loadAfter_ = l; +} + +void PluginMetadata::SetRequirements(const std::vector& r) { + requirements_ = r; +} + +void PluginMetadata::SetIncompatibilities(const std::vector& i) { + incompatibilities_ = i; +} + +void PluginMetadata::SetMessages(const std::vector& m) { + messages_ = m; +} + +void PluginMetadata::SetTags(const std::vector& t) { tags_ = t; } + +void PluginMetadata::SetDirtyInfo( + const std::vector& dirtyInfo) { + dirtyInfo_ = dirtyInfo; +} + +void PluginMetadata::SetCleanInfo(const std::vector& info) { + cleanInfo_ = info; +} + +void PluginMetadata::SetLocations(const std::vector& locations) { + locations_ = locations; +} + +bool PluginMetadata::HasNameOnly() const { + return !group_.has_value() && loadAfter_.empty() && requirements_.empty() && + incompatibilities_.empty() && messages_.empty() && tags_.empty() && + dirtyInfo_.empty() && cleanInfo_.empty() && locations_.empty(); +} + +bool PluginMetadata::IsRegexPlugin() const { + // Treat as regex if the plugin filename contains any of ":\*?|" as + // they are not valid Windows filename characters, but have meaning + // in regexes. + return strpbrk(name_.c_str(), ":\\*?|") != nullptr; +} + +bool PluginMetadata::NameMatches(const std::string& pluginName) const { + if (IsRegexPlugin()) { + if (!nameRegex_.has_value()) { + throw std::runtime_error("Regex plugin does not have regex object"); + } + + return std::regex_match(pluginName, nameRegex_.value()); + } + + return loot::rust::compare_filenames(name_, pluginName) == 0; +} + +std::string PluginMetadata::AsYaml() const { + const auto metadata = convert(*this); + return std::string(metadata->as_yaml()); +} +} diff --git a/cxx/src/api/metadata/tag.cpp b/cxx/src/api/metadata/tag.cpp new file mode 100644 index 00000000..1a551ad5 --- /dev/null +++ b/cxx/src/api/metadata/tag.cpp @@ -0,0 +1,66 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2012-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ + +#include "loot/metadata/tag.h" + +namespace loot { +Tag::Tag(const std::string& tag, + const bool isAddition, + const std::string& condition) : + ConditionalMetadata(condition), name_(tag), addTag_(isAddition) {} + +bool Tag::IsAddition() const { return addTag_; } + +std::string Tag::GetName() const { return name_; } + +bool operator==(const Tag& lhs, const Tag& rhs) { + return lhs.IsAddition() == rhs.IsAddition() && + lhs.GetName() == rhs.GetName() && + lhs.GetCondition() == rhs.GetCondition(); +} + +bool operator!=(const Tag& lhs, const Tag& rhs) { return !(lhs == rhs); } + +bool operator<(const Tag& lhs, const Tag& rhs) { + if (lhs.IsAddition() != rhs.IsAddition()) { + return lhs.IsAddition() && !rhs.IsAddition(); + } + + if (lhs.GetName() < rhs.GetName()) { + return true; + } + + if (rhs.GetName() < lhs.GetName()) { + return false; + } + + return lhs.GetCondition() < rhs.GetCondition(); +} + +bool operator>(const Tag& lhs, const Tag& rhs) { return rhs < lhs; } + +bool operator<=(const Tag& lhs, const Tag& rhs) { return !(lhs > rhs); } + +bool operator>=(const Tag& lhs, const Tag& rhs) { return !(lhs < rhs); } +} diff --git a/cxx/src/api/plugin.cpp b/cxx/src/api/plugin.cpp new file mode 100644 index 00000000..5e9e17d4 --- /dev/null +++ b/cxx/src/api/plugin.cpp @@ -0,0 +1,91 @@ +#include "api/plugin.h" + +#include +#include + +#include "api/convert.h" + +namespace loot { +Plugin::Plugin(::rust::Box plugin) : + plugin_(std::move(plugin)) {} + +std::string Plugin::GetName() const { return std::string(plugin_->name()); } + +std::optional Plugin::GetHeaderVersion() const { + const auto value = plugin_->header_version(); + if (std::isnan(value)) { + return std::nullopt; + } else { + return value; + } +} + +std::optional Plugin::GetVersion() const { + const auto value = plugin_->version(); + if (value.empty()) { + return std::nullopt; + } else { + return std::string(value); + } +} + +std::vector Plugin::GetMasters() const { + return convert(plugin_->masters()); +} + +std::vector Plugin::GetBashTags() const { + std::vector tags; + for (const auto& tag : plugin_->bash_tags()) { + tags.push_back(Tag(std::string(tag))); + } + + return tags; +} + +std::optional Plugin::GetCRC() const { + const auto value = plugin_->crc(); + if (value < 0 || value > UINT32_MAX) { + return std::nullopt; + } else { + return uint32_t(value); + } +} + +bool Plugin::IsMaster() const { return plugin_->is_master(); } + +bool Plugin::IsLightPlugin() const { return plugin_->is_light_plugin(); } + +bool Plugin::IsMediumPlugin() const { return plugin_->is_medium_plugin(); } + +bool Plugin::IsUpdatePlugin() const { return plugin_->is_update_plugin(); } + +bool Plugin::IsBlueprintPlugin() const { + return plugin_->is_blueprint_plugin(); +} + +bool Plugin::IsValidAsLightPlugin() const { + return plugin_->is_valid_as_light_plugin(); +} + +bool Plugin::IsValidAsMediumPlugin() const { + return plugin_->is_valid_as_medium_plugin(); +} + +bool Plugin::IsValidAsUpdatePlugin() const { + return plugin_->is_valid_as_update_plugin(); +} + +bool Plugin::IsEmpty() const { return plugin_->is_empty(); } + +bool Plugin::LoadsArchive() const { return plugin_->loads_archive(); } + +bool Plugin::DoRecordsOverlap(const PluginInterface& plugin) const { + try { + auto& otherPlugin = dynamic_cast(plugin); + + return plugin_->do_records_overlap(*otherPlugin.plugin_); + } catch (std::bad_cast&) { + throw std::invalid_argument("Tried to check if records overlapped with a different concrete type implementing PluginInterface"); + } +} +} diff --git a/cxx/src/api/plugin.h b/cxx/src/api/plugin.h new file mode 100644 index 00000000..b4ecd523 --- /dev/null +++ b/cxx/src/api/plugin.h @@ -0,0 +1,46 @@ + +#ifndef LOOT_API_PLUGIN +#define LOOT_API_PLUGIN + +#include +#include +#include +#include +#include + +#include "libloot-cxx/src/lib.rs.h" +#include "loot/metadata/tag.h" +#include "loot/plugin_interface.h" + +namespace loot { + +class Plugin final : public PluginInterface { +public: + explicit Plugin(::rust::Box plugin); + + std::string GetName() const override; + std::optional GetHeaderVersion() const override; + std::optional GetVersion() const override; + std::vector GetMasters() const override; + std::vector GetBashTags() const override; + std::optional GetCRC() const override; + + bool IsMaster() const override; + bool IsLightPlugin() const override; + bool IsMediumPlugin() const override; + bool IsUpdatePlugin() const override; + bool IsBlueprintPlugin() const override; + + bool IsValidAsLightPlugin() const override; + bool IsValidAsMediumPlugin() const override; + bool IsValidAsUpdatePlugin() const override; + bool IsEmpty() const override; + bool LoadsArchive() const override; + bool DoRecordsOverlap(const PluginInterface& plugin) const override; + +private: + ::rust::Box plugin_; +}; +} + +#endif diff --git a/cxx/src/api/resource.rc b/cxx/src/api/resource.rc new file mode 100644 index 00000000..3332aba2 --- /dev/null +++ b/cxx/src/api/resource.rc @@ -0,0 +1,30 @@ +#ifdef _WIN32 +#include + +1 VERSIONINFO +FILEVERSION 0, 25, 3, 0 +PRODUCTVERSION 0, 25, 3, 0 +FILEOS VOS__WINDOWS32 +FILETYPE VFT_DLL +BEGIN +BLOCK "StringFileInfo" +BEGIN +BLOCK "040904b0" +BEGIN +VALUE "CompanyName", "LOOT" +VALUE "FileDescription", "Library providing LOOT's core functionality" +VALUE "FileVersion", "0.25.3" +VALUE "InternalName", "loot" +VALUE "LegalCopyright", "Copyright (C) 2013-2022 Oliver Hamlet" +VALUE "OriginalFilename", "loot.dll" +VALUE "ProductName", "LOOT" +VALUE "ProductVersion", "0.25.3" +END +END +BLOCK "VarFileInfo" +BEGIN +VALUE "Translation", 0x409, 1200 +END +END + +#endif diff --git a/cxx/src/api/sorting/cyclic_interaction_error.cpp b/cxx/src/api/sorting/cyclic_interaction_error.cpp new file mode 100644 index 00000000..d62f5332 --- /dev/null +++ b/cxx/src/api/sorting/cyclic_interaction_error.cpp @@ -0,0 +1,85 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2018 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#include "loot/exception/cyclic_interaction_error.h" + +namespace { +using loot::EdgeType; + +std::string describeEdgeType(EdgeType edgeType) { + switch (edgeType) { + case EdgeType::hardcoded: + return "Hardcoded"; + case EdgeType::masterFlag: + return "Master Flag"; + case EdgeType::master: + return "Master"; + case EdgeType::masterlistRequirement: + return "Masterlist Requirement"; + case EdgeType::userRequirement: + return "User Requirement"; + case EdgeType::masterlistLoadAfter: + return "Masterlist Load After"; + case EdgeType::userLoadAfter: + return "User Load After"; + case EdgeType::masterlistGroup: + return "Masterlist Group"; + case EdgeType::userGroup: + return "User Group"; + case EdgeType::recordOverlap: + return "Record Overlap"; + case EdgeType::assetOverlap: + return "Asset Overlap"; + case EdgeType::tieBreak: + return "Tie Break"; + default: + return "Unknown"; + } +} +} + +namespace loot { +// A.esp --[Master Flag]-> B.esp --Group-> +std::string describeCycle(const std::vector& cycle) { + std::string text; + for (const auto& vertex : cycle) { + text += vertex.GetName(); + if (vertex.GetTypeOfEdgeToNextVertex().has_value()) { + text += " --[" + + describeEdgeType(vertex.GetTypeOfEdgeToNextVertex().value()) + + "]-> "; + } + } + if (!cycle.empty()) { + text += cycle.at(0).GetName(); + } + + return text; +} + +CyclicInteractionError::CyclicInteractionError(std::vector cycle) : + std::runtime_error("Cyclic interaction detected: " + describeCycle(cycle)), + cycle_(cycle) {} + +std::vector CyclicInteractionError::GetCycle() const { return cycle_; } +} diff --git a/cxx/src/api/sorting/undefined_group_error.cpp b/cxx/src/api/sorting/undefined_group_error.cpp new file mode 100644 index 00000000..50fa72da --- /dev/null +++ b/cxx/src/api/sorting/undefined_group_error.cpp @@ -0,0 +1,32 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2018 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#include "loot/exception/undefined_group_error.h" + +namespace loot { +UndefinedGroupError::UndefinedGroupError(const std::string& groupName) : + std::runtime_error("The group \"" + groupName + "\" does not exist"), + groupName_(groupName) {} + +std::string UndefinedGroupError::GetGroupName() const { return groupName_; } +} diff --git a/cxx/src/api/vertex.cpp b/cxx/src/api/vertex.cpp new file mode 100644 index 00000000..fb6a345b --- /dev/null +++ b/cxx/src/api/vertex.cpp @@ -0,0 +1,37 @@ +/* LOOT + + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2018 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ +#include "loot/vertex.h" + +namespace loot { +Vertex::Vertex(std::string name) : name_(name) {} + +Vertex::Vertex(std::string name, EdgeType outEdgeType) : + name_(name), outEdgeType_(outEdgeType) {} + +std::string Vertex::GetName() const { return name_; } + +std::optional Vertex::GetTypeOfEdgeToNextVertex() const { + return outEdgeType_; +} +} diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index 9e3091eb..e52a8e9c 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -13,7 +13,11 @@ use metadata::{ new_tag, select_message_content, }; use plugin::{OptionalPluginRef, PluginRef}; -use std::ffi::{CString, c_char, c_uint}; +use std::{ + ffi::{c_char, c_uchar, c_uint, c_void, CString}, + sync::{atomic::AtomicPtr, Mutex}, +}; +use unicase::UniCase; use libloot::set_logging_callback; pub use libloot::{is_compatible, libloot_revision, libloot_version}; @@ -95,8 +99,16 @@ impl std::fmt::Display for UnsupportedEnumValueError { impl std::error::Error for UnsupportedEnumValueError {} +fn compare_filenames(lhs: &str, rhs: &str) -> i8 { + match UniCase::new(lhs).cmp(&UniCase::new(rhs)) { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => 1, + } +} + #[allow(clippy::needless_lifetimes)] -#[cxx::bridge(namespace = "loot")] +#[cxx::bridge(namespace = "loot::rust")] mod ffi { pub enum GameType { @@ -149,6 +161,8 @@ mod ffi { contents: &[MessageContent], language: &str, ) -> Box; + + fn compare_filenames(lhs: &str, rhs: &str) -> i8; } extern "Rust" { @@ -351,6 +365,8 @@ mod ffi { pub fn loads_archive(&self) -> bool; pub unsafe fn do_records_overlap<'a>(&self, plugin: &PluginRef<'a>) -> Result; + + pub unsafe fn boxed_clone<'a>(&'a self) -> Box>; } extern "Rust" { @@ -555,15 +571,57 @@ pub static LIBLOOT_VERSION_MINOR: c_uint = libloot::LIBLOOT_VERSION_MINOR; #[unsafe(no_mangle)] pub static LIBLOOT_VERSION_PATCH: c_uint = libloot::LIBLOOT_VERSION_PATCH; -// TODO: Implement this properly. +#[unsafe(no_mangle)] +pub static LIBLOOT_LOG_LEVEL_TRACE: c_uchar = libloot::LogLevel::Trace as u8; + +#[unsafe(no_mangle)] +pub static LIBLOOT_LOG_LEVEL_DEBUG: c_uchar = libloot::LogLevel::Debug as u8; + +#[unsafe(no_mangle)] +pub static LIBLOOT_LOG_LEVEL_INFO: c_uchar = libloot::LogLevel::Info as u8; + +#[unsafe(no_mangle)] +pub static LIBLOOT_LOG_LEVEL_WARNING: c_uchar = libloot::LogLevel::Warning as u8; + +#[unsafe(no_mangle)] +pub static LIBLOOT_LOG_LEVEL_ERROR: c_uchar = libloot::LogLevel::Error as u8; + +#[unsafe(no_mangle)] +pub static LIBLOOT_LOG_LEVEL_FATAL: c_uchar = libloot::LogLevel::Fatal as u8; + #[unsafe(no_mangle)] unsafe extern "C" fn libloot_set_logging_callback( - callback: unsafe extern "C" fn(u8, *const c_char), + callback: unsafe extern "C" fn(u8, *const c_char, *mut c_void), + context: *mut c_void, ) { + let mutex = Mutex::new(AtomicPtr::new(context)); + set_logging_callback(move |level, message| { - let c_string = CString::new(message).unwrap(); + let (level, c_string) = match CString::new(message) { + Ok(c) => (level, c), + Err(_) => { + let c_string = CString::new(format!( + "Attempted to log a message containing a null byte: {}", + message.replace('\0', "\\0") + )) + .unwrap_or_else(|_| { + CString::from(c"Attempted to log a message containing a null byte") + }); + (libloot::LogLevel::Error, c_string) + } + }; + + let mut context = match mutex.lock() { + Ok(c) => c, + Err(e) => { + // The stored value is an atomic, since it's atomic it can't have been left in an invalid state. + mutex.clear_poison(); + e.into_inner() + } + }; + unsafe { - callback(level as u8, c_string.as_ptr()); + callback(level as u8, c_string.as_ptr(), *context.get_mut()); } }); } diff --git a/cxx/src/plugin.rs b/cxx/src/plugin.rs index 4f769186..59b9267d 100644 --- a/cxx/src/plugin.rs +++ b/cxx/src/plugin.rs @@ -43,6 +43,10 @@ impl<'a> PluginRef<'a> { self.0.do_records_overlap(plugin.0).map_err(Into::into) } + pub fn boxed_clone(&self) -> Box { + Box::new(Self(self.0)) + } + delegate! { to self.0 { pub fn name(&self) -> &str; diff --git a/cxx/src/tests/api/interface/main.cpp b/cxx/src/tests/api/interface/main.cpp index 405173f4..a501bd5e 100644 --- a/cxx/src/tests/api/interface/main.cpp +++ b/cxx/src/tests/api/interface/main.cpp @@ -74,7 +74,7 @@ TEST(SetLoggingCallback, shouldAcceptAMemberFunction) { } catch (...) { EXPECT_EQ( "Attempting to create a game handle for game type \"The Elder Scrolls " - "IV: Oblivion\" with game path \"dummy\" and game local path \"\"", + "IV: Oblivion\" with game path \"dummy\"", testLogger.loggedMessages); SetLoggingCallback([](LogLevel, const char *) {}); @@ -94,7 +94,7 @@ TEST(SetLoggingCallback, shouldAcceptALambdaFunction) { } catch (...) { EXPECT_EQ( "Attempting to create a game handle for game type \"The Elder Scrolls " - "IV: Oblivion\" with game path \"dummy\" and game local path \"\"", + "IV: Oblivion\" with game path \"dummy\"", loggedMessages); SetLoggingCallback([](LogLevel, const char *) {}); @@ -116,7 +116,7 @@ TEST(SetLoggingCallback, } catch (...) { EXPECT_EQ( "Attempting to create a game handle for game type \"The Elder Scrolls " - "IV: Oblivion\" with game path \"dummy\" and game local path \"\"", + "IV: Oblivion\" with game path \"dummy\"", loggedMessages); SetLoggingCallback([](LogLevel, const char *) {}); diff --git a/cxx/src/tests/api/internals/main.cpp b/cxx/src/tests/api/internals/main.cpp new file mode 100644 index 00000000..eb94b22b --- /dev/null +++ b/cxx/src/tests/api/internals/main.cpp @@ -0,0 +1,61 @@ + + +#include + +#include "libloot-cxx/src/lib.rs.h" +#include "rust/cxx.h" + +namespace rust { +template +bool operator==(const Vec& lhs, const Vec& rhs) { + if (lhs.size() != rhs.size()) { + return false; + } + return std::equal(lhs.begin(), lhs.end(), rhs.begin()); +} +} + +namespace loot::rust { +TEST(libloot_version, shouldReturnExpectedValue) { + auto version = libloot_version(); + + EXPECT_EQ(version, "0.25.3"); +} + +TEST(libloot_revision, shouldReturnExpectedValue) { + auto revision = libloot_revision(); + + EXPECT_EQ(revision, "unknown"); +} + +TEST(new_game, shouldThrowIfGivenNonsense) { + EXPECT_THROW(new_game(GameType::fo3, "foo"), ::rust::Error); +} + +TEST(Message, creation) { + auto content = new_message_content("a message"); + content->set_language( + ::rust::String(std::string(message_content_default_language()))); + + auto message = new_message(MessageType::say, "message2"); + message->set_condition("invalid condition"); + + std::vector<::rust::Box> contents; + contents.push_back(std::move(content)); + auto multi_message = multilingual_message( + MessageType::say, + ::rust::Slice>(contents)); + + multi_message->set_condition("invalid condition"); + + EXPECT_EQ(multi_message->content()[0].text(), "a message"); + EXPECT_EQ(multi_message->content()[0].language(), "en"); + EXPECT_EQ(MessageType::say, multi_message->message_type()); + EXPECT_EQ(multi_message->condition(), "invalid condition"); +} +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/cxx/src/tests/common_game_test_fixture.h b/cxx/src/tests/common_game_test_fixture.h index 8db9c037..5288e62a 100644 --- a/cxx/src/tests/common_game_test_fixture.h +++ b/cxx/src/tests/common_game_test_fixture.h @@ -28,7 +28,6 @@ along with LOOT. If not, see #include #include -#include #include #include #include @@ -229,10 +228,9 @@ protected: std::string filename = it->path().filename().u8string(); if (filename == nonPluginFile) continue; - if (boost::ends_with(filename, ".ghost")) + if (endsWith(filename, ".ghost")) filename = it->path().stem().u8string(); - if (boost::ends_with(filename, ".esp") || - boost::ends_with(filename, ".esm")) + if (endsWith(filename, ".esp") || endsWith(filename, ".esm")) loadOrder.emplace(std::filesystem::last_write_time(it->path()), filename); } @@ -501,6 +499,17 @@ private: return gameType == GameType::tes3 || gameType == GameType::tes4 || gameType == GameType::fo3 || gameType == GameType::fonv; } + + static bool endsWith(const std::string& str, const std::string& suffix) { + if (str.length() < suffix.length()) { + return false; + } + + auto view = std::string_view(str); + view.remove_prefix(str.length() - suffix.length()); + + return view == suffix; + } }; } } From 327d9935c92b4af1011fedd263d269d8824d53ee Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Fri, 14 Mar 2025 17:05:34 +0000 Subject: [PATCH 033/206] Map C++ exception types --- Cargo.lock | 3 + README.md | 4 +- cxx/CMakeLists.txt | 2 + cxx/Cargo.toml | 4 + cxx/README.md | 54 ++------- cxx/src/api/convert.cpp | 112 +++++++++++------- cxx/src/api/database.cpp | 122 ++++++++++++++------ cxx/src/api/exception.cpp | 177 ++++++++++++++++++++++++++++ cxx/src/api/exception.h | 10 ++ cxx/src/api/game.cpp | 105 ++++++++++++----- cxx/src/api/plugin.cpp | 27 ++++- cxx/src/error_codes.rs | 236 ++++++++++++++++++++++++++++++++++++++ cxx/src/lib.rs | 214 +++++++++++++++++++++++++++++++--- 13 files changed, 905 insertions(+), 165 deletions(-) create mode 100644 cxx/src/api/exception.cpp create mode 100644 cxx/src/api/exception.h create mode 100644 cxx/src/error_codes.rs diff --git a/Cargo.lock b/Cargo.lock index 3545f348..5bea9336 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -588,7 +588,10 @@ dependencies = [ "cxx", "cxx-build", "delegate", + "esplugin", + "libloadorder", "libloot", + "loot-condition-interpreter", "unicase", ] diff --git a/README.md b/README.md index f60c3622..a511ae5b 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ Currently complete: - [x] Metadata-related functionality (excluding writing YAML) - [x] Sorting functionality - [x] Unit tests -- [ ] Integration tests -- [ ] C++ FFI +- [x] Integration tests +- [x] C++ FFI - [ ] Python FFI The complete bits should match libloot commit [55b341fc6cbdccee52e42923c13a91eddb5ca97d](https://github.com/loot/libloot/commit/55b341fc6cbdccee52e42923c13a91eddb5ca97d), which is libloot v0.25.3 plus a few changes prompted by this translation. diff --git a/cxx/CMakeLists.txt b/cxx/CMakeLists.txt index 78e4576c..e1c6de29 100644 --- a/cxx/CMakeLists.txt +++ b/cxx/CMakeLists.txt @@ -41,6 +41,7 @@ set(LIBLOOT_SRC_API_CPP_FILES "${CMAKE_SOURCE_DIR}/src/api/convert.cpp" "${CMAKE_SOURCE_DIR}/src/api/database.cpp" "${CMAKE_SOURCE_DIR}/src/api/error_categories.cpp" + "${CMAKE_SOURCE_DIR}/src/api/exception.cpp" "${CMAKE_SOURCE_DIR}/src/api/metadata/conditional_metadata.cpp" "${CMAKE_SOURCE_DIR}/src/api/metadata/file.cpp" "${CMAKE_SOURCE_DIR}/src/api/metadata/filename.cpp" @@ -89,6 +90,7 @@ set(LIBLOOT_INCLUDE_H_FILES set(LIBLOOT_SRC_API_H_FILES "${CMAKE_SOURCE_DIR}/src/api/convert.h" "${CMAKE_SOURCE_DIR}/src/api/database.h" + "${CMAKE_SOURCE_DIR}/src/api/exception.h" "${CMAKE_SOURCE_DIR}/src/api/game.h" "${CMAKE_SOURCE_DIR}/src/api/plugin.h" ) diff --git a/cxx/Cargo.toml b/cxx/Cargo.toml index 82e5480a..0a81b884 100644 --- a/cxx/Cargo.toml +++ b/cxx/Cargo.toml @@ -10,6 +10,10 @@ delegate = "0.13.2" libloot = { path = ".." } unicase = "2.8.1" +esplugin = "6.1.1" +libloadorder = { git = "https://github.com/Ortham/libloadorder.git", rev = "6245dbc6824c4751daef5ab01dfe7b9497f5446f" } +loot-condition-interpreter = { git = "https://github.com/loot/loot-condition-interpreter.git", rev = "f7d9947fa434037743ce81e2d409184ec0bfb693" } + [build-dependencies] cxx-build = "1.0" diff --git a/cxx/README.md b/cxx/README.md index 5dadc604..494e016c 100644 --- a/cxx/README.md +++ b/cxx/README.md @@ -1,49 +1,10 @@ # libloot-rs C++ wrapper -This is an **imcomplete** and **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface that's ABI-compatible with libloot v0.25.3. - -## Current status - -- [x] `EdgeType` -- [x] `GameType` -- [x] `LogLevel` -- [x] `MessageType` -- [ ] `ConditionSyntaxError` -- [ ] `CyclicInteractionError` -- [ ] `FileAccessError` -- [ ] `UndefinedGroupError` -- [ ] `std::system_error` with esplugin `std::error_category` -- [ ] `std::system_error` with libloadorder `std::error_category` -- [ ] `std::system_error` with loot-condition-interpreter `std::error_category` -- [x] `ConditionalMetadata` -- [x] `File` -- [x] `Filename` -- [x] `Group` -- [x] `Location` -- [x] `MessageContent` -- [x] `SelectMessageContent` -- [x] `Message` -- [x] `PluginCleaningData` -- [x] `PluginMetadata` -- [x] `Tag` -- [x] `SetLoggingCallback` -- [x] `IsCompatible` -- [x] `CreateGameHandle` -- [x] `DatabaseInterface` -- [x] `GameInterface` -- [x] `LIBLOOT_VERSION_MAJOR` -- [x] `LIBLOOT_VERSION_MINOR` -- [x] `LIBLOOT_VERSION_PATCH` -- [x] `GetLiblootVersion` -- [x] `GetLiblootRevision` -- [x] `PluginInterface` -- [x] `Vertex` - -The error types are defined but currently unused, causing behavioural differences when errors occur - see the implementation notes below for details. +This is an **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface that's ABI-compatible with libloot v0.25.3. ## Building -The wrapper is currently built in two layers: +The wrapper has two layers: - a static library built using Cargo, which provides a C++ interface - a shared library built using CMake, which wraps that C++ interface to provide another that is ABI-compatible with C++ libloot. @@ -62,8 +23,6 @@ This also builds a copy of the public API tests from C++ libloot v0.25.3, which ctest --test-dir build --output-on-failure -V ``` -Some of the tests currently fail: they are all expecting the exception classes that aren't yet used. - To package the build: ``` @@ -74,9 +33,16 @@ This repository (and so the created package) doesn't currently include any of li ## Usage notes +For the first layer of the wrapper, built using Cargo: + - `LIBLOOT_VERSION_MAJOR`, `LIBLOOT_VERSION_MINOR` and `LIBLOOT_VERSION_PATCH` are exposed as `extern "C"` `static unsigned int` globals. - CXX doesn't provide integration between Rust's `Option<_>` and C++'s `std::optional`, so sentinel values are used to communicate the absence of a value in the wrapper's API: - For `Option<&str>`, `None` is represented using an empty string, which is how it's already done for many metadata object fields in the C++ implementation of libloot. - For `Option`, `None` is represented using `NaN`, since the C++ implementation of libloot's public API already says that `NaN` values get converted to `None`. - Plugin CRCs (which are `Option` in Rust) are represented as `int64_t`, with `-1` representing `None`, since all `uint32_t` values are possibly valid CRCs. -- All Rust errors are converted to `::rust::Error` exceptions that have a `what()` string that is the concatenation of the error's display string and all its recursive source error display strings. +- All Rust errors are converted to `::rust::Error` exceptions that have a `what()` string that is the concatenation of the error's display string and all its recursive source error display strings. For some errors the `what()` string also includes some data that is parsed by the wrapper's second layer to differentiate certain error types. + +For the ABI-compatible second layer of the wrapper, built using CMake: + +- Some exceptions have changed type: they all still derive from `std::exception`, but for example some `std::logic_error` and `std::invalid_argument` exceptions have become `std::runtime_error` and `FileAccessError` exceptions, some `YAML::RepresentationException` exceptions have become `FileAccessError` exceptions, etc. +- Exception messages are generally not expected to be the same between the two implementations. diff --git a/cxx/src/api/convert.cpp b/cxx/src/api/convert.cpp index a2480ec3..08405f27 100644 --- a/cxx/src/api/convert.cpp +++ b/cxx/src/api/convert.cpp @@ -1,5 +1,7 @@ #include "api/convert.h" +#include "api/exception.h" + namespace loot { // To public types ///////////////////// @@ -118,11 +120,15 @@ std::optional convert(uint8_t edgeType) { } loot::Vertex convert(const loot::rust::Vertex& vertex) { - const auto outEdgeType = convert(vertex.out_edge_type()); - if (outEdgeType.has_value()) { - return loot::Vertex(std::string(vertex.name()), outEdgeType.value()); - } else { - return loot::Vertex(std::string(vertex.name())); + try { + const auto outEdgeType = convert(vertex.out_edge_type()); + if (outEdgeType.has_value()) { + return loot::Vertex(std::string(vertex.name()), outEdgeType.value()); + } else { + return loot::Vertex(std::string(vertex.name())); + } + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); } } @@ -140,8 +146,14 @@ loot::Vertex convert(const loot::rust::Vertex& vertex) { ::rust::Box convert(const loot::File& file) { auto output = loot::rust::new_file(std::string(file.GetName())); output->set_display_name(file.GetDisplayName()); - output->set_detail( - ::rust::Slice(convert(file.GetDetail()))); + + try { + output->set_detail( + ::rust::Slice(convert(file.GetDetail()))); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } + output->set_condition(file.GetCondition()); return output; @@ -169,29 +181,44 @@ loot::rust::MessageType convert(loot::MessageType messageType) { } ::rust::Box convert(const loot::Message& message) { - auto output = loot::rust::multilingual_message( - convert(message.GetType()), - ::rust::Slice(convert(message.GetContent()))); - output->set_condition(message.GetCondition()); + try { + auto output = loot::rust::multilingual_message( + convert(message.GetType()), + ::rust::Slice( + convert(message.GetContent()))); + output->set_condition(message.GetCondition()); - return output; + return output; + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } ::rust::Box convert(const loot::Tag& tag) { - const auto suggestion = tag.IsAddition() ? loot::rust::TagSuggestion::Addition - : loot::rust::TagSuggestion::Removal; - auto output = loot::rust::new_tag(tag.GetName(), suggestion); - output->set_condition(tag.GetCondition()); + try { + const auto suggestion = tag.IsAddition() + ? loot::rust::TagSuggestion::Addition + : loot::rust::TagSuggestion::Removal; + auto output = loot::rust::new_tag(tag.GetName(), suggestion); + output->set_condition(tag.GetCondition()); - return output; + return output; + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } ::rust::Box convert( const loot::PluginCleaningData& data) { auto output = loot::rust::new_plugin_cleaning_data(data.GetCRC(), data.GetCleaningUtility()); - output->set_detail( - ::rust::Slice(convert(data.GetDetail()))); + try { + output->set_detail( + ::rust::Slice(convert(data.GetDetail()))); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } + output->set_itm_count(data.GetITMCount()); output->set_deleted_reference_count(data.GetDeletedReferenceCount()); output->set_deleted_navmesh_count(data.GetDeletedNavmeshCount()); @@ -208,29 +235,34 @@ loot::rust::MessageType convert(loot::MessageType messageType) { ::rust::Box convert( const loot::PluginMetadata& metadata) { - auto output = loot::rust::new_plugin_metadata(metadata.GetName()); + try { + auto output = loot::rust::new_plugin_metadata(metadata.GetName()); - if (metadata.GetGroup().has_value()) { - output->set_group(metadata.GetGroup().value()); + if (metadata.GetGroup().has_value()) { + output->set_group(metadata.GetGroup().value()); + } + + output->set_load_after_files( + ::rust::Slice(convert(metadata.GetLoadAfterFiles()))); + output->set_requirements( + ::rust::Slice(convert(metadata.GetRequirements()))); + output->set_incompatibilities(::rust::Slice( + convert(metadata.GetIncompatibilities()))); + output->set_messages( + ::rust::Slice(convert(metadata.GetMessages()))); + output->set_tags( + ::rust::Slice(convert(metadata.GetTags()))); + output->set_dirty_info(::rust::Slice( + convert(metadata.GetDirtyInfo()))); + output->set_clean_info(::rust::Slice( + convert(metadata.GetCleanInfo()))); + output->set_locations( + ::rust::Slice(convert(metadata.GetLocations()))); + + return output; + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); } - - output->set_load_after_files( - ::rust::Slice(convert(metadata.GetLoadAfterFiles()))); - output->set_requirements( - ::rust::Slice(convert(metadata.GetRequirements()))); - output->set_incompatibilities(::rust::Slice( - convert(metadata.GetIncompatibilities()))); - output->set_messages( - ::rust::Slice(convert(metadata.GetMessages()))); - output->set_tags( - ::rust::Slice(convert(metadata.GetTags()))); - output->set_dirty_info( - ::rust::Slice(convert(metadata.GetDirtyInfo()))); - output->set_clean_info( - ::rust::Slice(convert(metadata.GetCleanInfo()))); - output->set_locations(::rust::Slice(convert(metadata.GetLocations()))); - - return output; } // Between containers diff --git a/cxx/src/api/database.cpp b/cxx/src/api/database.cpp index 2150596f..0bdb68e0 100644 --- a/cxx/src/api/database.cpp +++ b/cxx/src/api/database.cpp @@ -2,6 +2,7 @@ #include "api/database.h" #include "api/convert.h" +#include "api/exception.h" namespace loot { Database::Database(::rust::Box&& database) : @@ -10,91 +11,148 @@ Database::Database(::rust::Box&& database) : void Database::LoadLists(const std::filesystem::path& masterlistPath, const std::filesystem::path& userlistPath, const std::filesystem::path& masterlistPreludePath) { - if (!masterlistPath.empty()) { - if (!masterlistPreludePath.empty()) { - database_->load_masterlist_with_prelude(masterlistPath.u8string(), - masterlistPreludePath.u8string()); - } else { - database_->load_masterlist(masterlistPath.u8string()); + try { + if (!masterlistPath.empty()) { + if (!masterlistPreludePath.empty()) { + database_->load_masterlist_with_prelude( + masterlistPath.u8string(), masterlistPreludePath.u8string()); + } else { + database_->load_masterlist(masterlistPath.u8string()); + } } - } - if (!userlistPath.empty()) { - database_->load_userlist(userlistPath.u8string()); + if (!userlistPath.empty()) { + database_->load_userlist(userlistPath.u8string()); + } + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); } } void Database::WriteUserMetadata(const std::filesystem::path& outputFile, const bool overwrite) const { - database_->write_user_metadata(outputFile.u8string(), overwrite); + try { + database_->write_user_metadata(outputFile.u8string(), overwrite); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } std::vector Database::GetKnownBashTags() const { - return convert(database_->known_bash_tags()); + try { + return convert(database_->known_bash_tags()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } std::vector Database::GetGeneralMessages( bool evaluateConditions) const { - return convert(database_->general_messages(evaluateConditions)); + try { + return convert(database_->general_messages(evaluateConditions)); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } std::vector Database::GetGroups(bool includeUserMetadata) const { - return convert(database_->groups(includeUserMetadata)); + try { + return convert(database_->groups(includeUserMetadata)); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } std::vector Database::GetUserGroups() const { - return convert(database_->user_groups()); + try { + return convert(database_->user_groups()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } void Database::SetUserGroups(const std::vector& groups) { - database_->set_user_groups(::rust::Slice(convert(groups))); + try { + database_->set_user_groups( + ::rust::Slice(convert(groups))); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } std::vector Database::GetGroupsPath( const std::string& fromGroupName, const std::string& toGroupName) const { - return convert(database_->groups_path(fromGroupName, toGroupName)); + try { + return convert(database_->groups_path(fromGroupName, toGroupName)); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } std::optional Database::GetPluginMetadata( const std::string& plugin, bool includeUserMetadata, bool evaluateConditions) const { - const auto metadata = database_->plugin_metadata( - plugin, includeUserMetadata, evaluateConditions); - if (metadata->is_some()) { - return convert(metadata->as_ref()); - } else { - return std::nullopt; + try { + const auto metadata = database_->plugin_metadata( + plugin, includeUserMetadata, evaluateConditions); + if (metadata->is_some()) { + return convert(metadata->as_ref()); + } else { + return std::nullopt; + } + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); } } std::optional Database::GetPluginUserMetadata( const std::string& plugin, bool evaluateConditions) const { - const auto metadata = - database_->plugin_user_metadata(plugin, evaluateConditions); - if (metadata->is_some()) { - return convert(metadata->as_ref()); - } else { - return std::nullopt; + try { + const auto metadata = + database_->plugin_user_metadata(plugin, evaluateConditions); + if (metadata->is_some()) { + return convert(metadata->as_ref()); + } else { + return std::nullopt; + } + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); } } void Database::SetPluginUserMetadata(const PluginMetadata& pluginMetadata) { - database_->set_plugin_user_metadata(convert(pluginMetadata)); + try { + database_->set_plugin_user_metadata(convert(pluginMetadata)); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } void Database::DiscardPluginUserMetadata(const std::string& plugin) { - database_->discard_plugin_user_metadata(plugin); + try { + database_->discard_plugin_user_metadata(plugin); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } void Database::DiscardAllUserMetadata() { - database_->discard_all_user_metadata(); + try { + database_->discard_all_user_metadata(); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } void Database::WriteMinimalList(const std::filesystem::path& outputFile, const bool overwrite) const { - database_->write_minimal_list(outputFile.u8string(), overwrite); + try { + database_->write_minimal_list(outputFile.u8string(), overwrite); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } } diff --git a/cxx/src/api/exception.cpp b/cxx/src/api/exception.cpp new file mode 100644 index 00000000..acec793c --- /dev/null +++ b/cxx/src/api/exception.cpp @@ -0,0 +1,177 @@ +#include "api/exception.h" + +#include + +#include "loot/exception/condition_syntax_error.h" +#include "loot/exception/cyclic_interaction_error.h" +#include "loot/exception/error_categories.h" +#include "loot/exception/file_access_error.h" +#include "loot/exception/undefined_group_error.h" +#include "loot/vertex.h" + +namespace { +using loot::EdgeType; +using loot::Vertex; + +constexpr std::string_view CYCLIC_ERROR_PREFIX = "CyclicInteractionError: "; +constexpr std::string_view UNDEFINED_GROUP_ERROR_PREFIX = + "UndefinedGroupError: "; +constexpr std::string_view ESPLUGIN_ERROR_PREFIX = "EspluginError: "; +constexpr std::string_view LIBLOADORDER_ERROR_PREFIX = "LibloadorderError: "; +constexpr std::string_view LCI_ERROR_PREFIX = "LciError: "; +constexpr std::string_view FILE_ACCESS_ERROR_PREFIX = "FileAccessError: "; +constexpr std::string_view INVALID_ARGUMENT_PREFIX = "InvalidArgument: "; + +bool startsWith(const std::string_view& str, const std::string_view& prefix) { + if (str.size() < prefix.size()) { + return false; + } + + return str.substr(0, prefix.size()) == prefix; +} + +std::string replace(const std::string_view& str, + const std::string_view& from, + const std::string_view& to) { + std::string out; + out.reserve(str.size()); + + size_t i = 0; + while (i < str.size()) { + if (i + from.size() <= str.size() && str.substr(i, from.size()) == from) { + out.append(to); + i += from.size(); + } else { + out.push_back(str[i]); + i += 1; + } + } + + return out; +} + +EdgeType toEdgeType(const std::string_view& edgeTypeDisplay) { + if (edgeTypeDisplay == "Hardcoded") { + return EdgeType::hardcoded; + } else if (edgeTypeDisplay == "Master Flag") { + return EdgeType::masterFlag; + } else if (edgeTypeDisplay == "Master") { + return EdgeType::master; + } else if (edgeTypeDisplay == "Masterlist Requirement") { + return EdgeType::masterlistRequirement; + } else if (edgeTypeDisplay == "User Requirement") { + return EdgeType::userRequirement; + } else if (edgeTypeDisplay == "Masterlist Load After") { + return EdgeType::masterlistLoadAfter; + } else if (edgeTypeDisplay == "User Load After") { + return EdgeType::userLoadAfter; + } else if (edgeTypeDisplay == "Masterlist Group") { + return EdgeType::masterlistGroup; + } else if (edgeTypeDisplay == "User Group") { + return EdgeType::userGroup; + } else if (edgeTypeDisplay == "Record Overlap") { + return EdgeType::recordOverlap; + } else if (edgeTypeDisplay == "Asset Overlap") { + return EdgeType::assetOverlap; + } else if (edgeTypeDisplay == "Tie Break") { + return EdgeType::tieBreak; + } else if (edgeTypeDisplay == "Blueprint Master") { + return EdgeType::blueprintMaster; + } else { + std::string what("Unrecognised edge type: "); + what += edgeTypeDisplay; + throw std::logic_error(what); + } +} + +std::vector parseCyclicError(const std::string_view& what) { + const auto suffix = what.substr(0, CYCLIC_ERROR_PREFIX.size()); + + std::vector vertices; + size_t pos = 0; + while (pos < suffix.size()) { + const auto sepPos = suffix.find("--", pos); + const auto escapedName = suffix.substr(pos, sepPos); + const auto name = replace(replace(escapedName, "\\-", "-"), "\\\\", "\\"); + + if (sepPos != std::string::npos) { + const auto secondSepPos = suffix.find("--", sepPos + 2); + const auto escapedEdgeName = + suffix.substr(sepPos + 2, secondSepPos - (sepPos + 2)); + + vertices.push_back(Vertex(name, toEdgeType(escapedEdgeName))); + + pos = secondSepPos + 2; + } else { + vertices.push_back(Vertex(name)); + pos = suffix.size(); + } + } + + return vertices; +} + +std::string getErrorSuffix(const std::string_view& what) { + const auto sepPos = what.find(": "); + + return std::string(what.substr(sepPos + 2)); +} + +std::pair parseSystemError( + const std::string_view& whatSuffix) { + const auto sepPos = whatSuffix.find(": "); + int code; + const auto result = + std::from_chars(whatSuffix.data(), whatSuffix.data() + sepPos, code); + if (result.ec != std::errc{}) { + std::string err = "Could not parse error code from string: "; + err += whatSuffix; + throw std::runtime_error(err); + } + + return std::make_pair(code, std::string(whatSuffix.substr(sepPos + 2))); +} +} + +namespace loot { +std::exception_ptr mapError(const ::rust::Error& error) { + if (startsWith(error.what(), CYCLIC_ERROR_PREFIX)) { + return std::make_exception_ptr( + CyclicInteractionError(parseCyclicError(error.what()))); + } else if (startsWith(error.what(), UNDEFINED_GROUP_ERROR_PREFIX)) { + return std::make_exception_ptr( + UndefinedGroupError(getErrorSuffix(error.what()))); + } else if (startsWith(error.what(), ESPLUGIN_ERROR_PREFIX)) { + const auto [code, details] = parseSystemError( + std::string_view(error.what()).substr(0, ESPLUGIN_ERROR_PREFIX.size())); + + return std::make_exception_ptr( + std::system_error(code, esplugin_category(), details)); + + } else if (startsWith(error.what(), LIBLOADORDER_ERROR_PREFIX)) { + const auto [code, details] = + parseSystemError(std::string_view(error.what()) + .substr(0, LIBLOADORDER_ERROR_PREFIX.size())); + + return std::make_exception_ptr( + std::system_error(code, libloadorder_category(), details)); + } else if (startsWith(error.what(), LCI_ERROR_PREFIX)) { + const auto [code, details] = parseSystemError( + std::string_view(error.what()).substr(0, LCI_ERROR_PREFIX.size())); + + return std::make_exception_ptr(ConditionSyntaxError( + code, loot_condition_interpreter_category(), details)); + } else if (startsWith(error.what(), FILE_ACCESS_ERROR_PREFIX)) { + return std::make_exception_ptr( + FileAccessError(getErrorSuffix(error.what()))); + } else if (startsWith(error.what(), FILE_ACCESS_ERROR_PREFIX)) { + return std::make_exception_ptr( + FileAccessError(getErrorSuffix(error.what()))); + } else if (startsWith(error.what(), INVALID_ARGUMENT_PREFIX)) { + return std::make_exception_ptr( + std::invalid_argument(getErrorSuffix(error.what()))); + } else { + return std::make_exception_ptr(std::runtime_error(error.what())); + } +} +} diff --git a/cxx/src/api/exception.h b/cxx/src/api/exception.h new file mode 100644 index 00000000..2f3fa90e --- /dev/null +++ b/cxx/src/api/exception.h @@ -0,0 +1,10 @@ +#ifndef LOOT_API_EXCEPTION +#define LOOT_API_EXCEPTION + +#include "rust/cxx.h" + +namespace loot { +std::exception_ptr mapError(const ::rust::Error& error); +} + +#endif diff --git a/cxx/src/api/game.cpp b/cxx/src/api/game.cpp index 52de97bc..f09079b4 100644 --- a/cxx/src/api/game.cpp +++ b/cxx/src/api/game.cpp @@ -2,6 +2,7 @@ #include "api/game.h" #include "api/convert.h" +#include "api/exception.h" namespace { loot::GameType convert(loot::rust::GameType gameType) { @@ -66,12 +67,16 @@ rust::Box constructGame( const loot::GameType gameType, const std::filesystem::path& gamePath, const std::filesystem::path& localDataPath) { - if (localDataPath.empty()) { - return std::move( - loot::rust::new_game(convert(gameType), gamePath.u8string())); - } else { - return std::move(loot::rust::new_game_with_local_path( - convert(gameType), gamePath.u8string(), localDataPath.u8string())); + try { + if (localDataPath.empty()) { + return std::move( + loot::rust::new_game(convert(gameType), gamePath.u8string())); + } else { + return std::move(loot::rust::new_game_with_local_path( + convert(gameType), gamePath.u8string(), localDataPath.u8string())); + } + } catch (const ::rust::Error& e) { + std::rethrow_exception(loot::mapError(e)); } } @@ -92,19 +97,30 @@ Game::Game(const GameType gameType, game_(constructGame(gameType, gamePath, localDataPath)), database_(game_->database()) {} -GameType Game::GetType() const { return ::convert(game_->game_type()); } +GameType Game::GetType() const { + try { + return ::convert(game_->game_type()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} const DatabaseInterface& Game::GetDatabase() const { return database_; } DatabaseInterface& Game::GetDatabase() { return database_; } std::vector Game::GetAdditionalDataPaths() const { - std::vector paths; - for (const auto& path_str : game_->additional_data_paths()) { - paths.push_back(std::filesystem::u8path(path_str.begin(), path_str.end())); - } + try { + std::vector paths; + for (const auto& path_str : game_->additional_data_paths()) { + paths.push_back( + std::filesystem::u8path(path_str.begin(), path_str.end())); + } - return paths; + return paths; + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } void Game::SetAdditionalDataPaths( @@ -115,8 +131,12 @@ void Game::SetAdditionalDataPaths( path_strings.push_back(path.u8string()); path_strs.push_back(path_strings.back()); } - - game_->set_additional_data_paths(::rust::Slice(path_strs)); + try { + game_->set_additional_data_paths( + ::rust::Slice(path_strs)); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } bool Game::IsValidPlugin(const std::filesystem::path& pluginPath) const { @@ -132,10 +152,14 @@ void Game::LoadPlugins(const std::vector& pluginPaths, path_strs.push_back(path_strings.back()); } - if (loadHeadersOnly) { - game_->load_plugin_headers(::rust::Slice(path_strs)); - } else { - game_->load_plugins(::rust::Slice(path_strs)); + try { + if (loadHeadersOnly) { + game_->load_plugin_headers(::rust::Slice(path_strs)); + } else { + game_->load_plugins(::rust::Slice(path_strs)); + } + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); } for (const auto& path : pluginPaths) { @@ -164,10 +188,14 @@ const PluginInterface* Game::GetPlugin(const std::string& pluginName) const { return it->second.get(); } - auto plugin = std::make_shared(std::move(pluginOpt->as_ref())); - const auto result = plugins_.emplace(key, plugin); + try { + auto plugin = std::make_shared(std::move(pluginOpt->as_ref())); + const auto result = plugins_.emplace(key, plugin); - return result.first->second.get(); + return result.first->second.get(); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } std::vector Game::GetLoadedPlugins() const { @@ -188,23 +216,38 @@ std::vector Game::SortPlugins( const std::vector& pluginFilenames) { const auto strs = as_str_refs(pluginFilenames); - const auto results = - game_->sort_plugins(::rust::Slice(strs)); + try { + const auto results = game_->sort_plugins(::rust::Slice(strs)); - return convert(results); + return convert(results); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } void Game::LoadCurrentLoadOrderState() { - game_->load_current_load_order_state(); + try { + game_->load_current_load_order_state(); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } bool Game::IsLoadOrderAmbiguous() const { - return game_->is_load_order_ambiguous(); + try { + return game_->is_load_order_ambiguous(); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } std::filesystem::path Game::GetActivePluginsFilePath() const { - const auto path_string = game_->active_plugins_file_path(); - return std::filesystem::u8path(path_string.begin(), path_string.end()); + try { + const auto path_string = game_->active_plugins_file_path(); + return std::filesystem::u8path(path_string.begin(), path_string.end()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } bool Game::IsPluginActive(const std::string& pluginName) const { @@ -218,6 +261,10 @@ std::vector Game::GetLoadOrder() const { void Game::SetLoadOrder(const std::vector& loadOrder) { const auto strs = as_str_refs(loadOrder); - game_->set_load_order(::rust::Slice(strs)); + try { + game_->set_load_order(::rust::Slice(strs)); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } } diff --git a/cxx/src/api/plugin.cpp b/cxx/src/api/plugin.cpp index 5e9e17d4..ddf6c208 100644 --- a/cxx/src/api/plugin.cpp +++ b/cxx/src/api/plugin.cpp @@ -4,6 +4,7 @@ #include #include "api/convert.h" +#include "api/exception.h" namespace loot { Plugin::Plugin(::rust::Box plugin) : @@ -30,7 +31,11 @@ std::optional Plugin::GetVersion() const { } std::vector Plugin::GetMasters() const { - return convert(plugin_->masters()); + try { + return convert(plugin_->masters()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } std::vector Plugin::GetBashTags() const { @@ -64,15 +69,27 @@ bool Plugin::IsBlueprintPlugin() const { } bool Plugin::IsValidAsLightPlugin() const { - return plugin_->is_valid_as_light_plugin(); + try { + return plugin_->is_valid_as_light_plugin(); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } bool Plugin::IsValidAsMediumPlugin() const { - return plugin_->is_valid_as_medium_plugin(); + try { + return plugin_->is_valid_as_medium_plugin(); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } bool Plugin::IsValidAsUpdatePlugin() const { - return plugin_->is_valid_as_update_plugin(); + try { + return plugin_->is_valid_as_update_plugin(); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } } bool Plugin::IsEmpty() const { return plugin_->is_empty(); } @@ -86,6 +103,8 @@ bool Plugin::DoRecordsOverlap(const PluginInterface& plugin) const { return plugin_->do_records_overlap(*otherPlugin.plugin_); } catch (std::bad_cast&) { throw std::invalid_argument("Tried to check if records overlapped with a different concrete type implementing PluginInterface"); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); } } } diff --git a/cxx/src/error_codes.rs b/cxx/src/error_codes.rs new file mode 100644 index 00000000..170b0137 --- /dev/null +++ b/cxx/src/error_codes.rs @@ -0,0 +1,236 @@ +pub(crate) mod lci { + use loot_condition_interpreter::Error; + use std::ffi::c_int; + + /// Invalid arguments were given for the function. + #[unsafe(no_mangle)] + pub static LCI_ERROR_INVALID_ARGS: c_int = -1; + + /// Something went wrong while parsing the condition expression. + #[unsafe(no_mangle)] + pub static LCI_ERROR_PARSING_ERROR: c_int = -2; + + /// Something went wrong while getting the version of an executable. + #[unsafe(no_mangle)] + pub static LCI_ERROR_PE_PARSING_ERROR: c_int = -3; + + /// Some sort of I/O error occurred. + #[unsafe(no_mangle)] + pub static LCI_ERROR_IO_ERROR: c_int = -4; + + /// Something panicked. + #[unsafe(no_mangle)] + pub static LCI_ERROR_PANICKED: c_int = -5; + + /// A thread lock was poisoned. + #[unsafe(no_mangle)] + pub static LCI_ERROR_POISONED_THREAD_LOCK: c_int = -6; + + /// Failed to encode string as a C string, e.g. because there was a nul present. + #[unsafe(no_mangle)] + pub static LCI_ERROR_TEXT_ENCODE_FAIL: c_int = -7; + + /// The library encountered an error that should not have been possible to encounter. + #[unsafe(no_mangle)] + pub static LCI_ERROR_INTERNAL_LOGIC_ERROR: c_int = -8; + + pub(crate) fn map_error(err: &Error) -> c_int { + match err { + Error::ParsingIncomplete(_) => LCI_ERROR_PARSING_ERROR, + Error::UnconsumedInput(_) => LCI_ERROR_PARSING_ERROR, + Error::ParsingError(_, _) => LCI_ERROR_PARSING_ERROR, + Error::PeParsingError(_, _) => LCI_ERROR_PE_PARSING_ERROR, + Error::IoError(_, _) => LCI_ERROR_IO_ERROR, + _ => LCI_ERROR_INTERNAL_LOGIC_ERROR, + } + } +} + +pub(crate) mod libloadorder { + use loadorder::Error; + use std::ffi::c_uint; + + /// There is a mismatch between the files used to keep track of load order. + /// + /// This warning can only occur when using libloadorder with a game that uses the textfile-based + /// load order system. The load order in the active plugins list file (`plugins.txt`) does not + /// match the load order in the full load order file (`loadorder.txt`). Synchronisation between + /// the two is automatic when load order is managed through libloadorder. It is left to the client + /// to decide how best to restore synchronisation. + #[unsafe(no_mangle)] + pub static LIBLO_WARN_LO_MISMATCH: c_uint = 2; + + /// The specified file could not be found. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_FILE_NOT_FOUND: c_uint = 6; + + /// A file could not be renamed. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_FILE_RENAME_FAIL: c_uint = 7; + + /// There was an error parsing a plugin file. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_FILE_PARSE_FAIL: c_uint = 10; + + /// Invalid arguments were given for the function. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_INVALID_ARGS: c_uint = 12; + + /// A thread lock was poisoned. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_POISONED_THREAD_LOCK: c_uint = 14; + + /// An unknown I/O error occurred. This is used when the I/O error kind doesn't fit another error + /// code. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_IO_ERROR: c_uint = 15; + + /// Permission denied while trying to access a filesystem path. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_IO_PERMISSION_DENIED: c_uint = 16; + + /// A plugin filename contains characters that do not have Windows-1252 code points, or a character + /// string contains a null character. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_TEXT_ENCODE_FAIL: c_uint = 17; + + /// Text expected to be encoded in Windows-1252 could not be decoded to UTF-8. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_TEXT_DECODE_FAIL: c_uint = 18; + + /// The library encountered an error that should not have been possible to encounter. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_INTERNAL_LOGIC_ERROR: c_uint = 19; + + /// Something panicked. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_PANICKED: c_uint = 20; + + /// A path cannot be encoded in UTF-8. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_PATH_ENCODE_FAIL: c_uint = 21; + + /// An unknown operating system error occurred. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_SYSTEM_ERROR: c_uint = 22; + + /// A system path definition (e.g. for local app data on Windows, or $HOME on Linux) could not be + /// found. + #[unsafe(no_mangle)] + pub static LIBLO_ERROR_NO_PATH: c_uint = 23; + + /// Matches the value of the highest-numbered return code. + /// + /// Provided in case clients wish to incorporate additional return codes in their implementation + /// and desire some method of avoiding value conflicts. + #[unsafe(no_mangle)] + pub static LIBLO_RETURN_MAX: c_uint = 23; + + fn map_io_error(err: &std::io::Error) -> c_uint { + use std::io::ErrorKind::*; + match err.kind() { + NotFound => LIBLO_ERROR_FILE_NOT_FOUND, + AlreadyExists => LIBLO_ERROR_FILE_RENAME_FAIL, + PermissionDenied => LIBLO_ERROR_IO_PERMISSION_DENIED, + _ => LIBLO_ERROR_IO_ERROR, + } + } + + pub(crate) fn map_error(err: &Error) -> c_uint { + use Error::*; + match *err { + InvalidPath(_) => LIBLO_ERROR_FILE_NOT_FOUND, + IoError(_, ref x) => map_io_error(x), + NoFilename(_) => LIBLO_ERROR_FILE_PARSE_FAIL, + DecodeError(_) => LIBLO_ERROR_TEXT_DECODE_FAIL, + EncodeError(_) => LIBLO_ERROR_TEXT_ENCODE_FAIL, + PluginParsingError(_, _) => LIBLO_ERROR_FILE_PARSE_FAIL, + PluginNotFound(_) => LIBLO_ERROR_INVALID_ARGS, + TooManyActivePlugins { .. } => LIBLO_ERROR_INVALID_ARGS, + DuplicatePlugin(_) => LIBLO_ERROR_INVALID_ARGS, + NonMasterBeforeMaster { .. } => LIBLO_ERROR_INVALID_ARGS, + InvalidEarlyLoadingPluginPosition { .. } => LIBLO_ERROR_INVALID_ARGS, + ImplicitlyActivePlugin(_) => LIBLO_ERROR_INVALID_ARGS, + NoLocalAppData => LIBLO_ERROR_INVALID_ARGS, + NoDocumentsPath => LIBLO_ERROR_INVALID_ARGS, + NoUserConfigPath => LIBLO_ERROR_NO_PATH, + NoUserDataPath => LIBLO_ERROR_NO_PATH, + NoProgramFilesPath => LIBLO_ERROR_NO_PATH, + UnrepresentedHoist { .. } => LIBLO_ERROR_INVALID_ARGS, + InstalledPlugin(_) => LIBLO_ERROR_INVALID_ARGS, + IniParsingError { .. } => LIBLO_ERROR_FILE_PARSE_FAIL, + VdfParsingError(_, _) => LIBLO_ERROR_FILE_PARSE_FAIL, + SystemError(_, _) => LIBLO_ERROR_SYSTEM_ERROR, + InvalidBlueprintPluginPosition { .. } => LIBLO_ERROR_INVALID_ARGS, + _ => LIBLO_ERROR_INTERNAL_LOGIC_ERROR, + } + } +} + +pub(crate) mod esplugin { + use std::ffi::c_uint; + + use esplugin::Error; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_NULL_POINTER: u32 = 1; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_NOT_UTF8: u32 = 2; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_STRING_CONTAINS_NUL: u32 = 3; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_INVALID_GAME_ID: u32 = 4; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_PARSE_ERROR: u32 = 5; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_PANICKED: u32 = 6; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_NO_FILENAME: u32 = 7; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_TEXT_DECODE_ERROR: u32 = 8; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_TEXT_ENCODE_ERROR: u32 = 9; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_IO_ERROR: u32 = 10; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_FILE_NOT_FOUND: u32 = 11; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_IO_PERMISSION_DENIED: u32 = 12; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_UNRESOLVED_RECORD_IDS: u32 = 13; + + #[unsafe(no_mangle)] + pub static ESP_ERROR_PLUGIN_METADATA_NOT_FOUND: u32 = 14; + + fn map_io_error(err: &std::io::Error) -> c_uint { + match err.kind() { + std::io::ErrorKind::NotFound => ESP_ERROR_FILE_NOT_FOUND, + std::io::ErrorKind::PermissionDenied => ESP_ERROR_IO_PERMISSION_DENIED, + _ => ESP_ERROR_IO_ERROR, + } + } + + pub(crate) fn map_error(err: &Error) -> c_uint { + match *err { + Error::IoError(ref x) => map_io_error(x), + Error::NoFilename(_) => ESP_ERROR_NO_FILENAME, + Error::ParsingIncomplete(_) => ESP_ERROR_PARSE_ERROR, + Error::ParsingError(_, _) => ESP_ERROR_PARSE_ERROR, + Error::DecodeError(_) => ESP_ERROR_TEXT_DECODE_ERROR, + Error::UnresolvedRecordIds(_) => ESP_ERROR_UNRESOLVED_RECORD_IDS, + Error::PluginMetadataNotFound(_) => ESP_ERROR_PLUGIN_METADATA_NOT_FOUND, + } + } +} diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index e52a8e9c..56d97ea3 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -1,10 +1,11 @@ mod database; +mod error_codes; mod game; mod metadata; mod plugin; use database::{Database, Vertex, new_vertex}; -use game::{Game, new_game, new_game_with_local_path}; +use game::{Game, NotValidUtf8, new_game, new_game_with_local_path}; use metadata::{ File, Filename, Group, Location, Message, MessageContent, OptionalMessageContentRef, OptionalPluginMetadata, PluginCleaningData, PluginMetadata, Tag, group_default_name, @@ -14,32 +15,217 @@ use metadata::{ }; use plugin::{OptionalPluginRef, PluginRef}; use std::{ - ffi::{c_char, c_uchar, c_uint, c_void, CString}, - sync::{atomic::AtomicPtr, Mutex}, + error::Error, + ffi::{CString, c_char, c_uchar, c_uint, c_void}, + sync::{Mutex, atomic::AtomicPtr}, }; use unicase::UniCase; -use libloot::set_logging_callback; +use libloot::{ + error::{ + ConditionEvaluationError, DatabaseLockPoisonError, GameHandleCreationError, + GroupsPathError, LoadOrderError, LoadOrderStateError, LoadPluginsError, + MetadataRetrievalError, PluginDataError, SortPluginsError, + }, + metadata::error::{ + LoadMetadataError, MultilingualMessageContentsError, RegexError, WriteMetadataError, + }, + set_logging_callback, +}; pub use libloot::{is_compatible, libloot_revision, libloot_version}; #[derive(Debug)] -pub struct VerboseError(Box); +pub enum VerboseError { + CyclicInteractionError(Vec), + UndefinedGroupError(String), + EspluginError(u32, String), + LibloadorderError(u32, String), + LciError(i32, String), + FileAccessError(String), + InvalidArgument(String), + Other(Box), +} impl std::fmt::Display for VerboseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0)?; - let mut error = self.0.as_ref(); - while let Some(source) = error.source() { - write!(f, ": {}", source)?; - error = source; + match self { + Self::CyclicInteractionError(cycle) => { + write!(f, "CyclicInteractionError: ")?; + for vertex in cycle { + let name = vertex.name().replace("\\", "\\\\").replace("-", "\\-"); + match vertex.out_edge_type() { + Some(e) => write!(f, "{}--{}--", name, e)?, + None => write!(f, "{}", name)?, + } + } + Ok(()) + } + Self::UndefinedGroupError(group) => { + write!(f, "UndefinedGroupError: {}", group) + } + Self::EspluginError(c, s) => { + write!(f, "EspluginError: {}: {}", c, s) + } + Self::LibloadorderError(c, s) => { + write!(f, "LibloadorderError: {}: {}", c, s) + } + Self::LciError(c, s) => { + write!(f, "LciError: {}: {}", c, s) + } + Self::FileAccessError(s) => write!(f, "FileAccessError: {}", s), + Self::InvalidArgument(s) => write!(f, "InvalidArgument: {}", s), + Self::Other(e) => { + write!(f, "{}", e)?; + let mut error = e.as_ref(); + while let Some(source) = error.source() { + write!(f, ": {}", source)?; + error = source; + } + Ok(()) + } } - Ok(()) } } -impl From for VerboseError { - fn from(value: E) -> Self { - VerboseError(Box::new(value)) +impl From for VerboseError { + fn from(value: GameHandleCreationError) -> Self { + match value { + GameHandleCreationError::LoadOrderError(e) => e.into(), + GameHandleCreationError::NotADirectory(_) => { + Self::InvalidArgument(value.to_string()) + } + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: UnsupportedEnumValueError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: NotValidUtf8) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: DatabaseLockPoisonError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: LoadPluginsError) -> Self { + match value { + LoadPluginsError::PluginDataError(e) => e.into(), + LoadPluginsError::PluginValidationError(_) => { + Self::InvalidArgument(value.to_string()) + } + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: SortPluginsError) -> Self { + match value { + SortPluginsError::MetadataRetrievalError(e) => e.into(), + SortPluginsError::UndefinedGroup(g) => Self::UndefinedGroupError(g), + SortPluginsError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), + SortPluginsError::PluginDataError(e) => e.into(), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: LoadOrderStateError) -> Self { + match value { + LoadOrderStateError::LoadOrderError(e) => e.into(), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: LoadOrderError) -> Self { + let error = value + .source() + .expect("LoadOrderError has source") + .downcast_ref::() + .expect("LoadOrderError source is a loadorder::Error"); + let error_code = error_codes::libloadorder::map_error(error); + Self::LibloadorderError(error_code, error.to_string()) + } +} + +impl From for VerboseError { + fn from(value: LoadMetadataError) -> Self { + Self::FileAccessError(value.to_string()) + } +} + +impl From for VerboseError { + fn from(value: WriteMetadataError) -> Self { + Self::FileAccessError(value.to_string()) + } +} + +impl From for VerboseError { + fn from(value: ConditionEvaluationError) -> Self { + let error = value + .source() + .expect("LoadOrderError has source") + .downcast_ref::() + .expect("LoadOrderError source is a loot_condition_interpreter::Error"); + let error_code = error_codes::lci::map_error(error); + Self::LciError(error_code, error.to_string()) + } +} + +impl From for VerboseError { + fn from(value: GroupsPathError) -> Self { + match value { + GroupsPathError::UndefinedGroup(g) => Self::UndefinedGroupError(g), + GroupsPathError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: MetadataRetrievalError) -> Self { + match value { + MetadataRetrievalError::ConditionEvaluationError(e) => e.into(), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: PluginDataError) -> Self { + let error = value + .source() + .expect("LoadOrderError has source") + .downcast_ref::() + .expect("LoadOrderError source is an esplugin::Error"); + let error_code = error_codes::esplugin::map_error(error); + Self::EspluginError(error_code, error.to_string()) + } +} + +impl From for VerboseError { + fn from(value: MultilingualMessageContentsError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: RegexError) -> Self { + Self::Other(Box::new(value)) } } From 4f53341950fe353f3e38114176e8905c9577cdbd Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 16 Mar 2025 10:36:25 +0000 Subject: [PATCH 034/206] Sync with libloot v0.25.5 --- Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 2 +- cxx/Cargo.toml | 2 +- cxx/README.md | 4 +- cxx/include/loot/api.h | 9 ++ cxx/include/loot/loot_version.h | 2 +- cxx/src/api/api.cpp | 23 +++++ cxx/src/api/resource.rc | 8 +- cxx/src/lib.rs | 40 ++++++-- cxx/src/tests/api/internals/main.cpp | 2 +- src/archive/find.rs | 2 +- src/game.rs | 61 ++++++++++-- src/lib.rs | 2 +- src/logging.rs | 138 ++++++++++++++++++++++++--- src/plugin/mod.rs | 12 +-- src/sorting/groups.rs | 5 +- src/sorting/plugins.rs | 129 +++++++++++++++++++++---- 18 files changed, 377 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5bea9336..b94dabfc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -562,7 +562,7 @@ dependencies = [ [[package]] name = "libloot" -version = "0.25.3" +version = "0.25.5" dependencies = [ "crc32fast", "esplugin", @@ -583,7 +583,7 @@ dependencies = [ [[package]] name = "libloot-cxx" -version = "0.25.3" +version = "0.25.5" dependencies = [ "cxx", "cxx-build", diff --git a/Cargo.toml b/Cargo.toml index cb2eb6ec..06d9c37a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot" -version = "0.25.3" +version = "0.25.5" edition = "2024" license = "GPL-3.0" diff --git a/README.md b/README.md index a511ae5b..3a0b2e7c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Currently complete: - [x] C++ FFI - [ ] Python FFI -The complete bits should match libloot commit [55b341fc6cbdccee52e42923c13a91eddb5ca97d](https://github.com/loot/libloot/commit/55b341fc6cbdccee52e42923c13a91eddb5ca97d), which is libloot v0.25.3 plus a few changes prompted by this translation. +The complete bits should match libloot v0.25.5. ## Build diff --git a/cxx/Cargo.toml b/cxx/Cargo.toml index 0a81b884..9e3bfc32 100644 --- a/cxx/Cargo.toml +++ b/cxx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot-cxx" -version = "0.25.3" +version = "0.25.5" edition = "2024" license = "GPL-3.0" diff --git a/cxx/README.md b/cxx/README.md index 494e016c..86821ad0 100644 --- a/cxx/README.md +++ b/cxx/README.md @@ -1,6 +1,6 @@ # libloot-rs C++ wrapper -This is an **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface that's ABI-compatible with libloot v0.25.3. +This is an **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface that's ABI-compatible with libloot v0.25.5. ## Building @@ -17,7 +17,7 @@ cmake -B build . cmake --build build --config RelWithDebInfo ``` -This also builds a copy of the public API tests from C++ libloot v0.25.3, which can be run using: +This also builds a copy of the public API tests from C++ libloot v0.25.5, which can be run using: ``` ctest --test-dir build --output-on-failure -V diff --git a/cxx/include/loot/api.h b/cxx/include/loot/api.h index 33dd52ec..80c7095b 100644 --- a/cxx/include/loot/api.h +++ b/cxx/include/loot/api.h @@ -59,6 +59,15 @@ namespace loot { LOOT_API void SetLoggingCallback( std::function callback); +/** + * @brief Set the log severity level. + * @details The default level setting is trace. This function has no effect if + * no logging callback has been set. + * @param level + * Messages of this severity level and higher will be logged. + */ +LOOT_API void SetLogLevel(LogLevel level); + /** * @} * @name Version Functions diff --git a/cxx/include/loot/loot_version.h b/cxx/include/loot/loot_version.h index 65e8916d..f39f504b 100644 --- a/cxx/include/loot/loot_version.h +++ b/cxx/include/loot/loot_version.h @@ -37,7 +37,7 @@ inline constexpr unsigned int LIBLOOT_VERSION_MAJOR = 0; inline constexpr unsigned int LIBLOOT_VERSION_MINOR = 25; /** @brief libloot's patch version number. */ -inline constexpr unsigned int LIBLOOT_VERSION_PATCH = 3; +inline constexpr unsigned int LIBLOOT_VERSION_PATCH = 5; /** * @brief Get the library version. diff --git a/cxx/src/api/api.cpp b/cxx/src/api/api.cpp index 85d2f748..cc7e32bd 100644 --- a/cxx/src/api/api.cpp +++ b/cxx/src/api/api.cpp @@ -50,6 +50,25 @@ LogLevel convert(uint8_t level) { } } +loot::rust::LogLevel convert(LogLevel level) { + switch (level) { + case LogLevel::trace: + return loot::rust::LogLevel::Trace; + case LogLevel::debug: + return loot::rust::LogLevel::Debug; + case LogLevel::info: + return loot::rust::LogLevel::Info; + case LogLevel::warning: + return loot::rust::LogLevel::Warning; + case LogLevel::error: + return loot::rust::LogLevel::Error; + case LogLevel::fatal: + return loot::rust::LogLevel::Fatal; + default: + return loot::rust::LogLevel::Trace; + } +} + void logging_callback(uint8_t level, const char* message, void* context) { auto callbackPtr = static_cast*>(context); @@ -65,6 +84,10 @@ LOOT_API void SetLoggingCallback( libloot_set_logging_callback(logging_callback, &STORED_CALLBACK); } +LOOT_API void SetLogLevel(LogLevel level) { + loot::rust::set_log_level(convert(level)); +} + LOOT_API bool IsCompatible(const unsigned int versionMajor, const unsigned int versionMinor, const unsigned int versionPatch) { diff --git a/cxx/src/api/resource.rc b/cxx/src/api/resource.rc index 3332aba2..80d6b3bd 100644 --- a/cxx/src/api/resource.rc +++ b/cxx/src/api/resource.rc @@ -2,8 +2,8 @@ #include 1 VERSIONINFO -FILEVERSION 0, 25, 3, 0 -PRODUCTVERSION 0, 25, 3, 0 +FILEVERSION 0, 25, 5, 0 +PRODUCTVERSION 0, 25, 5, 0 FILEOS VOS__WINDOWS32 FILETYPE VFT_DLL BEGIN @@ -13,12 +13,12 @@ BLOCK "040904b0" BEGIN VALUE "CompanyName", "LOOT" VALUE "FileDescription", "Library providing LOOT's core functionality" -VALUE "FileVersion", "0.25.3" +VALUE "FileVersion", "0.25.5" VALUE "InternalName", "loot" VALUE "LegalCopyright", "Copyright (C) 2013-2022 Oliver Hamlet" VALUE "OriginalFilename", "loot.dll" VALUE "ProductName", "LOOT" -VALUE "ProductVersion", "0.25.3" +VALUE "ProductVersion", "0.25.5" END END BLOCK "VarFileInfo" diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index 56d97ea3..ad23b29b 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -91,9 +91,7 @@ impl From for VerboseError { fn from(value: GameHandleCreationError) -> Self { match value { GameHandleCreationError::LoadOrderError(e) => e.into(), - GameHandleCreationError::NotADirectory(_) => { - Self::InvalidArgument(value.to_string()) - } + GameHandleCreationError::NotADirectory(_) => Self::InvalidArgument(value.to_string()), _ => Self::Other(Box::new(value)), } } @@ -121,9 +119,7 @@ impl From for VerboseError { fn from(value: LoadPluginsError) -> Self { match value { LoadPluginsError::PluginDataError(e) => e.into(), - LoadPluginsError::PluginValidationError(_) => { - Self::InvalidArgument(value.to_string()) - } + LoadPluginsError::PluginValidationError(_) => Self::InvalidArgument(value.to_string()), _ => Self::Other(Box::new(value)), } } @@ -293,6 +289,27 @@ fn compare_filenames(lhs: &str, rhs: &str) -> i8 { } } +fn set_log_level(level: ffi::LogLevel) -> Result<(), VerboseError> { + libloot::set_log_level(level.try_into()?); + Ok(()) +} + +impl TryFrom for libloot::LogLevel { + type Error = UnsupportedEnumValueError; + + fn try_from(value: ffi::LogLevel) -> Result { + match value { + ffi::LogLevel::Trace => Ok(libloot::LogLevel::Trace), + ffi::LogLevel::Debug => Ok(libloot::LogLevel::Debug), + ffi::LogLevel::Info => Ok(libloot::LogLevel::Info), + ffi::LogLevel::Warning => Ok(libloot::LogLevel::Warning), + ffi::LogLevel::Error => Ok(libloot::LogLevel::Error), + ffi::LogLevel::Fatal => Ok(libloot::LogLevel::Fatal), + _ => Err(UnsupportedEnumValueError), + } + } +} + #[allow(clippy::needless_lifetimes)] #[cxx::bridge(namespace = "loot::rust")] mod ffi { @@ -338,7 +355,18 @@ mod ffi { blueprintMaster, } + pub enum LogLevel { + Trace, + Debug, + Info, + Warning, + Error, + Fatal, + } + extern "Rust" { + fn set_log_level(level: LogLevel) -> Result<()>; + fn is_compatible(major: u32, minor: u32, patch: u32) -> bool; fn libloot_version() -> String; fn libloot_revision() -> String; diff --git a/cxx/src/tests/api/internals/main.cpp b/cxx/src/tests/api/internals/main.cpp index eb94b22b..e05ed9be 100644 --- a/cxx/src/tests/api/internals/main.cpp +++ b/cxx/src/tests/api/internals/main.cpp @@ -19,7 +19,7 @@ namespace loot::rust { TEST(libloot_version, shouldReturnExpectedValue) { auto version = libloot_version(); - EXPECT_EQ(version, "0.25.3"); + EXPECT_EQ(version, "0.25.5"); } TEST(libloot_revision, shouldReturnExpectedValue) { diff --git a/src/archive/find.rs b/src/archive/find.rs index c54d7044..aa8aea3e 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -95,7 +95,7 @@ fn find_associated_archives_with_arbitrary_suffixes( }; game_cache - .archives() + .archives_iter() .filter(|path| { // Need to check if it starts with the given plugin's basename, // but case insensitively. This is hard to do accurately, so diff --git a/src/game.rs b/src/game.rs index 9ae6dd85..0de70510 100644 --- a/src/game.rs +++ b/src/game.rs @@ -9,12 +9,13 @@ use loadorder::WritableLoadOrder; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use crate::{ + LogLevel, database::Database, error::{ DatabaseLockPoisonError, GameHandleCreationError, LoadOrderError, LoadOrderStateError, LoadPluginsError, SortPluginsError, }, - logging::{self, format_details}, + logging::{self, format_details, is_log_enabled}, metadata::{ Filename, plugin_metadata::{GHOST_FILE_EXTENSION, iends_with_ascii}, @@ -330,7 +331,20 @@ impl Game { self.game_type, GameType::TES3 | GameType::OpenMW | GameType::Starfield ) { - let plugins_metadata = plugins_metadata(&plugins)?; + let mut loaded_plugins: HashMap = self + .cache + .plugins() + .iter() + .map(|(k, v)| (k.clone(), v)) + .collect(); + + for plugin in &plugins { + loaded_plugins.insert(Filename::new(plugin.name().to_string()), plugin); + } + + let loaded_plugins: Vec<_> = loaded_plugins.into_values().collect(); + + let plugins_metadata = plugins_metadata(&loaded_plugins)?; for plugin in &mut plugins { plugin.resolve_record_ids(&plugins_metadata)?; @@ -393,7 +407,7 @@ impl Game { let mut database = self.database.write()?; update_loaded_plugin_state( database.condition_evaluator_state_mut(), - self.cache.plugins(), + self.cache.plugins_iter(), ); Ok(()) @@ -412,7 +426,7 @@ impl Game { /// Get data for all loaded plugins. pub fn loaded_plugins(&self) -> Vec<&Plugin> { - self.cache.plugins().collect() + self.cache.plugins_iter().collect() } /// Calculates a new load order for the game's installed plugins (including @@ -453,7 +467,7 @@ impl Game { }) .collect::, _>>()?; - if log::log_enabled!(log::Level::Debug) { + if is_log_enabled(LogLevel::Debug) { logging::debug!("Current load order:"); for plugin_name in plugin_names { logging::debug!("\t{}", plugin_name); @@ -468,7 +482,7 @@ impl Game { self.load_order.game_settings().early_loading_plugins(), )?; - if log::log_enabled!(log::Level::Debug) { + if is_log_enabled(LogLevel::Debug) { logging::debug!("Sorted load order:"); for plugin_name in &new_load_order { logging::debug!("\t{}", plugin_name); @@ -755,7 +769,11 @@ impl GameCache { self.plugins.clear(); } - fn plugins(&self) -> impl Iterator { + fn plugins(&self) -> &HashMap { + &self.plugins + } + + fn plugins_iter(&self) -> impl Iterator { self.plugins.values() } @@ -763,7 +781,7 @@ impl GameCache { self.plugins.get(&Filename::new(plugin_name.to_string())) } - pub fn archives(&self) -> impl Iterator { + pub fn archives_iter(&self) -> impl Iterator { self.archive_paths.iter() } } @@ -1627,6 +1645,33 @@ mod tests { assert!(game.plugin(BLANK_MASTER_DEPENDENT_ESM).is_some()); } + + #[apply(all_game_types)] + fn should_not_error_if_loading_a_plugin_with_a_master_that_is_already_loaded_if_game_is_morrowind_or_starfield( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let master = if game_type == GameType::Starfield { + BLANK_FULL_ESM + } else { + BLANK_ESM + }; + + game.load_plugins(&[Path::new(master)]).unwrap(); + + game.load_plugins(&[Path::new(BLANK_MASTER_DEPENDENT_ESM)]) + .unwrap(); + + assert!(game.plugin(BLANK_MASTER_DEPENDENT_ESM).is_some()); + } } mod load_plugins_common { diff --git a/src/lib.rs b/src/lib.rs index f63797af..a53caf07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,7 @@ mod version; pub use database::Database; pub use game::{Game, GameType}; -pub use logging::{LogLevel, set_logging_callback}; +pub use logging::{LogLevel, set_log_level, set_logging_callback}; pub use plugin::Plugin; pub use sorting::vertex::{EdgeType, Vertex}; pub use version::{ diff --git a/src/logging.rs b/src/logging.rs index 87776fb7..db752298 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -2,8 +2,8 @@ use std::sync::{LazyLock, RwLock}; type Callback = dyn Fn(LogLevel, &str) + Send + Sync; -pub(crate) static LOGGER: LazyLock>> = - LazyLock::new(|| RwLock::new(Box::new(|_, _| {}))); +pub(crate) static LOGGER: LazyLock> = + LazyLock::new(|| RwLock::new(Logger::new(Box::new(|_, _| {})))); /// Set the callback function that is called when logging. /// @@ -16,9 +16,22 @@ where let boxed = Box::new(callback); match LOGGER.write() { - Ok(mut logger) => *logger = boxed, + Ok(mut logger) => logger.set_callback(boxed), Err(e) => { - *e.into_inner() = boxed; + e.into_inner().set_callback(boxed); + LOGGER.clear_poison(); + } + } +} + +// Set the log severity level. +// +// The default level setting is trace. This function has no effect if no logging callback has been set. +pub fn set_log_level(level: LogLevel) { + match LOGGER.write() { + Ok(mut logger) => logger.set_level(level), + Err(e) => { + e.into_inner().set_level(level); LOGGER.clear_poison(); } } @@ -61,14 +74,52 @@ impl From for log::Level { } } +pub(crate) struct Logger { + callback: Box, + level: LogLevel, +} + +impl Logger { + fn new(callback: Box) -> Self { + Self { + callback, + level: LogLevel::Trace, + } + } + + pub(crate) fn log(&self, level: LogLevel, message: &str) { + if level >= self.level { + (self.callback)(level, message) + } + } + + fn level(&self) -> LogLevel { + self.level + } + + fn set_callback(&mut self, callback: Box) { + self.callback = callback; + } + + fn set_level(&mut self, level: LogLevel) { + self.level = level; + } +} + macro_rules! log { ($level:expr, $($arg:tt)+) => { // Log using the Rust log crate, as it's probably good to support that. log::log!(log::Level::from($level), $($arg)+); // Also log using the callback. - if let Ok(logger) = $crate::logging::LOGGER.read() { - logger($level, &std::fmt::format(format_args!($($arg)+))); + let message = std::fmt::format(format_args!($($arg)+)); + + match $crate::logging::LOGGER.read() { + Ok(logger) => logger.log($level, &message), + Err(e) => { + $crate::logging::LOGGER.clear_poison(); + e.into_inner().log($level, &message); + } } }; } @@ -93,6 +144,22 @@ macro_rules! trace { ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Trace, $($arg)+) }; } +pub fn is_log_enabled(level: LogLevel) -> bool { + if log::log_enabled!(level.into()) { + return true; + } + + let logger = match LOGGER.read() { + Ok(logger) => logger, + Err(e) => { + LOGGER.clear_poison(); + e.into_inner() + } + }; + + level >= logger.level() +} + pub(crate) use {debug, error, info, log, trace, warning as warn}; pub(crate) fn format_details(error: &E) -> String { @@ -109,16 +176,16 @@ pub(crate) fn format_details(error: &E) -> String { mod tests { use super::*; + use std::sync::{Arc, LazyLock, Mutex}; + + // Since the callback is a global object, these tests need to be run in + // series so that one doesn't switch out the callback between another + // doing the same and trying to use it. + static TEST_LOCK: Mutex<()> = Mutex::new(()); + mod set_logging_callback { use super::*; - use std::sync::{Arc, LazyLock, Mutex}; - - // Since the callback is a global object, these tests need to be run in - // series so that one doesn't switch out the callback between another - // doing the same and trying to use it. - static TEST_LOCK: Mutex<()> = Mutex::new(()); - #[test] fn should_support_a_function() { let _lock = TEST_LOCK.lock().unwrap(); @@ -207,4 +274,49 @@ mod tests { ); } } + + mod set_log_level { + use super::*; + + #[test] + fn should_set_the_level_used_to_filter_messages_passed_to_the_callback() { + let _lock = TEST_LOCK.lock().unwrap(); + + static MESSAGES: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + + fn callback(level: LogLevel, message: &str) { + if let Ok(mut messages) = MESSAGES.lock() { + messages.push((level, message.to_string())); + } + } + + set_logging_callback(callback); + set_log_level(LogLevel::Warning); + + error!("Test error"); + info!("Test info"); + + assert_eq!( + vec![(LogLevel::Error, "Test error".into())], + *MESSAGES.lock().unwrap() + ); + } + } + + mod is_log_enabled { + use super::*; + + #[test] + fn should_return_true_iff_log_level_is_less_than_or_equal_to_given_level() { + set_log_level(LogLevel::Warning); + + assert!(!is_log_enabled(LogLevel::Trace)); + assert!(!is_log_enabled(LogLevel::Debug)); + assert!(!is_log_enabled(LogLevel::Info)); + assert!(is_log_enabled(LogLevel::Warning)); + assert!(is_log_enabled(LogLevel::Error)); + assert!(is_log_enabled(LogLevel::Fatal)); + } + } } diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index c81752e0..c3378d74 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -356,7 +356,7 @@ pub(crate) fn has_ascii_extension(path: &Path, extension: &str) -> bool { } pub(crate) fn plugins_metadata( - plugins: &[Plugin], + plugins: &[&Plugin], ) -> Result, PluginDataError> { let esplugins: Vec<_> = plugins.iter().filter_map(|p| p.plugin.as_ref()).collect(); Ok(esplugin::plugins_metadata(&esplugins)?) @@ -646,7 +646,7 @@ mod tests { ) .unwrap(); - let metadata = plugins_metadata(&[master]).unwrap(); + let metadata = plugins_metadata(&[&master]).unwrap(); plugin.resolve_record_ids(&metadata).unwrap(); @@ -660,7 +660,7 @@ mod tests { ) .unwrap(); - let metadata = plugins_metadata(&[master]).unwrap(); + let metadata = plugins_metadata(&[&master]).unwrap(); plugin.resolve_record_ids(&metadata).unwrap(); @@ -947,7 +947,7 @@ mod tests { ) .unwrap(); - let metadata = plugins_metadata(&[master]).unwrap(); + let metadata = plugins_metadata(&[&master]).unwrap(); plugin.resolve_record_ids(&metadata).unwrap(); } @@ -990,7 +990,7 @@ mod tests { ) .unwrap(); - let metadata = plugins_metadata(&[master]).unwrap(); + let metadata = plugins_metadata(&[&master]).unwrap(); plugin.resolve_record_ids(&metadata).unwrap(); } @@ -1027,7 +1027,7 @@ mod tests { ) .unwrap(); - let metadata = plugins_metadata(&[master]).unwrap(); + let metadata = plugins_metadata(&[&master]).unwrap(); plugin.resolve_record_ids(&metadata).unwrap(); } diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs index 373db7cc..09dec17d 100644 --- a/src/sorting/groups.rs +++ b/src/sorting/groups.rs @@ -3,7 +3,8 @@ use std::{cmp::Reverse, collections::HashMap}; use petgraph::{Graph, algo::bellman_ford, graph::NodeIndex}; use crate::{ - EdgeType, Vertex, logging, + EdgeType, LogLevel, Vertex, + logging::{self, is_log_enabled}, metadata::Group, sorting::{ dfs::find_cycle, @@ -75,7 +76,7 @@ fn add_groups<'a>( } for group in groups { - if log::log_enabled!(log::Level::Trace) { + if is_log_enabled(LogLevel::Trace) { logging::trace!( "Group \"{}\" directly loads after groups \"{}\"", group.name(), diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 856aad01..c2c408aa 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -3,7 +3,6 @@ use std::{ rc::Rc, }; -use log::log_enabled; use petgraph::{ Graph, graph::{EdgeReference, NodeIndex}, @@ -11,7 +10,8 @@ use petgraph::{ }; use crate::{ - EdgeType, Plugin, logging, + EdgeType, LogLevel, Plugin, + logging::{self, is_log_enabled}, metadata::{File, Group, PluginMetadata}, plugin::error::PluginDataError, sorting::{ @@ -559,7 +559,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { // Record the path as the start of the new load order. // Don't need to add any edges because there's nothing for nextVertex // to load after at this point. - if log_enabled!(log::Level::Debug) { + if is_log_enabled(LogLevel::Debug) { logging::debug!( "The path ends with the first plugin checked, treating the following path as the start of the load order: {}", path_to_string(&self.inner, &path_from_next_node) @@ -662,7 +662,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { new_load_order.insert(insert_position, node_index); processed_nodes.insert(node_index); - if log_enabled!(log::Level::Debug) { + if is_log_enabled(LogLevel::Debug) { if let Some(next_node_index) = new_load_order.get(insert_position + 1) { logging::debug!( "The plugin \"{}\" loads before \"{}\" in the new load order.", @@ -993,6 +993,17 @@ fn get_plugins_in_groups( plugins_in_groups.entry(group_name).or_default().push(node); } + if is_log_enabled(LogLevel::Debug) { + logging::debug!("Found the following plugins in groups:"); + for (key, value) in &plugins_in_groups { + let plugin_names: Vec<_> = value + .iter() + .map(|i| format!("\"{}\"", graph[*i].name())) + .collect(); + logging::debug!("\t{}: {}", key, plugin_names.join(", ")); + } + } + plugins_in_groups } @@ -1099,22 +1110,23 @@ impl<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, for to_plugin in to_plugins { if !self.plugins_graph.is_path_cached(from_plugin, *to_plugin) { + let involves_user_metadata = path_involves_user_metadata + || self.plugins_graph[from_plugin].group_is_user_metadata + || self.plugins_graph[*to_plugin].group_is_user_metadata; + + let edge_type = if involves_user_metadata { + EdgeType::UserGroup + } else { + EdgeType::MasterlistGroup + }; + if !self.plugins_graph.path_exists(*to_plugin, from_plugin) { - let involves_user_metadata = path_involves_user_metadata - || self.plugins_graph[from_plugin].group_is_user_metadata - || self.plugins_graph[*to_plugin].group_is_user_metadata; - - let edge_type = if involves_user_metadata { - EdgeType::UserGroup - } else { - EdgeType::MasterlistGroup - }; - self.plugins_graph .add_edge(from_plugin, *to_plugin, edge_type); } else { logging::debug!( - "Skipping group edge from \"{}\" to \"{}\" as it would create a cycle.", + "Skipping a \"{}\" edge from \"{}\" to \"{}\" as it would create a cycle.", + edge_type, self.plugins_graph[from_plugin].name(), self.plugins_graph[*to_plugin].name() ); @@ -1153,10 +1165,30 @@ impl<'e, T: SortingPlugin> DfsVisitor<'e> for GroupsPathVisitor<'_, '_, '_, '_, } fn visit_forward_or_cross_edge(&mut self, edge_ref: EdgeReference<'e, EdgeType>) { - // Mark the source vertex as unfinishable, because none of the plugins in - // in the path so far can have edges added to plugins past the target - // vertex. - self.unfinishable_nodes.insert(edge_ref.source()); + // Mark the source vertex and all edges in the current stack as + // unfinishable, because none of the plugins in the path so far can have + // edges added to plugins past the target vertex. + + logging::debug!( + "Found groups graph forward or cross \"{}\" edge going from \"{}\" to \"{}\"", + edge_ref.weight(), + self.groups_graph[edge_ref.source()], + self.groups_graph[edge_ref.target()] + ); + + let iter = self + .edge_stack + .iter() + .map(|e| e.0.source()) + .chain(std::iter::once(edge_ref.source())); + + for source in iter { + let inserted = self.unfinishable_nodes.insert(source); + + if inserted { + logging::debug!("Treating \"{}\" as unfinishable", self.groups_graph[source]); + } + } } fn visit_back_edge(&mut self, _: EdgeReference<'e, EdgeType>) {} @@ -1170,7 +1202,13 @@ impl<'e, T: SortingPlugin> DfsVisitor<'e> for GroupsPathVisitor<'_, '_, '_, '_, if self.group_node_to_ignore_as_source != Some(node_index) && !self.unfinishable_nodes.contains(&node_index) { - self.finished_group_vertices.insert(node_index); + let inserted = self.finished_group_vertices.insert(node_index); + if inserted { + logging::debug!( + "Recorded groups graph vertex \"{}\" as finished", + self.groups_graph[node_index] + ); + } } // Since this vertex has been fully explored, pop the edge stack to remove @@ -2760,6 +2798,57 @@ mod tests { assert!(graph.check_for_cycles().is_ok()); } + + #[test] + fn should_mark_nodes_as_unfinishable_if_a_node_in_their_subtree_is_unfinishable() { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_B1, PLUGIN_B2, PLUGIN_C, PLUGIN_D, + ]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + &[ + Group::new("BU1".into()).with_after_groups(vec!["B".into()]), + Group::new("BU2".into()).with_after_groups(vec!["BU1".into()]), + Group::new("C".into()).with_after_groups(vec!["BU2".into()]), + ], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "BU1")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "BU2")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -----------------------> C.esp -> D.esp + // -> BU1.esp -> BU2.esp -> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(a, d)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(b, b1)); + assert!(graph.inner.contains_edge(b, b2)); + assert!(graph.inner.contains_edge(b1, b2)); + assert!(graph.inner.contains_edge(b1, c)); + assert!(graph.inner.contains_edge(b1, d)); + assert!(graph.inner.contains_edge(b2, c)); + assert!(graph.inner.contains_edge(b2, c)); + assert!(graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } } mod add_overlap_edges { From d71a207c57dc5181980acc7f2e4e00c7901c840a Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 16 Mar 2025 18:05:32 +0000 Subject: [PATCH 035/206] Add Python wrapper built using PyO3 It's missing logging integration, specific error types, and the ability to retrieve loaded plugin data. --- Cargo.lock | 110 +++++ Cargo.toml | 2 +- pyo3/.github/workflows/CI.yml | 181 +++++++ pyo3/.gitignore | 72 +++ pyo3/Cargo.toml | 13 + pyo3/README.md | 60 +++ pyo3/pyproject.toml | 16 + pyo3/src/database.rs | 347 +++++++++++++ pyo3/src/game.rs | 170 +++++++ pyo3/src/lib.rs | 108 ++++ pyo3/src/metadata.rs | 903 ++++++++++++++++++++++++++++++++++ pyo3/src/plugin.rs | 95 ++++ 12 files changed, 2076 insertions(+), 1 deletion(-) create mode 100644 pyo3/.github/workflows/CI.yml create mode 100644 pyo3/.gitignore create mode 100644 pyo3/Cargo.toml create mode 100644 pyo3/README.md create mode 100644 pyo3/pyproject.toml create mode 100644 pyo3/src/database.rs create mode 100644 pyo3/src/game.rs create mode 100644 pyo3/src/lib.rs create mode 100644 pyo3/src/metadata.rs create mode 100644 pyo3/src/plugin.rs diff --git a/Cargo.lock b/Cargo.lock index b94dabfc..8b5f4c29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -517,6 +517,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "indexmap" version = "2.7.1" @@ -527,6 +533,12 @@ dependencies = [ "hashbrown 0.15.2", ] +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + [[package]] name = "keyvalues-parser" version = "0.2.0" @@ -595,6 +607,14 @@ dependencies = [ "unicase", ] +[[package]] +name = "libloot_pyo3" +version = "0.25.5" +dependencies = [ + "libloot", + "pyo3", +] + [[package]] name = "libredox" version = "0.1.3" @@ -645,6 +665,15 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -784,6 +813,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -811,6 +846,69 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f1c6c3591120564d64db2261bec5f910ae454f01def849b9c22835a84695e86" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b6c2b34cf71427ea37c7001aefbaeb85886a074795e35f161f5aecc7620a7a" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5507651906a46432cdda02cd02dd0319f6064f1374c9147c45b978621d2c3a9c" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0d394b5b4fd8d97d48336bb0dd2aebabad39f1d294edd6bcd2cccf2eefe6f42" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd72da09cfa943b1080f621f024d2ef7e2773df7badd51aa30a2be1f8caa7c8e" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.38" @@ -1074,6 +1172,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "target-lexicon" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" + [[package]] name = "tempfile" version = "3.17.1" @@ -1199,6 +1303,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 06d9c37a..30b8dfdd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,4 +26,4 @@ rstest_reuse = "0.7.0" tempfile = "3.17.1" [workspace] -members = ["cxx"] +members = ["cxx", "pyo3"] diff --git a/pyo3/.github/workflows/CI.yml b/pyo3/.github/workflows/CI.yml new file mode 100644 index 00000000..6574c1fa --- /dev/null +++ b/pyo3/.github/workflows/CI.yml @@ -0,0 +1,181 @@ +# This file is autogenerated by maturin v1.8.3 +# To update, run +# +# maturin generate-ci github +# +name: CI + +on: + push: + branches: + - main + - master + tags: + - '*' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-22.04 + target: x86_64 + - runner: ubuntu-22.04 + target: x86 + - runner: ubuntu-22.04 + target: aarch64 + - runner: ubuntu-22.04 + target: armv7 + - runner: ubuntu-22.04 + target: s390x + - runner: ubuntu-22.04 + target: ppc64le + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + manylinux: auto + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + + musllinux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-22.04 + target: x86_64 + - runner: ubuntu-22.04 + target: x86 + - runner: ubuntu-22.04 + target: aarch64 + - runner: ubuntu-22.04 + target: armv7 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + manylinux: musllinux_1_2 + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-musllinux-${{ matrix.platform.target }} + path: dist + + windows: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + architecture: ${{ matrix.platform.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist + + macos: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: macos-13 + target: x86_64 + - runner: macos-14 + target: aarch64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + release: + name: Release + runs-on: ubuntu-latest + if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} + needs: [linux, musllinux, windows, macos, sdist] + permissions: + # Use to sign the release artifacts + id-token: write + # Used to upload release artifacts + contents: write + # Used to generate artifact attestation + attestations: write + steps: + - uses: actions/download-artifact@v4 + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 + with: + subject-path: 'wheels-*/*' + - name: Publish to PyPI + if: ${{ startsWith(github.ref, 'refs/tags/') }} + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* diff --git a/pyo3/.gitignore b/pyo3/.gitignore new file mode 100644 index 00000000..c8f04429 --- /dev/null +++ b/pyo3/.gitignore @@ -0,0 +1,72 @@ +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version diff --git a/pyo3/Cargo.toml b/pyo3/Cargo.toml new file mode 100644 index 00000000..d3bf2db9 --- /dev/null +++ b/pyo3/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "libloot_pyo3" +version = "0.25.5" +edition = "2024" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "loot" +crate-type = ["cdylib"] + +[dependencies] +libloot = { path = ".." } +pyo3 = "0.24.0" diff --git a/pyo3/README.md b/pyo3/README.md new file mode 100644 index 00000000..771b8fcf --- /dev/null +++ b/pyo3/README.md @@ -0,0 +1,60 @@ +# libloot-pyo3 + +An **incomplete** and **experimental** Python wrapper around the libloot Rust implementation, built using [PyO3](https://pyo3.rs). + +## Current coverage + +- [x] `LIBLOOT_VERSION_MAJOR` +- [x] `LIBLOOT_VERSION_MINOR` +- [x] `LIBLOOT_VERSION_PATCH` +- [x] `is_compatible()` +- [x] `libloot_revision()` +- [x] `libloot_version()` +- [ ] `set_logging_callback()` +- [ ] `set_log_level()` +- [x] `EdgeType` +- [x] `GameType` +- [ ] `LogLevel` +- [x] `Database` +- [ ] `Game` +- [x] `Plugin` +- [x] `Vertex` +- [x] `File` +- [x] `Filename` +- [x] `Group` +- [x] `Location` +- [x] `Message` +- [x] `MessageContent` +- [x] `PluginCleaningData` +- [x] `PluginMetadata` +- [x] `Tag` +- [x] `MessageType` +- [x] `TagSuggestion` +- [x] `select_message_content()` +- [ ] Error types + - All errors are currently raised as Python `ValueError` values that contain the full detail of the error. + +## Build + +To build, first set up a Python venv and install [maturin](https://github.com/PyO3/maturin): + +``` +python -m venv .venv +.\.venv\Scripts\activate +pip install maturin +maturin develop +``` + +Then build the library: + +``` +.\.venv\Scripts\activate +maturin develop +``` + +Then import it in Python: + +``` +py +> import loot +``` diff --git a/pyo3/pyproject.toml b/pyo3/pyproject.toml new file mode 100644 index 00000000..42b8bb36 --- /dev/null +++ b/pyo3/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["maturin>=1.8,<2.0"] +build-backend = "maturin" + +[project] +name = "libloot" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] + +[tool.maturin] +features = ["pyo3/extension-module"] diff --git a/pyo3/src/database.rs b/pyo3/src/database.rs new file mode 100644 index 00000000..59ed3550 --- /dev/null +++ b/pyo3/src/database.rs @@ -0,0 +1,347 @@ +use std::{ + hash::{DefaultHasher, Hash, Hasher}, + path::PathBuf, + sync::{Arc, RwLock}, +}; + +use libloot::error::DatabaseLockPoisonError; +use pyo3::{ + Bound, PyResult, pyclass, pymethods, + types::{PyAnyMethods, PyTypeMethods}, +}; + +use crate::{ + UnsupportedEnumValueError, VerboseError, + metadata::{Group, Message, NONE_REPR, PluginMetadata}, +}; + +#[pyclass] +#[derive(Clone, Debug)] +pub struct Database(Arc>); + +#[pymethods] +impl Database { + pub fn load_masterlist(&self, path: PathBuf) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .load_masterlist(&path) + .map_err(Into::into) + } + + pub fn load_masterlist_with_prelude( + &self, + masterlist_path: PathBuf, + prelude_path: PathBuf, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .load_masterlist_with_prelude(&masterlist_path, &prelude_path) + .map_err(Into::into) + } + + pub fn load_userlist(&self, path: PathBuf) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .load_userlist(&path) + .map_err(Into::into) + } + + pub fn write_user_metadata( + &self, + output_path: PathBuf, + overwrite: bool, + ) -> Result<(), VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .write_user_metadata(&output_path, overwrite) + .map_err(Into::into) + } + + pub fn write_minimal_list( + &self, + output_path: PathBuf, + overwrite: bool, + ) -> Result<(), VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .write_minimal_list(&output_path, overwrite) + .map_err(Into::into) + } + + pub fn known_bash_tags(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .known_bash_tags()) + } + + pub fn general_messages( + &self, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .general_messages(evaluate_conditions) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + pub fn groups(&self, include_user_metadata: bool) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .groups(include_user_metadata) + .into_iter() + .map(Into::into) + .collect()) + } + + fn user_groups(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .user_groups() + .iter() + .cloned() + .map(Into::into) + .collect()) + } + + pub fn set_user_groups(&self, groups: Vec) -> Result<(), VerboseError> { + let groups = groups.into_iter().map(Into::into).collect(); + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .set_user_groups(groups); + Ok(()) + } + + pub fn groups_path( + &self, + from_group_name: &str, + to_group_name: &str, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .groups_path(from_group_name, to_group_name) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + pub fn plugin_metadata( + &self, + plugin_name: &str, + include_user_metadata: bool, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .plugin_metadata(plugin_name, include_user_metadata, evaluate_conditions) + .map(|p| p.map(Into::into)) + .map_err(Into::into) + } + + pub fn plugin_user_metadata( + &self, + plugin_name: &str, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .plugin_user_metadata(plugin_name, evaluate_conditions) + .map(|p| p.map(Into::into)) + .map_err(Into::into) + } + + pub fn set_plugin_user_metadata( + &mut self, + plugin_metadata: PluginMetadata, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .set_plugin_user_metadata(plugin_metadata.into()); + Ok(()) + } + + pub fn discard_plugin_user_metadata(&self, plugin: &str) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .discard_plugin_user_metadata(plugin); + Ok(()) + } + + pub fn discard_all_user_metadata(&self) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .discard_all_user_metadata(); + Ok(()) + } +} + +impl From>> for Database { + fn from(value: Arc>) -> Self { + Self(value) + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Vertex(libloot::Vertex); + +#[pymethods] +impl Vertex { + #[new] + fn new(name: String) -> Self { + Self(libloot::Vertex::new(name)) + } + + #[getter] + fn name(&self) -> &str { + self.0.name() + } + + #[getter] + fn out_edge_type(&self) -> Result, UnsupportedEnumValueError> { + self.0.out_edge_type().map(|e| e.try_into()).transpose() + } + + #[setter] + fn set_out_edge_type( + &mut self, + out_edge_type: EdgeType, + ) -> Result<(), UnsupportedEnumValueError> { + let out_edge_type = out_edge_type.try_into()?; + self.0.set_out_edge_type(out_edge_type); + Ok(()) + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {})", + class_name, + inner.name(), + inner + .out_edge_type() + .map(repr_edge_type) + .unwrap_or(NONE_REPR), + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for Vertex { + fn from(value: libloot::Vertex) -> Self { + Self(value) + } +} + +impl From for libloot::Vertex { + fn from(value: Vertex) -> Self { + value.0 + } +} + +#[pyclass(eq, frozen, hash, ord)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum EdgeType { + Hardcoded, + MasterFlag, + Master, + MasterlistRequirement, + UserRequirement, + MasterlistLoadAfter, + UserLoadAfter, + MasterlistGroup, + UserGroup, + RecordOverlap, + AssetOverlap, + TieBreak, + BlueprintMaster, +} + +impl TryFrom for EdgeType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::EdgeType) -> Result { + match value { + libloot::EdgeType::Hardcoded => Ok(EdgeType::Hardcoded), + libloot::EdgeType::MasterFlag => Ok(EdgeType::MasterFlag), + libloot::EdgeType::Master => Ok(EdgeType::Master), + libloot::EdgeType::MasterlistRequirement => Ok(EdgeType::MasterlistRequirement), + libloot::EdgeType::UserRequirement => Ok(EdgeType::UserRequirement), + libloot::EdgeType::MasterlistLoadAfter => Ok(EdgeType::MasterlistLoadAfter), + libloot::EdgeType::UserLoadAfter => Ok(EdgeType::UserLoadAfter), + libloot::EdgeType::MasterlistGroup => Ok(EdgeType::MasterlistGroup), + libloot::EdgeType::UserGroup => Ok(EdgeType::UserGroup), + libloot::EdgeType::RecordOverlap => Ok(EdgeType::RecordOverlap), + libloot::EdgeType::AssetOverlap => Ok(EdgeType::AssetOverlap), + libloot::EdgeType::TieBreak => Ok(EdgeType::TieBreak), + libloot::EdgeType::BlueprintMaster => Ok(EdgeType::BlueprintMaster), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl TryFrom for libloot::EdgeType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: EdgeType) -> Result { + match value { + EdgeType::Hardcoded => Ok(libloot::EdgeType::Hardcoded), + EdgeType::MasterFlag => Ok(libloot::EdgeType::MasterFlag), + EdgeType::Master => Ok(libloot::EdgeType::Master), + EdgeType::MasterlistRequirement => Ok(libloot::EdgeType::MasterlistRequirement), + EdgeType::UserRequirement => Ok(libloot::EdgeType::UserRequirement), + EdgeType::MasterlistLoadAfter => Ok(libloot::EdgeType::MasterlistLoadAfter), + EdgeType::UserLoadAfter => Ok(libloot::EdgeType::UserLoadAfter), + EdgeType::MasterlistGroup => Ok(libloot::EdgeType::MasterlistGroup), + EdgeType::UserGroup => Ok(libloot::EdgeType::UserGroup), + EdgeType::RecordOverlap => Ok(libloot::EdgeType::RecordOverlap), + EdgeType::AssetOverlap => Ok(libloot::EdgeType::AssetOverlap), + EdgeType::TieBreak => Ok(libloot::EdgeType::TieBreak), + EdgeType::BlueprintMaster => Ok(libloot::EdgeType::BlueprintMaster), + } + } +} + +fn repr_edge_type(value: libloot::EdgeType) -> &'static str { + match value { + libloot::EdgeType::Hardcoded => "EdgeType.Hardcoded", + libloot::EdgeType::MasterFlag => "EdgeType.MasterFlag", + libloot::EdgeType::Master => "EdgeType.Master", + libloot::EdgeType::MasterlistRequirement => "EdgeType.MasterlistRequirement", + libloot::EdgeType::UserRequirement => "EdgeType.UserRequirement", + libloot::EdgeType::MasterlistLoadAfter => "EdgeType.MasterlistLoadAfter", + libloot::EdgeType::UserLoadAfter => "EdgeType.UserLoadAfter", + libloot::EdgeType::MasterlistGroup => "EdgeType.MasterlistGroup", + libloot::EdgeType::UserGroup => "EdgeType.UserGroup", + libloot::EdgeType::RecordOverlap => "EdgeType.RecordOverlap", + libloot::EdgeType::AssetOverlap => "EdgeType.AssetOverlap", + libloot::EdgeType::TieBreak => "EdgeType.TieBreak", + libloot::EdgeType::BlueprintMaster => "EdgeType.BlueprintMaster", + _ => "", + } +} diff --git a/pyo3/src/game.rs b/pyo3/src/game.rs new file mode 100644 index 00000000..78459fec --- /dev/null +++ b/pyo3/src/game.rs @@ -0,0 +1,170 @@ +use std::path::{Path, PathBuf}; + +use pyo3::{pyclass, pymethods}; + +use crate::{UnsupportedEnumValueError, VerboseError, database::Database, plugin::Plugin}; + +#[allow(non_camel_case_types)] +#[pyclass(eq, frozen, hash, ord)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum GameType { + tes4, + tes5, + fo3, + fonv, + fo4, + tes5se, + fo4vr, + tes5vr, + tes3, + starfield, + openmw, +} + +impl TryFrom for GameType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::GameType) -> Result { + match value { + libloot::GameType::TES4 => Ok(GameType::tes4), + libloot::GameType::TES5 => Ok(GameType::tes5), + libloot::GameType::FO3 => Ok(GameType::fo3), + libloot::GameType::FONV => Ok(GameType::fonv), + libloot::GameType::FO4 => Ok(GameType::fo4), + libloot::GameType::TES5SE => Ok(GameType::tes5se), + libloot::GameType::FO4VR => Ok(GameType::fo4vr), + libloot::GameType::TES5VR => Ok(GameType::tes5vr), + libloot::GameType::TES3 => Ok(GameType::tes3), + libloot::GameType::Starfield => Ok(GameType::starfield), + libloot::GameType::OpenMW => Ok(GameType::openmw), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl TryFrom for libloot::GameType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: GameType) -> Result { + match value { + GameType::tes4 => Ok(libloot::GameType::TES4), + GameType::tes5 => Ok(libloot::GameType::TES5), + GameType::fo3 => Ok(libloot::GameType::FO3), + GameType::fonv => Ok(libloot::GameType::FONV), + GameType::fo4 => Ok(libloot::GameType::FO4), + GameType::tes5se => Ok(libloot::GameType::TES5SE), + GameType::fo4vr => Ok(libloot::GameType::FO4VR), + GameType::tes5vr => Ok(libloot::GameType::TES5VR), + GameType::tes3 => Ok(libloot::GameType::TES3), + GameType::starfield => Ok(libloot::GameType::Starfield), + GameType::openmw => Ok(libloot::GameType::OpenMW), + } + } +} + +#[pyclass] +#[derive(Debug)] +pub struct Game(libloot::Game); + +#[pymethods] +impl Game { + #[new] + #[pyo3(signature = (game_type, game_path, local_path = None))] + fn new( + game_type: GameType, + game_path: PathBuf, + local_path: Option, + ) -> Result { + match local_path { + Some(local_path) => Ok(Game(libloot::Game::with_local_path( + game_type.try_into()?, + &game_path, + &local_path, + )?)), + None => Ok(Game(libloot::Game::new(game_type.try_into()?, &game_path)?)), + } + } + + fn game_type(&self) -> Result { + self.0.game_type().try_into().map_err(Into::into) + } + + fn additional_data_paths(&self) -> &[PathBuf] { + self.0.additional_data_paths() + } + + fn set_additional_data_paths(&mut self, paths: Vec) -> Result<(), VerboseError> { + self.0.set_additional_data_paths(&as_paths(&paths))?; + Ok(()) + } + + fn database(&self) -> Database { + self.0.database().into() + } + + fn is_valid_plugin(&self, plugin_path: PathBuf) -> bool { + self.0.is_valid_plugin(&plugin_path) + } + + fn load_plugins(&mut self, plugin_paths: Vec) -> Result<(), VerboseError> { + self.0.load_plugins(&as_paths(&plugin_paths))?; + Ok(()) + } + + fn load_plugin_headers(&mut self, plugin_paths: Vec) -> Result<(), VerboseError> { + self.0.load_plugin_headers(&as_paths(&plugin_paths))?; + Ok(()) + } + + fn clear_loaded_plugins(&mut self) { + self.0.clear_loaded_plugins(); + } + + // TODO: Game::plugin(). + // fn plugin(&self, plugin_name: &str) -> Option<&Plugin> { + // self.0.plugin(plugin_name).map(Plugin::wrap) + // } + + // TODO: Game::loaded_plugins(). + // fn loaded_plugins(&self) -> Vec<&Plugin> { + // wrap_plugins(self.0.loaded_plugins()) + // } + + fn sort_plugins(&self, plugin_names: Vec) -> Result, VerboseError> { + Ok(self.0.sort_plugins(&as_strs(&plugin_names))?) + } + + fn load_current_load_order_state(&mut self) -> Result<(), VerboseError> { + self.0.load_current_load_order_state()?; + Ok(()) + } + + fn is_load_order_ambiguous(&self) -> Result { + Ok(self.0.is_load_order_ambiguous()?) + } + + fn active_plugins_file_path(&self) -> &PathBuf { + self.0.active_plugins_file_path() + } + + fn is_plugin_active(&self, plugin_name: &str) -> bool { + self.0.is_plugin_active(plugin_name) + } + + fn load_order(&self) -> Vec<&str> { + self.0.load_order() + } + + fn set_load_order(&mut self, load_order: Vec) -> Result<(), VerboseError> { + self.0.set_load_order(&as_strs(&load_order))?; + Ok(()) + } +} + +fn as_paths(pathbufs: &[PathBuf]) -> Vec<&Path> { + pathbufs.iter().map(PathBuf::as_ref).collect() +} + +fn as_strs(strings: &[String]) -> Vec<&str> { + strings.iter().map(String::as_ref).collect() +} diff --git a/pyo3/src/lib.rs b/pyo3/src/lib.rs new file mode 100644 index 00000000..9eb43ed1 --- /dev/null +++ b/pyo3/src/lib.rs @@ -0,0 +1,108 @@ +mod database; +mod game; +mod metadata; +mod plugin; + +use database::{Database, EdgeType, Vertex}; +use game::{Game, GameType}; +use metadata::{ + File, Filename, Group, Location, Message, MessageContent, MessageType, PluginMetadata, Tag, + TagSuggestion, select_message_content, +}; +use plugin::Plugin; +use pyo3::{exceptions::PyValueError, prelude::*}; + +#[pyfunction] +fn is_compatible(major: u32, minor: u32, patch: u32) -> bool { + libloot::is_compatible(major, minor, patch) +} + +#[pyfunction] +fn libloot_revision() -> String { + libloot::libloot_revision() +} + +#[pyfunction] +fn libloot_version() -> String { + libloot::libloot_version() +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct UnsupportedEnumValueError; + +impl std::fmt::Display for UnsupportedEnumValueError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Enum value is unsupported") + } +} + +impl std::error::Error for UnsupportedEnumValueError {} + +impl From for PyErr { + fn from(value: UnsupportedEnumValueError) -> Self { + PyValueError::new_err(value.to_string()) + } +} + +#[derive(Debug)] +pub struct VerboseError(Box); + +impl std::fmt::Display for VerboseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let e = &self.0; + write!(f, "{}", e)?; + let mut error = e.as_ref(); + while let Some(source) = error.source() { + write!(f, ": {}", source)?; + error = source; + } + Ok(()) + } +} + +// impl std::error::Error for VerboseError {} + +impl From for VerboseError { + fn from(value: T) -> Self { + VerboseError(Box::new(value)) + } +} + +impl From for PyErr { + fn from(value: VerboseError) -> Self { + PyValueError::new_err(value.to_string()) + } +} + +/// A Python module implemented in Rust. +#[pymodule(name = "loot")] +fn libloot_pyo3(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("LIBLOOT_VERSION_MAJOR", libloot::LIBLOOT_VERSION_MAJOR)?; + m.add("LIBLOOT_VERSION_MINOR", libloot::LIBLOOT_VERSION_MINOR)?; + m.add("LIBLOOT_VERSION_PATCH", libloot::LIBLOOT_VERSION_PATCH)?; + + m.add_function(wrap_pyfunction!(is_compatible, m)?)?; + m.add_function(wrap_pyfunction!(libloot_revision, m)?)?; + m.add_function(wrap_pyfunction!(libloot_version, m)?)?; + + m.add_function(wrap_pyfunction!(select_message_content, m)?)?; + + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + Ok(()) +} diff --git a/pyo3/src/metadata.rs b/pyo3/src/metadata.rs new file mode 100644 index 00000000..3537210c --- /dev/null +++ b/pyo3/src/metadata.rs @@ -0,0 +1,903 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; + +use pyo3::{ + Bound, FromPyObject, PyResult, pyclass, pyfunction, pymethods, + types::{PyAnyMethods, PyTypeMethods}, +}; + +use crate::{UnsupportedEnumValueError, VerboseError}; + +pub const NONE_REPR: &str = "None"; + +#[pyclass(eq, ord, str = "{0:?}")] +#[repr(transparent)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Group(libloot::metadata::Group); + +#[pymethods] +impl Group { + #[classattr] + fn default_name() -> &'static str { + libloot::metadata::Group::DEFAULT_NAME + } + + #[new] + #[pyo3(signature = (name, description = None, after_groups = None))] + fn new(name: String, description: Option, after_groups: Option>) -> Self { + let mut group = libloot::metadata::Group::new(name); + + if let Some(description) = description { + group.set_description(description); + } + + if let Some(after_groups) = after_groups { + group.set_after_groups(after_groups); + } + + Self(group) + } + + #[getter] + fn name(&self) -> &str { + self.0.name() + } + + #[getter] + fn description(&self) -> Option<&str> { + self.0.description() + } + + #[setter] + fn set_description(&mut self, description: String) { + self.0.set_description(description); + } + + #[getter] + fn after_groups(&self) -> &[String] { + self.0.after_groups() + } + + #[setter] + fn set_after_groups(&mut self, after_groups: Vec) { + self.0.set_after_groups(after_groups); + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {}, [{}])", + class_name, + inner.name(), + inner.description().unwrap_or(NONE_REPR), + inner.after_groups().join(",") + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for Group { + fn from(value: libloot::metadata::Group) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Group { + fn from(value: Group) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[repr(transparent)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct MessageContent(libloot::metadata::MessageContent); + +#[pymethods] +impl MessageContent { + #[classattr] + fn default_language() -> &'static str { + libloot::metadata::MessageContent::DEFAULT_LANGUAGE + } + + #[new] + #[pyo3(signature = (text, language = None))] + fn new(text: String, language: Option) -> Self { + let mut content = libloot::metadata::MessageContent::new(text); + + if let Some(language) = language { + content.set_language(language); + } + + Self(content) + } + + #[getter] + fn text(&self) -> &str { + self.0.text() + } + + #[getter] + fn language(&self) -> &str { + self.0.language() + } + + #[setter] + fn set_language(&mut self, language: String) { + self.0.set_language(language); + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {})", + class_name, + inner.text(), + inner.language() + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for MessageContent { + fn from(value: libloot::metadata::MessageContent) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::MessageContent { + fn from(value: MessageContent) -> Self { + value.0 + } +} + +fn repr_message_contents(contents: &[libloot::metadata::MessageContent]) -> String { + contents + .iter() + .map(|c| format!("MessageContent({}, {})", c.text(), c.language())) + .collect::>() + .join(",") +} + +#[pyfunction] +pub fn select_message_content( + content: Vec, + language: &str, +) -> Option { + let content: Vec<_> = content.into_iter().map(Into::into).collect(); + libloot::metadata::select_message_content(&content, language) + .cloned() + .map(Into::into) +} + +#[pyclass(eq, frozen, hash, ord)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum MessageType { + Say, + Warn, + Error, +} + +impl From for MessageType { + fn from(value: libloot::metadata::MessageType) -> Self { + match value { + libloot::metadata::MessageType::Say => MessageType::Say, + libloot::metadata::MessageType::Warn => MessageType::Warn, + libloot::metadata::MessageType::Error => MessageType::Error, + } + } +} + +impl From for libloot::metadata::MessageType { + fn from(value: MessageType) -> Self { + match value { + MessageType::Say => libloot::metadata::MessageType::Say, + MessageType::Warn => libloot::metadata::MessageType::Warn, + MessageType::Error => libloot::metadata::MessageType::Error, + } + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Message(libloot::metadata::Message); + +#[derive(FromPyObject)] +enum MessageContents { + Monolingual(String), + Multilingual(Vec), +} + +#[pymethods] +impl Message { + #[new] + #[pyo3(signature = (message_type, contents, condition = None))] + fn new( + message_type: MessageType, + contents: MessageContents, + condition: Option, + ) -> Result { + let mut message = match contents { + MessageContents::Monolingual(c) => { + libloot::metadata::Message::new(message_type.into(), c) + } + MessageContents::Multilingual(c) => { + let c = c.into_iter().map(Into::into).collect(); + libloot::metadata::Message::multilingual(message_type.into(), c)? + } + }; + + if let Some(condition) = condition { + message.set_condition(condition); + } + + Ok(Self(message)) + } + + #[getter] + fn message_type(&self) -> MessageType { + self.0.message_type().into() + } + + #[getter] + fn content(&self) -> Vec { + self.0.content().iter().cloned().map(Into::into).collect() + } + + #[getter] + fn condition(&self) -> Option<&str> { + self.0.condition() + } + + #[setter] + fn set_condition(&mut self, condition: String) { + self.0.set_condition(condition); + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, [{}], {})", + class_name, + inner.message_type(), + repr_message_contents(inner.content()), + inner.condition().unwrap_or(NONE_REPR), + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for Message { + fn from(value: libloot::metadata::Message) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Message { + fn from(value: Message) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct File(libloot::metadata::File); + +#[pymethods] +impl File { + #[new] + #[pyo3(signature = (name, display_name = None, detail = None, condition = None))] + fn new( + name: String, + display_name: Option, + detail: Option>, + condition: Option, + ) -> Result { + let mut file = libloot::metadata::File::new(name); + + if let Some(display_name) = display_name { + file.set_display_name(display_name); + } + + if let Some(detail) = detail { + let detail = detail.into_iter().map(Into::into).collect(); + file.set_detail(detail)?; + } + + if let Some(condition) = condition { + file.set_condition(condition); + } + + Ok(Self(file)) + } + + #[getter] + fn name(&self) -> Filename { + self.0.name().clone().into() + } + + #[getter] + fn display_name(&self) -> Option<&str> { + self.0.display_name() + } + + #[setter] + fn set_display_name(&mut self, description: String) { + self.0.set_display_name(description); + } + + #[getter] + fn detail(&self) -> Vec { + self.0.detail().iter().cloned().map(Into::into).collect() + } + + #[setter] + fn set_detail(&mut self, detail: Vec) -> Result<(), VerboseError> { + let detail = detail.into_iter().map(Into::into).collect(); + self.0.set_detail(detail)?; + Ok(()) + } + + #[getter] + fn condition(&self) -> Option<&str> { + self.0.condition() + } + + #[setter] + fn set_condition(&mut self, condition: String) { + self.0.set_condition(condition); + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {}, {}, {})", + class_name, + inner.name(), + inner.display_name().unwrap_or(NONE_REPR), + repr_message_contents(inner.detail()), + inner.condition().unwrap_or(NONE_REPR) + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for File { + fn from(value: libloot::metadata::File) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::File { + fn from(value: File) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Filename(libloot::metadata::Filename); + +#[pymethods] +impl Filename { + #[new] + fn new(name: String) -> Self { + Self(libloot::metadata::Filename::new(name)) + } + + fn as_str(&self) -> &str { + self.0.as_str() + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!("{}({})", class_name, inner.as_str(),)) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for Filename { + fn from(value: libloot::metadata::Filename) -> Self { + Self(value) + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct PluginCleaningData(libloot::metadata::PluginCleaningData); + +#[pymethods] +impl PluginCleaningData { + #[new] + #[pyo3(signature = (crc, cleaning_utility, itm_count = None, deleted_reference_count = None, deleted_navmesh_count = None, detail = None))] + fn new( + crc: u32, + cleaning_utility: String, + itm_count: Option, + deleted_reference_count: Option, + deleted_navmesh_count: Option, + detail: Option>, + ) -> Result { + let mut data = libloot::metadata::PluginCleaningData::new(crc, cleaning_utility); + + if let Some(count) = itm_count { + data.set_itm_count(count); + } + + if let Some(count) = deleted_reference_count { + data.set_deleted_reference_count(count); + } + + if let Some(count) = deleted_navmesh_count { + data.set_deleted_navmesh_count(count); + } + + if let Some(detail) = detail { + let detail = detail.into_iter().map(Into::into).collect(); + data.set_detail(detail)?; + } + + Ok(Self(data)) + } + + #[getter] + fn crc(&self) -> u32 { + self.0.crc() + } + + #[getter] + fn itm_count(&self) -> u32 { + self.0.itm_count() + } + + #[setter] + fn set_itm_count(&mut self, count: u32) { + self.0.set_itm_count(count); + } + + #[getter] + fn deleted_reference_count(&self) -> u32 { + self.0.deleted_reference_count() + } + + #[setter] + fn set_deleted_reference_count(&mut self, count: u32) { + self.0.set_deleted_reference_count(count); + } + + #[getter] + fn deleted_navmesh_count(&self) -> u32 { + self.0.deleted_navmesh_count() + } + + #[setter] + fn set_deleted_navmesh_count(&mut self, count: u32) { + self.0.set_deleted_navmesh_count(count); + } + + #[getter] + fn cleaning_utility(&self) -> &str { + self.0.cleaning_utility() + } + + #[getter] + fn detail(&self) -> Vec { + self.0.detail().iter().cloned().map(Into::into).collect() + } + + #[setter] + fn set_detail(&mut self, detail: Vec) -> Result<(), VerboseError> { + let detail = detail.into_iter().map(Into::into).collect(); + self.0.set_detail(detail)?; + Ok(()) + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {}, {}, {}, {}, {})", + class_name, + inner.crc(), + inner.cleaning_utility(), + inner.itm_count(), + inner.deleted_reference_count(), + inner.deleted_navmesh_count(), + repr_message_contents(inner.detail()) + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for PluginCleaningData { + fn from(value: libloot::metadata::PluginCleaningData) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::PluginCleaningData { + fn from(value: PluginCleaningData) -> Self { + value.0 + } +} + +#[pyclass(eq, frozen, hash, ord)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum TagSuggestion { + Addition, + Removal, +} + +impl TryFrom for libloot::metadata::TagSuggestion { + type Error = UnsupportedEnumValueError; + + fn try_from(value: TagSuggestion) -> Result { + match value { + TagSuggestion::Addition => Ok(libloot::metadata::TagSuggestion::Addition), + TagSuggestion::Removal => Ok(libloot::metadata::TagSuggestion::Removal), + } + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Tag(libloot::metadata::Tag); + +#[pymethods] +impl Tag { + #[new] + #[pyo3(signature = (name, suggestion, condition = None))] + fn new( + name: String, + suggestion: TagSuggestion, + condition: Option, + ) -> Result { + let mut tag = libloot::metadata::Tag::new(name, suggestion.try_into()?); + + if let Some(condition) = condition { + tag.set_condition(condition); + } + + Ok(Self(tag)) + } + + #[getter] + fn name(&self) -> &str { + self.0.name() + } + + #[getter] + fn is_addition(&self) -> bool { + self.0.is_addition() + } + + #[getter] + fn condition(&self) -> Option<&str> { + self.0.condition() + } + + #[setter] + fn set_condition(&mut self, condition: String) { + self.0.set_condition(condition); + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + let suggestion = if inner.is_addition() { + "TagSuggestion.Addition" + } else { + "TagSuggestion.Removal" + }; + Ok(format!( + "{}({}, {}, {})", + class_name, + inner.name(), + suggestion, + inner.condition().unwrap_or(NONE_REPR) + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for Tag { + fn from(value: libloot::metadata::Tag) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Tag { + fn from(value: Tag) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Location(libloot::metadata::Location); + +#[pymethods] +impl Location { + #[new] + #[pyo3(signature = (url, name = None))] + fn new(url: String, name: Option) -> Self { + let mut location = libloot::metadata::Location::new(url); + + if let Some(name) = name { + location.set_name(name); + } + + Self(location) + } + + #[getter] + fn url(&self) -> &str { + self.0.url() + } + + #[getter] + fn name(&self) -> Option<&str> { + self.0.name() + } + + #[setter] + fn set_name(&mut self, name: String) { + self.0.set_name(name); + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {})", + class_name, + inner.url(), + inner.name().unwrap_or(NONE_REPR) + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for Location { + fn from(value: libloot::metadata::Location) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Location { + fn from(value: Location) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct PluginMetadata(libloot::metadata::PluginMetadata); + +#[pymethods] +impl PluginMetadata { + #[new] + fn new(name: String) -> Result { + Ok(Self(libloot::metadata::PluginMetadata::new(&name)?)) + } + + #[getter] + fn name(&self) -> &str { + self.0.name() + } + + #[getter] + fn group(&self) -> Option<&str> { + self.0.group() + } + + #[getter] + fn load_after_files(&self) -> Vec { + self.0 + .load_after_files() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn requirements(&self) -> Vec { + self.0 + .requirements() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn incompatibilities(&self) -> Vec { + self.0 + .incompatibilities() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn messages(&self) -> Vec { + self.0.messages().iter().cloned().map(Into::into).collect() + } + + #[getter] + fn tags(&self) -> Vec { + self.0.tags().iter().cloned().map(Into::into).collect() + } + + #[getter] + fn dirty_info(&self) -> Vec { + self.0 + .dirty_info() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn clean_info(&self) -> Vec { + self.0 + .clean_info() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn locations(&self) -> Vec { + self.0.locations().iter().cloned().map(Into::into).collect() + } + + #[setter] + fn set_group(&mut self, group: Option) { + match group { + Some(g) => self.0.set_group(g), + None => self.0.unset_group(), + } + } + + #[setter] + fn set_load_after_files(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_load_after_files(value); + } + + #[setter] + fn set_requirements(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_requirements(value); + } + + #[setter] + fn set_incompatibilities(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_incompatibilities(value); + } + + #[setter] + fn set_messages(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_messages(value); + } + + #[setter] + fn set_tags(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_tags(value); + } + + #[setter] + fn set_dirty_info(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_dirty_info(value); + } + + #[setter] + fn set_clean_info(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_clean_info(value); + } + + #[setter] + fn set_locations(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_locations(value); + } + + fn merge_metadata(&mut self, other: PluginMetadata) { + self.0.merge_metadata(&other.0); + } + + fn has_name_only(&self) -> bool { + self.0.has_name_only() + } + + fn is_regex_plugin(&self) -> bool { + self.0.is_regex_plugin() + } + + fn name_matches(&self, other_name: &str) -> bool { + self.0.name_matches(other_name) + } + + fn as_yaml(&self) -> String { + self.0.as_yaml() + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for PluginMetadata { + fn from(value: libloot::metadata::PluginMetadata) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::PluginMetadata { + fn from(value: PluginMetadata) -> Self { + value.0 + } +} diff --git a/pyo3/src/plugin.rs b/pyo3/src/plugin.rs new file mode 100644 index 00000000..37ed9ff9 --- /dev/null +++ b/pyo3/src/plugin.rs @@ -0,0 +1,95 @@ +use pyo3::{pyclass, pymethods}; + +use crate::VerboseError; + +#[pyclass(eq, frozen)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[repr(transparent)] +pub struct Plugin(libloot::Plugin); + +#[pymethods] +impl Plugin { + fn name(&self) -> &str { + self.0.name() + } + + fn header_version(&self) -> Option { + self.0.header_version() + } + + fn version(&self) -> Option<&str> { + self.0.version() + } + + fn masters(&self) -> Result, VerboseError> { + Ok(self.0.masters()?) + } + + fn bash_tags(&self) -> &[String] { + self.0.bash_tags() + } + + fn crc(&self) -> Option { + self.0.crc() + } + + fn is_master(&self) -> bool { + self.0.is_master() + } + + fn is_light_plugin(&self) -> bool { + self.0.is_light_plugin() + } + + fn is_medium_plugin(&self) -> bool { + self.0.is_medium_plugin() + } + + fn is_update_plugin(&self) -> bool { + self.0.is_update_plugin() + } + + fn is_blueprint_plugin(&self) -> bool { + self.0.is_blueprint_plugin() + } + + fn is_valid_as_light_plugin(&self) -> Result { + Ok(self.0.is_valid_as_light_plugin()?) + } + + fn is_valid_as_medium_plugin(&self) -> Result { + Ok(self.0.is_valid_as_medium_plugin()?) + } + + fn is_valid_as_update_plugin(&self) -> Result { + Ok(self.0.is_valid_as_update_plugin()?) + } + + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn loads_archive(&self) -> bool { + self.0.loads_archive() + } + + fn do_records_overlap(&self, plugin: &Self) -> Result { + Ok(self.0.do_records_overlap(&plugin.0)?) + } +} + +impl Plugin { + pub fn wrap(plugin: &libloot::Plugin) -> &Plugin { + let p = plugin as *const libloot::Plugin; + // SAFETY: Plugin is a transparent wrapper, so reinterpreting the pointer is safe. + unsafe { + let p = p as *const Plugin; + &*p + } + } +} + +pub fn wrap_plugins(mut vec: Vec<&libloot::Plugin>) -> Vec<&Plugin> { + // SAFETY: This is safe because Plugin is a transparent wrapper around the libloot type. + unsafe { Vec::from_raw_parts(vec.as_mut_ptr().cast(), vec.len(), vec.capacity()) } +} From 3c02bfd1daeb276acb3f16b21bea07c8893ee8b3 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 16 Mar 2025 18:44:30 +0000 Subject: [PATCH 036/206] Return plugins as Arc instead of &Plugin This is the only way I can see for the PyO3 wrapper to work safely, and it's probably a good idea anyway (it simplifies the CXX wrapper too). --- cxx/src/api/game.cpp | 2 +- cxx/src/api/plugin.cpp | 2 +- cxx/src/api/plugin.h | 4 ++-- cxx/src/game.rs | 8 ++++---- cxx/src/lib.rs | 22 +++++++++++----------- cxx/src/plugin.rs | 35 +++++++++++++++-------------------- pyo3/README.md | 2 +- pyo3/src/game.rs | 18 ++++++++++-------- pyo3/src/plugin.rs | 20 ++++++-------------- src/game.rs | 27 ++++++++++++++------------- 10 files changed, 65 insertions(+), 75 deletions(-) diff --git a/cxx/src/api/game.cpp b/cxx/src/api/game.cpp index f09079b4..836044b8 100644 --- a/cxx/src/api/game.cpp +++ b/cxx/src/api/game.cpp @@ -189,7 +189,7 @@ const PluginInterface* Game::GetPlugin(const std::string& pluginName) const { } try { - auto plugin = std::make_shared(std::move(pluginOpt->as_ref())); + auto plugin = std::make_shared(std::move(pluginOpt->as_ref().boxed_clone())); const auto result = plugins_.emplace(key, plugin); return result.first->second.get(); diff --git a/cxx/src/api/plugin.cpp b/cxx/src/api/plugin.cpp index ddf6c208..592a1a53 100644 --- a/cxx/src/api/plugin.cpp +++ b/cxx/src/api/plugin.cpp @@ -7,7 +7,7 @@ #include "api/exception.h" namespace loot { -Plugin::Plugin(::rust::Box plugin) : +Plugin::Plugin(::rust::Box plugin) : plugin_(std::move(plugin)) {} std::string Plugin::GetName() const { return std::string(plugin_->name()); } diff --git a/cxx/src/api/plugin.h b/cxx/src/api/plugin.h index b4ecd523..79eb6d40 100644 --- a/cxx/src/api/plugin.h +++ b/cxx/src/api/plugin.h @@ -16,7 +16,7 @@ namespace loot { class Plugin final : public PluginInterface { public: - explicit Plugin(::rust::Box plugin); + explicit Plugin(::rust::Box plugin); std::string GetName() const override; std::optional GetHeaderVersion() const override; @@ -39,7 +39,7 @@ public: bool DoRecordsOverlap(const PluginInterface& plugin) const override; private: - ::rust::Box plugin_; + ::rust::Box plugin_; }; } diff --git a/cxx/src/game.rs b/cxx/src/game.rs index 147ebfa7..6236edaa 100644 --- a/cxx/src/game.rs +++ b/cxx/src/game.rs @@ -3,8 +3,8 @@ use std::path::Path; use delegate::delegate; use crate::{ - PluginRef, UnsupportedEnumValueError, VerboseError, database::Database, ffi::GameType, - plugin::OptionalPluginRef, + Plugin, UnsupportedEnumValueError, VerboseError, database::Database, ffi::GameType, + plugin::OptionalPlugin, }; impl TryFrom for GameType { @@ -150,11 +150,11 @@ impl Game { .map_err(Into::into) } - pub fn plugin(&self, plugin_name: &str) -> Box { + pub fn plugin(&self, plugin_name: &str) -> Box { Box::new(self.0.plugin(plugin_name).into()) } - pub fn loaded_plugins(&self) -> Vec> { + pub fn loaded_plugins(&self) -> Vec { self.0 .loaded_plugins() .into_iter() diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index ad23b29b..a56c2e5e 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -13,7 +13,7 @@ use metadata::{ new_location, new_message, new_message_content, new_plugin_cleaning_data, new_plugin_metadata, new_tag, select_message_content, }; -use plugin::{OptionalPluginRef, PluginRef}; +use plugin::{OptionalPlugin, Plugin}; use std::{ error::Error, ffi::{CString, c_char, c_uchar, c_uint, c_void}, @@ -407,10 +407,10 @@ mod ffi { pub fn clear_loaded_plugins(&mut self); // The plugin's lifetime is actually less than &Game's, it's only valid so long as the loaded plugin is not overwritten or cleared. - pub fn plugin(&self, plugin_name: &str) -> Box; + pub fn plugin(&self, plugin_name: &str) -> Box; // The plugin's lifetime is actually less than &Game's, it's only valid so long as the loaded plugin is not overwritten or cleared. - pub unsafe fn loaded_plugins<'a>(&'a self) -> Vec>; + pub fn loaded_plugins(&self) -> Vec; pub fn sort_plugins(&self, plugin_names: &[&str]) -> Result>; @@ -541,19 +541,19 @@ mod ffi { } extern "Rust" { - type PluginRef<'a>; + type Plugin; - pub unsafe fn name<'a>(&'a self) -> &'a str; + pub fn name(&self) -> &str; // The None case is signalled by NaN. pub fn header_version(&self) -> f32; // The None case is signalled by an empty string (which is not a valid version). - pub unsafe fn version<'a>(&'a self) -> &'a str; + pub fn version(&self) -> &str; pub fn masters(&self) -> Result>; - pub unsafe fn bash_tags<'a>(&'a self) -> &'a [String]; + pub fn bash_tags(&self) -> &[String]; // The None case is signalled by -1, all other values fit in u32. pub fn crc(&self) -> i64; @@ -578,18 +578,18 @@ mod ffi { pub fn loads_archive(&self) -> bool; - pub unsafe fn do_records_overlap<'a>(&self, plugin: &PluginRef<'a>) -> Result; + pub fn do_records_overlap(&self, plugin: &Plugin) -> Result; - pub unsafe fn boxed_clone<'a>(&'a self) -> Box>; + pub fn boxed_clone(&self) -> Box; } extern "Rust" { - type OptionalPluginRef; + type OptionalPlugin; pub fn is_some(&self) -> bool; // Again, these lifetimes are wrong. - pub unsafe fn as_ref<'a>(&'a self) -> Result>>; + pub unsafe fn as_ref<'a>(&'a self) -> Result<&'a Plugin>; } extern "Rust" { diff --git a/cxx/src/plugin.rs b/cxx/src/plugin.rs index 59b9267d..54365f84 100644 --- a/cxx/src/plugin.rs +++ b/cxx/src/plugin.rs @@ -1,13 +1,15 @@ +use std::sync::Arc; + use delegate::delegate; -use crate::{EmptyOptionalError, OptionalRef, VerboseError}; +use crate::{Optional, VerboseError}; #[derive(Debug)] #[repr(transparent)] -pub struct PluginRef<'a>(&'a libloot::Plugin); +pub struct Plugin(Arc); -impl<'a> PluginRef<'a> { - pub fn new(plugin: &'a libloot::Plugin) -> Self { +impl Plugin { + pub fn new(plugin: Arc) -> Self { Self(plugin) } @@ -40,11 +42,11 @@ impl<'a> PluginRef<'a> { } pub fn do_records_overlap(&self, plugin: &Self) -> Result { - self.0.do_records_overlap(plugin.0).map_err(Into::into) + self.0.do_records_overlap(&plugin.0).map_err(Into::into) } pub fn boxed_clone(&self) -> Box { - Box::new(Self(self.0)) + Box::new(Self(self.0.clone())) } delegate! { @@ -70,23 +72,16 @@ impl<'a> PluginRef<'a> { } } -impl<'a> From<&'a libloot::Plugin> for PluginRef<'a> { - fn from(value: &'a libloot::Plugin) -> Self { - PluginRef(value) +impl From> for Plugin { + fn from(value: Arc) -> Self { + Plugin(value) } } -pub type OptionalPluginRef = OptionalRef; +pub type OptionalPlugin = Optional; -impl OptionalRef { - /// # Safety - /// - /// This is safe as long as the pointer in the OptionalRef is still valid. - pub unsafe fn as_ref(&self) -> Result>, EmptyOptionalError> { - if self.0.is_null() { - Err(EmptyOptionalError) - } else { - unsafe { Ok(Box::new(PluginRef::new(&*self.0))) } - } +impl From>> for Optional { + fn from(value: Option>) -> Self { + Self(value.map(Into::into)) } } diff --git a/pyo3/README.md b/pyo3/README.md index 771b8fcf..806c886b 100644 --- a/pyo3/README.md +++ b/pyo3/README.md @@ -16,7 +16,7 @@ An **incomplete** and **experimental** Python wrapper around the libloot Rust im - [x] `GameType` - [ ] `LogLevel` - [x] `Database` -- [ ] `Game` +- [x] `Game` - [x] `Plugin` - [x] `Vertex` - [x] `File` diff --git a/pyo3/src/game.rs b/pyo3/src/game.rs index 78459fec..66757372 100644 --- a/pyo3/src/game.rs +++ b/pyo3/src/game.rs @@ -120,15 +120,17 @@ impl Game { self.0.clear_loaded_plugins(); } - // TODO: Game::plugin(). - // fn plugin(&self, plugin_name: &str) -> Option<&Plugin> { - // self.0.plugin(plugin_name).map(Plugin::wrap) - // } + fn plugin(&self, plugin_name: &str) -> Option { + self.0.plugin(plugin_name).map(Into::into) + } - // TODO: Game::loaded_plugins(). - // fn loaded_plugins(&self) -> Vec<&Plugin> { - // wrap_plugins(self.0.loaded_plugins()) - // } + fn loaded_plugins(&self) -> Vec { + self.0 + .loaded_plugins() + .into_iter() + .map(Into::into) + .collect() + } fn sort_plugins(&self, plugin_names: Vec) -> Result, VerboseError> { Ok(self.0.sort_plugins(&as_strs(&plugin_names))?) diff --git a/pyo3/src/plugin.rs b/pyo3/src/plugin.rs index 37ed9ff9..b897cedb 100644 --- a/pyo3/src/plugin.rs +++ b/pyo3/src/plugin.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use pyo3::{pyclass, pymethods}; use crate::VerboseError; @@ -5,7 +7,7 @@ use crate::VerboseError; #[pyclass(eq, frozen)] #[derive(Clone, Debug, Eq, PartialEq)] #[repr(transparent)] -pub struct Plugin(libloot::Plugin); +pub struct Plugin(Arc); #[pymethods] impl Plugin { @@ -78,18 +80,8 @@ impl Plugin { } } -impl Plugin { - pub fn wrap(plugin: &libloot::Plugin) -> &Plugin { - let p = plugin as *const libloot::Plugin; - // SAFETY: Plugin is a transparent wrapper, so reinterpreting the pointer is safe. - unsafe { - let p = p as *const Plugin; - &*p - } +impl From> for Plugin { + fn from(value: Arc) -> Self { + Self(value) } } - -pub fn wrap_plugins(mut vec: Vec<&libloot::Plugin>) -> Vec<&Plugin> { - // SAFETY: This is safe because Plugin is a transparent wrapper around the libloot type. - unsafe { Vec::from_raw_parts(vec.as_mut_ptr().cast(), vec.len(), vec.capacity()) } -} diff --git a/src/game.rs b/src/game.rs index 0de70510..1b6e218c 100644 --- a/src/game.rs +++ b/src/game.rs @@ -335,7 +335,7 @@ impl Game { .cache .plugins() .iter() - .map(|(k, v)| (k.clone(), v)) + .map(|(k, v)| (k.clone(), v.as_ref())) .collect(); for plugin in &plugins { @@ -420,13 +420,13 @@ impl Game { } /// Get data for a loaded plugin. - pub fn plugin(&self, plugin_name: &str) -> Option<&Plugin> { - self.cache.plugin(plugin_name) + pub fn plugin(&self, plugin_name: &str) -> Option> { + self.cache.plugin(plugin_name).cloned() } /// Get data for all loaded plugins. - pub fn loaded_plugins(&self) -> Vec<&Plugin> { - self.cache.plugins_iter().collect() + pub fn loaded_plugins(&self) -> Vec> { + self.cache.plugins_iter().cloned().collect() } /// Calculates a new load order for the game's installed plugins (including @@ -444,7 +444,8 @@ impl Game { let plugins = plugin_names .iter() .map(|n| { - self.plugin(n) + self.cache + .plugin(n) .ok_or_else(|| SortPluginsError::PluginNotLoaded(n.to_string())) }) .collect::, _>>()?; @@ -458,7 +459,7 @@ impl Game { let masterlist_metadata = database.plugin_metadata(p.name(), false, true)?; let user_metadata = database.plugin_user_metadata(p.name(), true)?; let plugin = PluginSortingData::new( - p, + p.as_ref(), masterlist_metadata.as_ref(), user_metadata.as_ref(), i, @@ -711,7 +712,7 @@ fn resolve_plugin_path(game_type: GameType, data_path: &Path, plugin_path: &Path fn update_loaded_plugin_state<'a>( state: &mut loot_condition_interpreter::State, - plugins: impl Iterator, + plugins: impl Iterator>, ) { let mut plugin_versions = Vec::new(); let mut plugin_crcs = Vec::new(); @@ -748,7 +749,7 @@ fn update_loaded_plugin_state<'a>( #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct GameCache { - plugins: HashMap, + plugins: HashMap>, archive_paths: HashSet, } @@ -761,7 +762,7 @@ impl GameCache { fn insert_plugins(&mut self, plugins: Vec) { for plugin in plugins { self.plugins - .insert(Filename::new(plugin.name().to_string()), plugin); + .insert(Filename::new(plugin.name().to_string()), Arc::new(plugin)); } } @@ -769,15 +770,15 @@ impl GameCache { self.plugins.clear(); } - fn plugins(&self) -> &HashMap { + fn plugins(&self) -> &HashMap> { &self.plugins } - fn plugins_iter(&self) -> impl Iterator { + fn plugins_iter(&self) -> impl Iterator> { self.plugins.values() } - fn plugin(&self, plugin_name: &str) -> Option<&Plugin> { + fn plugin(&self, plugin_name: &str) -> Option<&Arc> { self.plugins.get(&Filename::new(plugin_name.to_string())) } From de97b906182fcffd3da7c19965cc5cb2d8d85a3e Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 17 Mar 2025 21:21:51 +0000 Subject: [PATCH 037/206] Improve Python wrapper error handling The new libloot-ffi-errors crate is for sharing code between the CXX and PyO3 wrappers. --- Cargo.lock | 11 +- Cargo.toml | 13 +- cxx/Cargo.toml | 5 +- cxx/src/error_codes.rs | 236 --------------------------------- cxx/src/lib.rs | 34 ++--- ffi-errors/Cargo.toml | 11 ++ ffi-errors/src/esplugin.rs | 65 +++++++++ ffi-errors/src/lci.rs | 45 +++++++ ffi-errors/src/lib.rs | 99 ++++++++++++++ ffi-errors/src/libloadorder.rs | 118 +++++++++++++++++ pyo3/Cargo.toml | 1 + pyo3/README.md | 10 +- pyo3/src/database.rs | 2 +- pyo3/src/error.rs | 208 +++++++++++++++++++++++++++++ pyo3/src/game.rs | 6 +- pyo3/src/lib.rs | 61 ++------- pyo3/src/metadata.rs | 2 +- pyo3/src/plugin.rs | 2 +- 18 files changed, 605 insertions(+), 324 deletions(-) delete mode 100644 cxx/src/error_codes.rs create mode 100644 ffi-errors/Cargo.toml create mode 100644 ffi-errors/src/esplugin.rs create mode 100644 ffi-errors/src/lci.rs create mode 100644 ffi-errors/src/lib.rs create mode 100644 ffi-errors/src/libloadorder.rs create mode 100644 pyo3/src/error.rs diff --git a/Cargo.lock b/Cargo.lock index 8b5f4c29..5ad283a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -600,11 +600,19 @@ dependencies = [ "cxx", "cxx-build", "delegate", + "libloot", + "libloot-ffi-errors", + "unicase", +] + +[[package]] +name = "libloot-ffi-errors" +version = "0.25.5" +dependencies = [ "esplugin", "libloadorder", "libloot", "loot-condition-interpreter", - "unicase", ] [[package]] @@ -612,6 +620,7 @@ name = "libloot_pyo3" version = "0.25.5" dependencies = [ "libloot", + "libloot-ffi-errors", "pyo3", ] diff --git a/Cargo.toml b/Cargo.toml index 30b8dfdd..66bc01c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,10 +6,10 @@ license = "GPL-3.0" [dependencies] crc32fast = "1.4.2" -esplugin = "6.1.1" -libloadorder = { git = "https://github.com/Ortham/libloadorder.git", rev = "6245dbc6824c4751daef5ab01dfe7b9497f5446f" } +esplugin.workspace = true log = { version = "0.4.26", features = ["std"] } -loot-condition-interpreter = { git = "https://github.com/loot/loot-condition-interpreter.git", rev = "f7d9947fa434037743ce81e2d409184ec0bfb693" } +libloadorder.workspace = true +loot-condition-interpreter.workspace = true petgraph = "0.7.1" fancy-regex = "0.14.0" saphyr = "0.0.3" @@ -26,4 +26,9 @@ rstest_reuse = "0.7.0" tempfile = "3.17.1" [workspace] -members = ["cxx", "pyo3"] +members = ["cxx", "ffi-errors", "pyo3"] + +[workspace.dependencies] +esplugin = "6.1.1" +libloadorder = { git = "https://github.com/Ortham/libloadorder.git", rev = "6245dbc6824c4751daef5ab01dfe7b9497f5446f" } +loot-condition-interpreter = { git = "https://github.com/loot/loot-condition-interpreter.git", rev = "f7d9947fa434037743ce81e2d409184ec0bfb693" } diff --git a/cxx/Cargo.toml b/cxx/Cargo.toml index 9e3bfc32..04d3b425 100644 --- a/cxx/Cargo.toml +++ b/cxx/Cargo.toml @@ -8,12 +8,9 @@ license = "GPL-3.0" cxx = { version = "1.0", features = ["c++17"] } delegate = "0.13.2" libloot = { path = ".." } +libloot-ffi-errors = { path = "../ffi-errors" } unicase = "2.8.1" -esplugin = "6.1.1" -libloadorder = { git = "https://github.com/Ortham/libloadorder.git", rev = "6245dbc6824c4751daef5ab01dfe7b9497f5446f" } -loot-condition-interpreter = { git = "https://github.com/loot/loot-condition-interpreter.git", rev = "f7d9947fa434037743ce81e2d409184ec0bfb693" } - [build-dependencies] cxx-build = "1.0" diff --git a/cxx/src/error_codes.rs b/cxx/src/error_codes.rs deleted file mode 100644 index 170b0137..00000000 --- a/cxx/src/error_codes.rs +++ /dev/null @@ -1,236 +0,0 @@ -pub(crate) mod lci { - use loot_condition_interpreter::Error; - use std::ffi::c_int; - - /// Invalid arguments were given for the function. - #[unsafe(no_mangle)] - pub static LCI_ERROR_INVALID_ARGS: c_int = -1; - - /// Something went wrong while parsing the condition expression. - #[unsafe(no_mangle)] - pub static LCI_ERROR_PARSING_ERROR: c_int = -2; - - /// Something went wrong while getting the version of an executable. - #[unsafe(no_mangle)] - pub static LCI_ERROR_PE_PARSING_ERROR: c_int = -3; - - /// Some sort of I/O error occurred. - #[unsafe(no_mangle)] - pub static LCI_ERROR_IO_ERROR: c_int = -4; - - /// Something panicked. - #[unsafe(no_mangle)] - pub static LCI_ERROR_PANICKED: c_int = -5; - - /// A thread lock was poisoned. - #[unsafe(no_mangle)] - pub static LCI_ERROR_POISONED_THREAD_LOCK: c_int = -6; - - /// Failed to encode string as a C string, e.g. because there was a nul present. - #[unsafe(no_mangle)] - pub static LCI_ERROR_TEXT_ENCODE_FAIL: c_int = -7; - - /// The library encountered an error that should not have been possible to encounter. - #[unsafe(no_mangle)] - pub static LCI_ERROR_INTERNAL_LOGIC_ERROR: c_int = -8; - - pub(crate) fn map_error(err: &Error) -> c_int { - match err { - Error::ParsingIncomplete(_) => LCI_ERROR_PARSING_ERROR, - Error::UnconsumedInput(_) => LCI_ERROR_PARSING_ERROR, - Error::ParsingError(_, _) => LCI_ERROR_PARSING_ERROR, - Error::PeParsingError(_, _) => LCI_ERROR_PE_PARSING_ERROR, - Error::IoError(_, _) => LCI_ERROR_IO_ERROR, - _ => LCI_ERROR_INTERNAL_LOGIC_ERROR, - } - } -} - -pub(crate) mod libloadorder { - use loadorder::Error; - use std::ffi::c_uint; - - /// There is a mismatch between the files used to keep track of load order. - /// - /// This warning can only occur when using libloadorder with a game that uses the textfile-based - /// load order system. The load order in the active plugins list file (`plugins.txt`) does not - /// match the load order in the full load order file (`loadorder.txt`). Synchronisation between - /// the two is automatic when load order is managed through libloadorder. It is left to the client - /// to decide how best to restore synchronisation. - #[unsafe(no_mangle)] - pub static LIBLO_WARN_LO_MISMATCH: c_uint = 2; - - /// The specified file could not be found. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_FILE_NOT_FOUND: c_uint = 6; - - /// A file could not be renamed. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_FILE_RENAME_FAIL: c_uint = 7; - - /// There was an error parsing a plugin file. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_FILE_PARSE_FAIL: c_uint = 10; - - /// Invalid arguments were given for the function. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_INVALID_ARGS: c_uint = 12; - - /// A thread lock was poisoned. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_POISONED_THREAD_LOCK: c_uint = 14; - - /// An unknown I/O error occurred. This is used when the I/O error kind doesn't fit another error - /// code. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_IO_ERROR: c_uint = 15; - - /// Permission denied while trying to access a filesystem path. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_IO_PERMISSION_DENIED: c_uint = 16; - - /// A plugin filename contains characters that do not have Windows-1252 code points, or a character - /// string contains a null character. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_TEXT_ENCODE_FAIL: c_uint = 17; - - /// Text expected to be encoded in Windows-1252 could not be decoded to UTF-8. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_TEXT_DECODE_FAIL: c_uint = 18; - - /// The library encountered an error that should not have been possible to encounter. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_INTERNAL_LOGIC_ERROR: c_uint = 19; - - /// Something panicked. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_PANICKED: c_uint = 20; - - /// A path cannot be encoded in UTF-8. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_PATH_ENCODE_FAIL: c_uint = 21; - - /// An unknown operating system error occurred. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_SYSTEM_ERROR: c_uint = 22; - - /// A system path definition (e.g. for local app data on Windows, or $HOME on Linux) could not be - /// found. - #[unsafe(no_mangle)] - pub static LIBLO_ERROR_NO_PATH: c_uint = 23; - - /// Matches the value of the highest-numbered return code. - /// - /// Provided in case clients wish to incorporate additional return codes in their implementation - /// and desire some method of avoiding value conflicts. - #[unsafe(no_mangle)] - pub static LIBLO_RETURN_MAX: c_uint = 23; - - fn map_io_error(err: &std::io::Error) -> c_uint { - use std::io::ErrorKind::*; - match err.kind() { - NotFound => LIBLO_ERROR_FILE_NOT_FOUND, - AlreadyExists => LIBLO_ERROR_FILE_RENAME_FAIL, - PermissionDenied => LIBLO_ERROR_IO_PERMISSION_DENIED, - _ => LIBLO_ERROR_IO_ERROR, - } - } - - pub(crate) fn map_error(err: &Error) -> c_uint { - use Error::*; - match *err { - InvalidPath(_) => LIBLO_ERROR_FILE_NOT_FOUND, - IoError(_, ref x) => map_io_error(x), - NoFilename(_) => LIBLO_ERROR_FILE_PARSE_FAIL, - DecodeError(_) => LIBLO_ERROR_TEXT_DECODE_FAIL, - EncodeError(_) => LIBLO_ERROR_TEXT_ENCODE_FAIL, - PluginParsingError(_, _) => LIBLO_ERROR_FILE_PARSE_FAIL, - PluginNotFound(_) => LIBLO_ERROR_INVALID_ARGS, - TooManyActivePlugins { .. } => LIBLO_ERROR_INVALID_ARGS, - DuplicatePlugin(_) => LIBLO_ERROR_INVALID_ARGS, - NonMasterBeforeMaster { .. } => LIBLO_ERROR_INVALID_ARGS, - InvalidEarlyLoadingPluginPosition { .. } => LIBLO_ERROR_INVALID_ARGS, - ImplicitlyActivePlugin(_) => LIBLO_ERROR_INVALID_ARGS, - NoLocalAppData => LIBLO_ERROR_INVALID_ARGS, - NoDocumentsPath => LIBLO_ERROR_INVALID_ARGS, - NoUserConfigPath => LIBLO_ERROR_NO_PATH, - NoUserDataPath => LIBLO_ERROR_NO_PATH, - NoProgramFilesPath => LIBLO_ERROR_NO_PATH, - UnrepresentedHoist { .. } => LIBLO_ERROR_INVALID_ARGS, - InstalledPlugin(_) => LIBLO_ERROR_INVALID_ARGS, - IniParsingError { .. } => LIBLO_ERROR_FILE_PARSE_FAIL, - VdfParsingError(_, _) => LIBLO_ERROR_FILE_PARSE_FAIL, - SystemError(_, _) => LIBLO_ERROR_SYSTEM_ERROR, - InvalidBlueprintPluginPosition { .. } => LIBLO_ERROR_INVALID_ARGS, - _ => LIBLO_ERROR_INTERNAL_LOGIC_ERROR, - } - } -} - -pub(crate) mod esplugin { - use std::ffi::c_uint; - - use esplugin::Error; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_NULL_POINTER: u32 = 1; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_NOT_UTF8: u32 = 2; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_STRING_CONTAINS_NUL: u32 = 3; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_INVALID_GAME_ID: u32 = 4; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_PARSE_ERROR: u32 = 5; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_PANICKED: u32 = 6; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_NO_FILENAME: u32 = 7; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_TEXT_DECODE_ERROR: u32 = 8; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_TEXT_ENCODE_ERROR: u32 = 9; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_IO_ERROR: u32 = 10; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_FILE_NOT_FOUND: u32 = 11; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_IO_PERMISSION_DENIED: u32 = 12; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_UNRESOLVED_RECORD_IDS: u32 = 13; - - #[unsafe(no_mangle)] - pub static ESP_ERROR_PLUGIN_METADATA_NOT_FOUND: u32 = 14; - - fn map_io_error(err: &std::io::Error) -> c_uint { - match err.kind() { - std::io::ErrorKind::NotFound => ESP_ERROR_FILE_NOT_FOUND, - std::io::ErrorKind::PermissionDenied => ESP_ERROR_IO_PERMISSION_DENIED, - _ => ESP_ERROR_IO_ERROR, - } - } - - pub(crate) fn map_error(err: &Error) -> c_uint { - match *err { - Error::IoError(ref x) => map_io_error(x), - Error::NoFilename(_) => ESP_ERROR_NO_FILENAME, - Error::ParsingIncomplete(_) => ESP_ERROR_PARSE_ERROR, - Error::ParsingError(_, _) => ESP_ERROR_PARSE_ERROR, - Error::DecodeError(_) => ESP_ERROR_TEXT_DECODE_ERROR, - Error::UnresolvedRecordIds(_) => ESP_ERROR_UNRESOLVED_RECORD_IDS, - Error::PluginMetadataNotFound(_) => ESP_ERROR_PLUGIN_METADATA_NOT_FOUND, - } - } -} diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index a56c2e5e..27012327 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -1,11 +1,11 @@ mod database; -mod error_codes; mod game; mod metadata; mod plugin; use database::{Database, Vertex, new_vertex}; use game::{Game, NotValidUtf8, new_game, new_game_with_local_path}; +use libloot_ffi_errors::SystemError; use metadata::{ File, Filename, Group, Location, Message, MessageContent, OptionalMessageContentRef, OptionalPluginMetadata, PluginCleaningData, PluginMetadata, Tag, group_default_name, @@ -15,7 +15,6 @@ use metadata::{ }; use plugin::{OptionalPlugin, Plugin}; use std::{ - error::Error, ffi::{CString, c_char, c_uchar, c_uint, c_void}, sync::{Mutex, atomic::AtomicPtr}, }; @@ -38,8 +37,8 @@ pub use libloot::{is_compatible, libloot_revision, libloot_version}; pub enum VerboseError { CyclicInteractionError(Vec), UndefinedGroupError(String), - EspluginError(u32, String), - LibloadorderError(u32, String), + EspluginError(i32, String), + LibloadorderError(i32, String), LciError(i32, String), FileAccessError(String), InvalidArgument(String), @@ -148,13 +147,8 @@ impl From for VerboseError { impl From for VerboseError { fn from(value: LoadOrderError) -> Self { - let error = value - .source() - .expect("LoadOrderError has source") - .downcast_ref::() - .expect("LoadOrderError source is a loadorder::Error"); - let error_code = error_codes::libloadorder::map_error(error); - Self::LibloadorderError(error_code, error.to_string()) + let error = SystemError::from(value); + Self::LibloadorderError(error.code(), error.message().to_string()) } } @@ -172,13 +166,8 @@ impl From for VerboseError { impl From for VerboseError { fn from(value: ConditionEvaluationError) -> Self { - let error = value - .source() - .expect("LoadOrderError has source") - .downcast_ref::() - .expect("LoadOrderError source is a loot_condition_interpreter::Error"); - let error_code = error_codes::lci::map_error(error); - Self::LciError(error_code, error.to_string()) + let error = SystemError::from(value); + Self::LciError(error.code(), error.message().to_string()) } } @@ -203,13 +192,8 @@ impl From for VerboseError { impl From for VerboseError { fn from(value: PluginDataError) -> Self { - let error = value - .source() - .expect("LoadOrderError has source") - .downcast_ref::() - .expect("LoadOrderError source is an esplugin::Error"); - let error_code = error_codes::esplugin::map_error(error); - Self::EspluginError(error_code, error.to_string()) + let error = SystemError::from(value); + Self::EspluginError(error.code(), error.message().to_string()) } } diff --git a/ffi-errors/Cargo.toml b/ffi-errors/Cargo.toml new file mode 100644 index 00000000..1cc1b72f --- /dev/null +++ b/ffi-errors/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "libloot-ffi-errors" +version = "0.25.5" +edition = "2024" + +[dependencies] +libloot = { path = ".." } + +esplugin.workspace = true +libloadorder.workspace = true +loot-condition-interpreter.workspace = true diff --git a/ffi-errors/src/esplugin.rs b/ffi-errors/src/esplugin.rs new file mode 100644 index 00000000..71ccba5c --- /dev/null +++ b/ffi-errors/src/esplugin.rs @@ -0,0 +1,65 @@ +use std::ffi::c_int; + +use esplugin::Error; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_NULL_POINTER: c_int = 1; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_NOT_UTF8: c_int = 2; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_STRING_CONTAINS_NUL: c_int = 3; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_INVALID_GAME_ID: c_int = 4; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_PARSE_ERROR: c_int = 5; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_PANICKED: c_int = 6; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_NO_FILENAME: c_int = 7; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_TEXT_DECODE_ERROR: c_int = 8; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_TEXT_ENCODE_ERROR: c_int = 9; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_IO_ERROR: c_int = 10; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_FILE_NOT_FOUND: c_int = 11; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_IO_PERMISSION_DENIED: c_int = 12; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_UNRESOLVED_RECORD_IDS: c_int = 13; + +#[unsafe(no_mangle)] +pub static ESP_ERROR_PLUGIN_METADATA_NOT_FOUND: c_int = 14; + +fn map_io_error(err: &std::io::Error) -> c_int { + match err.kind() { + std::io::ErrorKind::NotFound => ESP_ERROR_FILE_NOT_FOUND, + std::io::ErrorKind::PermissionDenied => ESP_ERROR_IO_PERMISSION_DENIED, + _ => ESP_ERROR_IO_ERROR, + } +} + +pub fn map_error(err: &Error) -> c_int { + match *err { + Error::IoError(ref x) => map_io_error(x), + Error::NoFilename(_) => ESP_ERROR_NO_FILENAME, + Error::ParsingIncomplete(_) => ESP_ERROR_PARSE_ERROR, + Error::ParsingError(_, _) => ESP_ERROR_PARSE_ERROR, + Error::DecodeError(_) => ESP_ERROR_TEXT_DECODE_ERROR, + Error::UnresolvedRecordIds(_) => ESP_ERROR_UNRESOLVED_RECORD_IDS, + Error::PluginMetadataNotFound(_) => ESP_ERROR_PLUGIN_METADATA_NOT_FOUND, + } +} diff --git a/ffi-errors/src/lci.rs b/ffi-errors/src/lci.rs new file mode 100644 index 00000000..d2475d50 --- /dev/null +++ b/ffi-errors/src/lci.rs @@ -0,0 +1,45 @@ +use loot_condition_interpreter::Error; +use std::ffi::c_int; + +/// Invalid arguments were given for the function. +#[unsafe(no_mangle)] +pub static LCI_ERROR_INVALID_ARGS: c_int = -1; + +/// Something went wrong while parsing the condition expression. +#[unsafe(no_mangle)] +pub static LCI_ERROR_PARSING_ERROR: c_int = -2; + +/// Something went wrong while getting the version of an executable. +#[unsafe(no_mangle)] +pub static LCI_ERROR_PE_PARSING_ERROR: c_int = -3; + +/// Some sort of I/O error occurred. +#[unsafe(no_mangle)] +pub static LCI_ERROR_IO_ERROR: c_int = -4; + +/// Something panicked. +#[unsafe(no_mangle)] +pub static LCI_ERROR_PANICKED: c_int = -5; + +/// A thread lock was poisoned. +#[unsafe(no_mangle)] +pub static LCI_ERROR_POISONED_THREAD_LOCK: c_int = -6; + +/// Failed to encode string as a C string, e.g. because there was a nul present. +#[unsafe(no_mangle)] +pub static LCI_ERROR_TEXT_ENCODE_FAIL: c_int = -7; + +/// The library encountered an error that should not have been possible to encounter. +#[unsafe(no_mangle)] +pub static LCI_ERROR_INTERNAL_LOGIC_ERROR: c_int = -8; + +pub fn map_error(err: &Error) -> c_int { + match err { + Error::ParsingIncomplete(_) => LCI_ERROR_PARSING_ERROR, + Error::UnconsumedInput(_) => LCI_ERROR_PARSING_ERROR, + Error::ParsingError(_, _) => LCI_ERROR_PARSING_ERROR, + Error::PeParsingError(_, _) => LCI_ERROR_PE_PARSING_ERROR, + Error::IoError(_, _) => LCI_ERROR_IO_ERROR, + _ => LCI_ERROR_INTERNAL_LOGIC_ERROR, + } +} diff --git a/ffi-errors/src/lib.rs b/ffi-errors/src/lib.rs new file mode 100644 index 00000000..34b09daa --- /dev/null +++ b/ffi-errors/src/lib.rs @@ -0,0 +1,99 @@ +use std::{ + error::Error, + ffi::{c_int, c_uchar}, +}; + +use libloot::error::{ConditionEvaluationError, LoadOrderError, PluginDataError}; + +pub mod esplugin; +pub mod lci; +pub mod libloadorder; + +/// An error from esplugin. +#[unsafe(no_mangle)] +pub static LIBLOOT_SYSTEM_ERROR_CATEGORY_ESPLUGIN: c_uchar = 1; + +/// An error from libloadorder. +#[unsafe(no_mangle)] +pub static LIBLOOT_SYSTEM_ERROR_CATEGORY_LIBLOADORDER: c_uchar = 2; + +/// An error from loot-condition-interpreter. +#[unsafe(no_mangle)] +pub static LIBLOOT_SYSTEM_ERROR_CATEGORY_LCI: c_uchar = 3; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(u8)] +pub enum SystemErrorCategory { + Esplugin = LIBLOOT_SYSTEM_ERROR_CATEGORY_ESPLUGIN, + Libloadorder = LIBLOOT_SYSTEM_ERROR_CATEGORY_LIBLOADORDER, + LootConditionInterpreter = LIBLOOT_SYSTEM_ERROR_CATEGORY_LCI, +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct SystemError { + code: c_int, + category: SystemErrorCategory, + message: String, +} + +impl SystemError { + pub fn code(&self) -> c_int { + self.code + } + + pub fn category(&self) -> SystemErrorCategory { + self.category + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl From for SystemError { + fn from(value: PluginDataError) -> Self { + let error = value + .source() + .expect("LoadOrderError has source") + .downcast_ref::<::esplugin::Error>() + .expect("LoadOrderError source is an esplugin::Error"); + let error_code = crate::esplugin::map_error(error); + SystemError { + code: error_code, + category: SystemErrorCategory::Esplugin, + message: error.to_string(), + } + } +} + +impl From for SystemError { + fn from(value: LoadOrderError) -> Self { + let error = value + .source() + .expect("LoadOrderError has source") + .downcast_ref::() + .expect("LoadOrderError source is a loadorder::Error"); + let error_code = crate::libloadorder::map_error(error); + SystemError { + code: error_code, + category: SystemErrorCategory::Libloadorder, + message: error.to_string(), + } + } +} + +impl From for SystemError { + fn from(value: ConditionEvaluationError) -> Self { + let error = value + .source() + .expect("ConditionEvaluationError has source") + .downcast_ref::() + .expect("ConditionEvaluationError source is a loot_condition_interpreter::Error"); + let error_code = crate::lci::map_error(error); + SystemError { + code: error_code, + category: SystemErrorCategory::LootConditionInterpreter, + message: error.to_string(), + } + } +} diff --git a/ffi-errors/src/libloadorder.rs b/ffi-errors/src/libloadorder.rs new file mode 100644 index 00000000..dcd23a09 --- /dev/null +++ b/ffi-errors/src/libloadorder.rs @@ -0,0 +1,118 @@ +use loadorder::Error; +use std::ffi::c_int; + +/// There is a mismatch between the files used to keep track of load order. +/// +/// This warning can only occur when using libloadorder with a game that uses the textfile-based +/// load order system. The load order in the active plugins list file (`plugins.txt`) does not +/// match the load order in the full load order file (`loadorder.txt`). Synchronisation between +/// the two is automatic when load order is managed through libloadorder. It is left to the client +/// to decide how best to restore synchronisation. +#[unsafe(no_mangle)] +pub static LIBLO_WARN_LO_MISMATCH: c_int = 2; + +/// The specified file could not be found. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_FILE_NOT_FOUND: c_int = 6; + +/// A file could not be renamed. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_FILE_RENAME_FAIL: c_int = 7; + +/// There was an error parsing a plugin file. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_FILE_PARSE_FAIL: c_int = 10; + +/// Invalid arguments were given for the function. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_INVALID_ARGS: c_int = 12; + +/// A thread lock was poisoned. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_POISONED_THREAD_LOCK: c_int = 14; + +/// An unknown I/O error occurred. This is used when the I/O error kind doesn't fit another error +/// code. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_IO_ERROR: c_int = 15; + +/// Permission denied while trying to access a filesystem path. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_IO_PERMISSION_DENIED: c_int = 16; + +/// A plugin filename contains characters that do not have Windows-1252 code points, or a character +/// string contains a null character. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_TEXT_ENCODE_FAIL: c_int = 17; + +/// Text expected to be encoded in Windows-1252 could not be decoded to UTF-8. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_TEXT_DECODE_FAIL: c_int = 18; + +/// The library encountered an error that should not have been possible to encounter. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_INTERNAL_LOGIC_ERROR: c_int = 19; + +/// Something panicked. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_PANICKED: c_int = 20; + +/// A path cannot be encoded in UTF-8. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_PATH_ENCODE_FAIL: c_int = 21; + +/// An unknown operating system error occurred. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_SYSTEM_ERROR: c_int = 22; + +/// A system path definition (e.g. for local app data on Windows, or $HOME on Linux) could not be +/// found. +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_NO_PATH: c_int = 23; + +/// Matches the value of the highest-numbered return code. +/// +/// Provided in case clients wish to incorporate additional return codes in their implementation +/// and desire some method of avoiding value conflicts. +#[unsafe(no_mangle)] +pub static LIBLO_RETURN_MAX: c_int = 23; + +fn map_io_error(err: &std::io::Error) -> c_int { + use std::io::ErrorKind::*; + match err.kind() { + NotFound => LIBLO_ERROR_FILE_NOT_FOUND, + AlreadyExists => LIBLO_ERROR_FILE_RENAME_FAIL, + PermissionDenied => LIBLO_ERROR_IO_PERMISSION_DENIED, + _ => LIBLO_ERROR_IO_ERROR, + } +} + +pub fn map_error(err: &Error) -> c_int { + use Error::*; + match *err { + InvalidPath(_) => LIBLO_ERROR_FILE_NOT_FOUND, + IoError(_, ref x) => map_io_error(x), + NoFilename(_) => LIBLO_ERROR_FILE_PARSE_FAIL, + DecodeError(_) => LIBLO_ERROR_TEXT_DECODE_FAIL, + EncodeError(_) => LIBLO_ERROR_TEXT_ENCODE_FAIL, + PluginParsingError(_, _) => LIBLO_ERROR_FILE_PARSE_FAIL, + PluginNotFound(_) => LIBLO_ERROR_INVALID_ARGS, + TooManyActivePlugins { .. } => LIBLO_ERROR_INVALID_ARGS, + DuplicatePlugin(_) => LIBLO_ERROR_INVALID_ARGS, + NonMasterBeforeMaster { .. } => LIBLO_ERROR_INVALID_ARGS, + InvalidEarlyLoadingPluginPosition { .. } => LIBLO_ERROR_INVALID_ARGS, + ImplicitlyActivePlugin(_) => LIBLO_ERROR_INVALID_ARGS, + NoLocalAppData => LIBLO_ERROR_INVALID_ARGS, + NoDocumentsPath => LIBLO_ERROR_INVALID_ARGS, + NoUserConfigPath => LIBLO_ERROR_NO_PATH, + NoUserDataPath => LIBLO_ERROR_NO_PATH, + NoProgramFilesPath => LIBLO_ERROR_NO_PATH, + UnrepresentedHoist { .. } => LIBLO_ERROR_INVALID_ARGS, + InstalledPlugin(_) => LIBLO_ERROR_INVALID_ARGS, + IniParsingError { .. } => LIBLO_ERROR_FILE_PARSE_FAIL, + VdfParsingError(_, _) => LIBLO_ERROR_FILE_PARSE_FAIL, + SystemError(_, _) => LIBLO_ERROR_SYSTEM_ERROR, + InvalidBlueprintPluginPosition { .. } => LIBLO_ERROR_INVALID_ARGS, + _ => LIBLO_ERROR_INTERNAL_LOGIC_ERROR, + } +} diff --git a/pyo3/Cargo.toml b/pyo3/Cargo.toml index d3bf2db9..5bb65565 100644 --- a/pyo3/Cargo.toml +++ b/pyo3/Cargo.toml @@ -10,4 +10,5 @@ crate-type = ["cdylib"] [dependencies] libloot = { path = ".." } +libloot-ffi-errors = { path = "../ffi-errors" } pyo3 = "0.24.0" diff --git a/pyo3/README.md b/pyo3/README.md index 806c886b..cca22f10 100644 --- a/pyo3/README.md +++ b/pyo3/README.md @@ -31,8 +31,7 @@ An **incomplete** and **experimental** Python wrapper around the libloot Rust im - [x] `MessageType` - [x] `TagSuggestion` - [x] `select_message_content()` -- [ ] Error types - - All errors are currently raised as Python `ValueError` values that contain the full detail of the error. +- [x] Error types ## Build @@ -58,3 +57,10 @@ Then import it in Python: py > import loot ``` + +## Usage notes + +- The Python exceptions that errors are mapped to are not the same as in the Rust or C++ interfaces: + - The API provides the custom `CyclicInteractionError`, `UndefinedGroupError`, `EspluginError` exception types. + - All other errors are raised as `ValueError` exceptions. + - There's no equivalent to the C++ interface's `FileAccessError` or `ConditionSyntaxError` classes or the libloadorder and loot-condition-interpreter system error categories. diff --git a/pyo3/src/database.rs b/pyo3/src/database.rs index 59ed3550..003f9eb3 100644 --- a/pyo3/src/database.rs +++ b/pyo3/src/database.rs @@ -11,7 +11,7 @@ use pyo3::{ }; use crate::{ - UnsupportedEnumValueError, VerboseError, + error::{UnsupportedEnumValueError, VerboseError}, metadata::{Group, Message, NONE_REPR, PluginMetadata}, }; diff --git a/pyo3/src/error.rs b/pyo3/src/error.rs new file mode 100644 index 00000000..119a4afa --- /dev/null +++ b/pyo3/src/error.rs @@ -0,0 +1,208 @@ +use libloot::{ + error::{ + ConditionEvaluationError, DatabaseLockPoisonError, GameHandleCreationError, + GroupsPathError, LoadOrderError, LoadOrderStateError, LoadPluginsError, + MetadataRetrievalError, PluginDataError, SortPluginsError, + }, + metadata::error::{ + LoadMetadataError, MultilingualMessageContentsError, RegexError, WriteMetadataError, + }, +}; +use libloot_ffi_errors::SystemError; +use pyo3::{PyErr, exceptions::PyValueError}; + +use crate::{CyclicInteractionError, EspluginError, UndefinedGroupError, database::Vertex}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct UnsupportedEnumValueError; + +impl std::fmt::Display for UnsupportedEnumValueError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Enum value is unsupported") + } +} + +impl std::error::Error for UnsupportedEnumValueError {} + +impl From for PyErr { + fn from(value: UnsupportedEnumValueError) -> Self { + PyValueError::new_err(value.to_string()) + } +} + +#[derive(Debug)] +pub enum VerboseError { + CyclicInteractionError(Vec), + UndefinedGroupError(String), + EspluginError(i32, String), + Other(Box), +} + +impl std::fmt::Display for VerboseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CyclicInteractionError(c) => { + let cycle = display_cycle(c); + write!(f, "cyclic interaction detected: {}", cycle) + } + Self::UndefinedGroupError(g) => { + write!(f, "the group \"{}\" does not exist", g) + } + Self::EspluginError(_, s) => s.fmt(f), + Self::Other(e) => { + write!(f, "{}", e)?; + let mut error = e.as_ref(); + while let Some(source) = error.source() { + write!(f, ": {}", source)?; + error = source; + } + Ok(()) + } + } + } +} + +impl From for VerboseError { + fn from(value: GameHandleCreationError) -> Self { + match value { + GameHandleCreationError::LoadOrderError(e) => e.into(), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: UnsupportedEnumValueError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: DatabaseLockPoisonError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: LoadPluginsError) -> Self { + match value { + LoadPluginsError::PluginDataError(e) => e.into(), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: SortPluginsError) -> Self { + match value { + SortPluginsError::MetadataRetrievalError(e) => e.into(), + SortPluginsError::UndefinedGroup(g) => Self::UndefinedGroupError(g), + SortPluginsError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), + SortPluginsError::PluginDataError(e) => e.into(), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: LoadOrderStateError) -> Self { + match value { + LoadOrderStateError::LoadOrderError(e) => e.into(), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: LoadOrderError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: LoadMetadataError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: WriteMetadataError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: ConditionEvaluationError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: GroupsPathError) -> Self { + match value { + GroupsPathError::UndefinedGroup(g) => Self::UndefinedGroupError(g), + GroupsPathError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: MetadataRetrievalError) -> Self { + match value { + MetadataRetrievalError::ConditionEvaluationError(e) => e.into(), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: PluginDataError) -> Self { + let error = SystemError::from(value); + Self::EspluginError(error.code(), error.message().to_string()) + } +} + +impl From for VerboseError { + fn from(value: MultilingualMessageContentsError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: RegexError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for PyErr { + fn from(value: VerboseError) -> Self { + let message = value.to_string(); + + match value { + VerboseError::CyclicInteractionError(c) => PyErr::new::(( + c.into_iter().map(Vertex::from).collect::>(), + message, + )), + VerboseError::UndefinedGroupError(g) => { + PyErr::new::((g, message)) + } + VerboseError::EspluginError(i, s) => PyErr::new::((i, s)), + VerboseError::Other(_) => PyValueError::new_err(message), + } + } +} + +fn display_cycle(cycle: &[libloot::Vertex]) -> String { + cycle + .iter() + .map(|v| { + if let Some(edge_type) = v.out_edge_type() { + format!("{} --[{}]-> ", v.name(), edge_type) + } else { + v.name().to_string() + } + }) + .chain(cycle.first().iter().map(|v| v.name().to_string())) + .collect() +} diff --git a/pyo3/src/game.rs b/pyo3/src/game.rs index 66757372..0bca69c2 100644 --- a/pyo3/src/game.rs +++ b/pyo3/src/game.rs @@ -2,7 +2,11 @@ use std::path::{Path, PathBuf}; use pyo3::{pyclass, pymethods}; -use crate::{UnsupportedEnumValueError, VerboseError, database::Database, plugin::Plugin}; +use crate::{ + database::Database, + error::{UnsupportedEnumValueError, VerboseError}, + plugin::Plugin, +}; #[allow(non_camel_case_types)] #[pyclass(eq, frozen, hash, ord)] diff --git a/pyo3/src/lib.rs b/pyo3/src/lib.rs index 9eb43ed1..8a871ee5 100644 --- a/pyo3/src/lib.rs +++ b/pyo3/src/lib.rs @@ -1,4 +1,5 @@ mod database; +mod error; mod game; mod metadata; mod plugin; @@ -10,7 +11,7 @@ use metadata::{ TagSuggestion, select_message_content, }; use plugin::Plugin; -use pyo3::{exceptions::PyValueError, prelude::*}; +use pyo3::{create_exception, exceptions::PyException, prelude::*}; #[pyfunction] fn is_compatible(major: u32, minor: u32, patch: u32) -> bool { @@ -27,56 +28,13 @@ fn libloot_version() -> String { libloot::libloot_version() } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] -pub struct UnsupportedEnumValueError; - -impl std::fmt::Display for UnsupportedEnumValueError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Enum value is unsupported") - } -} - -impl std::error::Error for UnsupportedEnumValueError {} - -impl From for PyErr { - fn from(value: UnsupportedEnumValueError) -> Self { - PyValueError::new_err(value.to_string()) - } -} - -#[derive(Debug)] -pub struct VerboseError(Box); - -impl std::fmt::Display for VerboseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let e = &self.0; - write!(f, "{}", e)?; - let mut error = e.as_ref(); - while let Some(source) = error.source() { - write!(f, ": {}", source)?; - error = source; - } - Ok(()) - } -} - -// impl std::error::Error for VerboseError {} - -impl From for VerboseError { - fn from(value: T) -> Self { - VerboseError(Box::new(value)) - } -} - -impl From for PyErr { - fn from(value: VerboseError) -> Self { - PyValueError::new_err(value.to_string()) - } -} +create_exception!(loot, CyclicInteractionError, PyException); +create_exception!(loot, UndefinedGroupError, PyException); +create_exception!(loot, EspluginError, PyException); /// A Python module implemented in Rust. #[pymodule(name = "loot")] -fn libloot_pyo3(m: &Bound<'_, PyModule>) -> PyResult<()> { +fn libloot_pyo3(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("LIBLOOT_VERSION_MAJOR", libloot::LIBLOOT_VERSION_MAJOR)?; m.add("LIBLOOT_VERSION_MINOR", libloot::LIBLOOT_VERSION_MINOR)?; m.add("LIBLOOT_VERSION_PATCH", libloot::LIBLOOT_VERSION_PATCH)?; @@ -104,5 +62,12 @@ fn libloot_pyo3(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; + m.add( + "CyclicInteractionError", + py.get_type::(), + )?; + m.add("UndefinedGroupError", py.get_type::())?; + m.add("EspluginError", py.get_type::())?; + Ok(()) } diff --git a/pyo3/src/metadata.rs b/pyo3/src/metadata.rs index 3537210c..9c82e88b 100644 --- a/pyo3/src/metadata.rs +++ b/pyo3/src/metadata.rs @@ -5,7 +5,7 @@ use pyo3::{ types::{PyAnyMethods, PyTypeMethods}, }; -use crate::{UnsupportedEnumValueError, VerboseError}; +use crate::error::{UnsupportedEnumValueError, VerboseError}; pub const NONE_REPR: &str = "None"; diff --git a/pyo3/src/plugin.rs b/pyo3/src/plugin.rs index b897cedb..94eafcd9 100644 --- a/pyo3/src/plugin.rs +++ b/pyo3/src/plugin.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use pyo3::{pyclass, pymethods}; -use crate::VerboseError; +use crate::error::VerboseError; #[pyclass(eq, frozen)] #[derive(Clone, Debug, Eq, PartialEq)] From 3b6752843afa86c2656f520e65c2de02b5194093 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 17 Mar 2025 21:30:22 +0000 Subject: [PATCH 038/206] Use faster hashing algorithm in sorting code The default std hasher is DoS-resistant but relatively slow, and DoS-resistance isn't a useful property during sorting. I tested using the rustc-hash, fnv and ahash crates against my 1600-plugin load order, and the results vs the default std hasher were: - AHash: 31% faster - FNV: 33% faster - FxHasher (from rustc-hash): 37% faster With the std hasher the Rust libloot is 35% slower than C++, with FxHasher it's 15% faster. --- Cargo.lock | 7 +++++++ Cargo.toml | 1 + src/sorting/dfs.rs | 11 +++++++---- src/sorting/groups.rs | 8 +++++--- src/sorting/plugins.rs | 27 +++++++++++++++------------ 5 files changed, 35 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5ad283a1..591e82ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,6 +586,7 @@ dependencies = [ "rayon", "rstest", "rstest_reuse", + "rustc-hash", "saphyr", "saphyr-parser", "tempfile", @@ -1076,6 +1077,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 66bc01c3..1b719d6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ saphyr = "0.0.3" saphyr-parser = "0.0.3" unicase = "2.8.1" rayon = "1.10.0" +rustc-hash = "2.1.1" [target.'cfg(windows)'.dependencies] windows = { version = "0.60.0", features = ["Win32_Storage_FileSystem"] } diff --git a/src/sorting/dfs.rs b/src/sorting/dfs.rs index 3476a74b..dee6798e 100644 --- a/src/sorting/dfs.rs +++ b/src/sorting/dfs.rs @@ -1,10 +1,11 @@ -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::VecDeque; use petgraph::{ Graph, graph::{EdgeReference, NodeIndex}, visit::EdgeRef, }; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use crate::{EdgeType, Vertex, logging}; @@ -36,8 +37,10 @@ pub fn bidirectional_bfs( ) -> bool { let mut forward_queue = VecDeque::from([from_index]); let mut reverse_queue = VecDeque::from([to_index]); - let mut forward_visited = HashSet::from([from_index]); - let mut reverse_visited = HashSet::from([to_index]); + let mut forward_visited = HashSet::default(); + forward_visited.insert(from_index); + let mut reverse_visited = HashSet::default(); + reverse_visited.insert(to_index); while let (Some(forward_current), Some(reverse_current)) = (forward_queue.pop_front(), reverse_queue.pop_front()) @@ -81,7 +84,7 @@ pub fn find_cycle( ) -> Option> { let mut cycle_detector = CycleDetector::new(graph, node_mapper); - let mut colour_map = HashMap::new(); + let mut colour_map = HashMap::default(); for node_index in graph.node_indices() { depth_first_search(graph, &mut colour_map, node_index, &mut cycle_detector); diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs index 09dec17d..feba93b6 100644 --- a/src/sorting/groups.rs +++ b/src/sorting/groups.rs @@ -1,4 +1,6 @@ -use std::{cmp::Reverse, collections::HashMap}; +use std::cmp::Reverse; + +use rustc_hash::FxHashMap as HashMap; use petgraph::{Graph, algo::bellman_ford, graph::NodeIndex}; @@ -29,7 +31,7 @@ pub fn build_groups_graph( let userlist_groups = sorted_by_name(userlist_groups); let mut graph = GroupsGraph::new(); - let mut group_nodes: HashMap<&str, NodeIndex> = HashMap::new(); + let mut group_nodes: HashMap<&str, NodeIndex> = HashMap::default(); logging::trace!("Adding masterlist groups to groups graph..."); add_groups( @@ -206,7 +208,7 @@ pub fn sorted_group_nodes(graph: &GroupsGraph) -> Vec { if is_root_node(graph, n) { let mut visitor = GroupsPathLengthVisitor::new(); - depth_first_search(graph, &mut HashMap::new(), n, &mut visitor); + depth_first_search(graph, &mut HashMap::default(), n, &mut visitor); (n, true, visitor.max_path_length()) } else { diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index c2c408aa..9b5a190b 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -1,13 +1,11 @@ -use std::{ - collections::{HashMap, HashSet}, - rc::Rc, -}; +use std::rc::Rc; use petgraph::{ Graph, graph::{EdgeReference, NodeIndex}, visit::EdgeRef, }; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use crate::{ EdgeType, LogLevel, Plugin, @@ -308,7 +306,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { // Keep a record of which vertices have already been fully explored to avoid // adding edges from their plugins more than once. - let mut finished_nodes = HashSet::new(); + let mut finished_nodes = HashSet::default(); // Now loop over the vertices in the groups graph. // The vertex sort order prioritises resolving potential cycles in // favour of earlier-loading groups. It does not guarantee that the @@ -330,7 +328,12 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { Some(default_group_node), ); - depth_first_search(groups_graph, &mut HashMap::new(), group_node, &mut visitor); + depth_first_search( + groups_graph, + &mut HashMap::default(), + group_node, + &mut visitor, + ); } // Now do one last DFS starting from the default group and not ignoring its @@ -345,7 +348,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { depth_first_search( groups_graph, - &mut HashMap::new(), + &mut HashMap::default(), default_group_node, &mut visitor, ); @@ -496,7 +499,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { let mut new_load_order: Vec = Vec::new(); // Holds nodes that have already been put into new_load_order. - let mut processed_nodes = HashSet::new(); + let mut processed_nodes = HashSet::default(); // First get the graph vertices and sort them into the current load order. let mut nodes: Vec<_> = self.node_indices().collect(); @@ -897,8 +900,8 @@ impl<'a, 'b, T: SortingPlugin> PathFinder<'a, 'b, T> { cache, from_node_index, to_node_index, - forward_parents: HashMap::new(), - reverse_children: HashMap::new(), + forward_parents: HashMap::default(), + reverse_children: HashMap::default(), intersection_node: None, } } @@ -985,7 +988,7 @@ struct PathCacher<'a> { fn get_plugins_in_groups( graph: &InnerPluginsGraph, ) -> HashMap> { - let mut plugins_in_groups: HashMap> = HashMap::new(); + let mut plugins_in_groups: HashMap> = HashMap::default(); for node in graph.node_indices() { let group_name = graph[node].group.clone(); @@ -1066,7 +1069,7 @@ impl<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, finished_group_vertices, group_node_to_ignore_as_source, edge_stack: Vec::new(), - unfinishable_nodes: HashSet::new(), + unfinishable_nodes: HashSet::default(), } } From 86063e437a548fd36de8f51f76c73edf33310a2e Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 17 Mar 2025 21:31:59 +0000 Subject: [PATCH 039/206] Tweak Cargo profiles Include limited debug info in release builds to help with profiling. Also add another profile that's equivalent to CMake's RelWithDebInfo. Testing suggests that although it uses a lower opt-level, it might actually perform better (at least on Windows), though the differences are only ~ 100 ms in startup and sorting times. --- Cargo.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 1b719d6e..4b79a434 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,3 +33,11 @@ members = ["cxx", "ffi-errors", "pyo3"] esplugin = "6.1.1" libloadorder = { git = "https://github.com/Ortham/libloadorder.git", rev = "6245dbc6824c4751daef5ab01dfe7b9497f5446f" } loot-condition-interpreter = { git = "https://github.com/loot/loot-condition-interpreter.git", rev = "f7d9947fa434037743ce81e2d409184ec0bfb693" } + +[profile.release] +debug = "limited" + +[profile.rel-with-deb-info] +inherits = "release" +opt-level = 2 +debug = "limited" From 9912bb7bcb0a02d5cc58baf9bb7e570b894a99a1 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 17 Mar 2025 22:01:35 +0000 Subject: [PATCH 040/206] Refactor CXX errors --- cxx/src/error.rs | 211 ++++++++++++++++++++++++++++++++++++++++++++++ cxx/src/lib.rs | 215 +---------------------------------------------- 2 files changed, 215 insertions(+), 211 deletions(-) create mode 100644 cxx/src/error.rs diff --git a/cxx/src/error.rs b/cxx/src/error.rs new file mode 100644 index 00000000..d9bd7f1a --- /dev/null +++ b/cxx/src/error.rs @@ -0,0 +1,211 @@ +use crate::game::NotValidUtf8; +use libloot_ffi_errors::SystemError; + +use libloot::{ + error::{ + ConditionEvaluationError, DatabaseLockPoisonError, GameHandleCreationError, + GroupsPathError, LoadOrderError, LoadOrderStateError, LoadPluginsError, + MetadataRetrievalError, PluginDataError, SortPluginsError, + }, + metadata::error::{ + LoadMetadataError, MultilingualMessageContentsError, RegexError, WriteMetadataError, + }, +}; + +#[derive(Debug)] +pub enum VerboseError { + CyclicInteractionError(Vec), + UndefinedGroupError(String), + EspluginError(i32, String), + LibloadorderError(i32, String), + LciError(i32, String), + FileAccessError(String), + InvalidArgument(String), + Other(Box), +} + +impl std::fmt::Display for VerboseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CyclicInteractionError(cycle) => { + write!(f, "CyclicInteractionError: ")?; + for vertex in cycle { + let name = vertex.name().replace("\\", "\\\\").replace("-", "\\-"); + match vertex.out_edge_type() { + Some(e) => write!(f, "{}--{}--", name, e)?, + None => write!(f, "{}", name)?, + } + } + Ok(()) + } + Self::UndefinedGroupError(group) => { + write!(f, "UndefinedGroupError: {}", group) + } + Self::EspluginError(c, s) => { + write!(f, "EspluginError: {}: {}", c, s) + } + Self::LibloadorderError(c, s) => { + write!(f, "LibloadorderError: {}: {}", c, s) + } + Self::LciError(c, s) => { + write!(f, "LciError: {}: {}", c, s) + } + Self::FileAccessError(s) => write!(f, "FileAccessError: {}", s), + Self::InvalidArgument(s) => write!(f, "InvalidArgument: {}", s), + Self::Other(e) => { + write!(f, "{}", e)?; + let mut error = e.as_ref(); + while let Some(source) = error.source() { + write!(f, ": {}", source)?; + error = source; + } + Ok(()) + } + } + } +} + +impl From for VerboseError { + fn from(value: GameHandleCreationError) -> Self { + match value { + GameHandleCreationError::LoadOrderError(e) => e.into(), + GameHandleCreationError::NotADirectory(_) => Self::InvalidArgument(value.to_string()), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: UnsupportedEnumValueError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: NotValidUtf8) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: DatabaseLockPoisonError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: LoadPluginsError) -> Self { + match value { + LoadPluginsError::PluginDataError(e) => e.into(), + LoadPluginsError::PluginValidationError(_) => Self::InvalidArgument(value.to_string()), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: SortPluginsError) -> Self { + match value { + SortPluginsError::MetadataRetrievalError(e) => e.into(), + SortPluginsError::UndefinedGroup(g) => Self::UndefinedGroupError(g), + SortPluginsError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), + SortPluginsError::PluginDataError(e) => e.into(), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: LoadOrderStateError) -> Self { + match value { + LoadOrderStateError::LoadOrderError(e) => e.into(), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: LoadOrderError) -> Self { + let error = SystemError::from(value); + Self::LibloadorderError(error.code(), error.message().to_string()) + } +} + +impl From for VerboseError { + fn from(value: LoadMetadataError) -> Self { + Self::FileAccessError(value.to_string()) + } +} + +impl From for VerboseError { + fn from(value: WriteMetadataError) -> Self { + Self::FileAccessError(value.to_string()) + } +} + +impl From for VerboseError { + fn from(value: ConditionEvaluationError) -> Self { + let error = SystemError::from(value); + Self::LciError(error.code(), error.message().to_string()) + } +} + +impl From for VerboseError { + fn from(value: GroupsPathError) -> Self { + match value { + GroupsPathError::UndefinedGroup(g) => Self::UndefinedGroupError(g), + GroupsPathError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: MetadataRetrievalError) -> Self { + match value { + MetadataRetrievalError::ConditionEvaluationError(e) => e.into(), + _ => Self::Other(Box::new(value)), + } + } +} + +impl From for VerboseError { + fn from(value: PluginDataError) -> Self { + let error = SystemError::from(value); + Self::EspluginError(error.code(), error.message().to_string()) + } +} + +impl From for VerboseError { + fn from(value: MultilingualMessageContentsError) -> Self { + Self::Other(Box::new(value)) + } +} + +impl From for VerboseError { + fn from(value: RegexError) -> Self { + Self::Other(Box::new(value)) + } +} + +#[derive(Debug)] +pub struct EmptyOptionalError; + +impl std::fmt::Display for EmptyOptionalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "optional is empty") + } +} + +impl std::error::Error for EmptyOptionalError {} + +#[derive(Debug)] +pub struct UnsupportedEnumValueError; + +impl std::fmt::Display for UnsupportedEnumValueError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Enum value is unsupported") + } +} + +impl std::error::Error for UnsupportedEnumValueError {} diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index 27012327..c1b1ebe6 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -1,11 +1,12 @@ mod database; +mod error; mod game; mod metadata; mod plugin; use database::{Database, Vertex, new_vertex}; -use game::{Game, NotValidUtf8, new_game, new_game_with_local_path}; -use libloot_ffi_errors::SystemError; +use error::{EmptyOptionalError, UnsupportedEnumValueError, VerboseError}; +use game::{Game, new_game, new_game_with_local_path}; use metadata::{ File, Filename, Group, Location, Message, MessageContent, OptionalMessageContentRef, OptionalPluginMetadata, PluginCleaningData, PluginMetadata, Tag, group_default_name, @@ -20,195 +21,9 @@ use std::{ }; use unicase::UniCase; -use libloot::{ - error::{ - ConditionEvaluationError, DatabaseLockPoisonError, GameHandleCreationError, - GroupsPathError, LoadOrderError, LoadOrderStateError, LoadPluginsError, - MetadataRetrievalError, PluginDataError, SortPluginsError, - }, - metadata::error::{ - LoadMetadataError, MultilingualMessageContentsError, RegexError, WriteMetadataError, - }, - set_logging_callback, -}; +use libloot::set_logging_callback; pub use libloot::{is_compatible, libloot_revision, libloot_version}; -#[derive(Debug)] -pub enum VerboseError { - CyclicInteractionError(Vec), - UndefinedGroupError(String), - EspluginError(i32, String), - LibloadorderError(i32, String), - LciError(i32, String), - FileAccessError(String), - InvalidArgument(String), - Other(Box), -} - -impl std::fmt::Display for VerboseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::CyclicInteractionError(cycle) => { - write!(f, "CyclicInteractionError: ")?; - for vertex in cycle { - let name = vertex.name().replace("\\", "\\\\").replace("-", "\\-"); - match vertex.out_edge_type() { - Some(e) => write!(f, "{}--{}--", name, e)?, - None => write!(f, "{}", name)?, - } - } - Ok(()) - } - Self::UndefinedGroupError(group) => { - write!(f, "UndefinedGroupError: {}", group) - } - Self::EspluginError(c, s) => { - write!(f, "EspluginError: {}: {}", c, s) - } - Self::LibloadorderError(c, s) => { - write!(f, "LibloadorderError: {}: {}", c, s) - } - Self::LciError(c, s) => { - write!(f, "LciError: {}: {}", c, s) - } - Self::FileAccessError(s) => write!(f, "FileAccessError: {}", s), - Self::InvalidArgument(s) => write!(f, "InvalidArgument: {}", s), - Self::Other(e) => { - write!(f, "{}", e)?; - let mut error = e.as_ref(); - while let Some(source) = error.source() { - write!(f, ": {}", source)?; - error = source; - } - Ok(()) - } - } - } -} - -impl From for VerboseError { - fn from(value: GameHandleCreationError) -> Self { - match value { - GameHandleCreationError::LoadOrderError(e) => e.into(), - GameHandleCreationError::NotADirectory(_) => Self::InvalidArgument(value.to_string()), - _ => Self::Other(Box::new(value)), - } - } -} - -impl From for VerboseError { - fn from(value: UnsupportedEnumValueError) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for VerboseError { - fn from(value: NotValidUtf8) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for VerboseError { - fn from(value: DatabaseLockPoisonError) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for VerboseError { - fn from(value: LoadPluginsError) -> Self { - match value { - LoadPluginsError::PluginDataError(e) => e.into(), - LoadPluginsError::PluginValidationError(_) => Self::InvalidArgument(value.to_string()), - _ => Self::Other(Box::new(value)), - } - } -} - -impl From for VerboseError { - fn from(value: SortPluginsError) -> Self { - match value { - SortPluginsError::MetadataRetrievalError(e) => e.into(), - SortPluginsError::UndefinedGroup(g) => Self::UndefinedGroupError(g), - SortPluginsError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), - SortPluginsError::PluginDataError(e) => e.into(), - _ => Self::Other(Box::new(value)), - } - } -} - -impl From for VerboseError { - fn from(value: LoadOrderStateError) -> Self { - match value { - LoadOrderStateError::LoadOrderError(e) => e.into(), - _ => Self::Other(Box::new(value)), - } - } -} - -impl From for VerboseError { - fn from(value: LoadOrderError) -> Self { - let error = SystemError::from(value); - Self::LibloadorderError(error.code(), error.message().to_string()) - } -} - -impl From for VerboseError { - fn from(value: LoadMetadataError) -> Self { - Self::FileAccessError(value.to_string()) - } -} - -impl From for VerboseError { - fn from(value: WriteMetadataError) -> Self { - Self::FileAccessError(value.to_string()) - } -} - -impl From for VerboseError { - fn from(value: ConditionEvaluationError) -> Self { - let error = SystemError::from(value); - Self::LciError(error.code(), error.message().to_string()) - } -} - -impl From for VerboseError { - fn from(value: GroupsPathError) -> Self { - match value { - GroupsPathError::UndefinedGroup(g) => Self::UndefinedGroupError(g), - GroupsPathError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), - _ => Self::Other(Box::new(value)), - } - } -} - -impl From for VerboseError { - fn from(value: MetadataRetrievalError) -> Self { - match value { - MetadataRetrievalError::ConditionEvaluationError(e) => e.into(), - _ => Self::Other(Box::new(value)), - } - } -} - -impl From for VerboseError { - fn from(value: PluginDataError) -> Self { - let error = SystemError::from(value); - Self::EspluginError(error.code(), error.message().to_string()) - } -} - -impl From for VerboseError { - fn from(value: MultilingualMessageContentsError) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for VerboseError { - fn from(value: RegexError) -> Self { - Self::Other(Box::new(value)) - } -} - #[derive(Debug)] pub struct OptionalRef(*const T); @@ -243,28 +58,6 @@ impl Optional { } } -#[derive(Debug)] -pub struct EmptyOptionalError; - -impl std::fmt::Display for EmptyOptionalError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "optional is empty") - } -} - -impl std::error::Error for EmptyOptionalError {} - -#[derive(Debug)] -pub struct UnsupportedEnumValueError; - -impl std::fmt::Display for UnsupportedEnumValueError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Enum value is unsupported") - } -} - -impl std::error::Error for UnsupportedEnumValueError {} - fn compare_filenames(lhs: &str, rhs: &str) -> i8 { match UniCase::new(lhs).cmp(&UniCase::new(rhs)) { std::cmp::Ordering::Less => -1, From 254892198d0ebc2100c4a98a228c26716764be3d Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 23 Mar 2025 18:12:11 +0000 Subject: [PATCH 041/206] Refactor unsafe code in CXX wrapper --- cxx/src/lib.rs | 11 +++ cxx/src/metadata.rs | 163 +++++++++++++++++++++++--------------------- 2 files changed, 97 insertions(+), 77 deletions(-) diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index c1b1ebe6..4e7e80b3 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -31,6 +31,17 @@ impl OptionalRef { pub fn is_some(&self) -> bool { !self.0.is_null() } + + /// # Safety + /// + /// This is safe as long as the pointer in the OptionalRef is still valid. + pub unsafe fn as_ref(&self) -> Result<&T, EmptyOptionalError> { + if self.0.is_null() { + Err(EmptyOptionalError) + } else { + unsafe { Ok(&*self.0) } + } + } } impl From> for OptionalRef { diff --git a/cxx/src/metadata.rs b/cxx/src/metadata.rs index fe228775..13e22e50 100644 --- a/cxx/src/metadata.rs +++ b/cxx/src/metadata.rs @@ -1,10 +1,46 @@ use delegate::delegate; use crate::{ - EmptyOptionalError, Optional, OptionalRef, UnsupportedEnumValueError, VerboseError, + Optional, OptionalRef, UnsupportedEnumValueError, VerboseError, ffi::{MessageType, TagSuggestion}, }; +/// # Safety +/// +/// Only implement this on structs that are #[repr(transparent)] and that have a +/// single field. +unsafe trait TransparentWrapper { + type Wrapped; + + fn wrap_ref(value: &Self::Wrapped) -> &Self + where + Self: Sized, + { + let v = value as *const Self::Wrapped; + // SAFETY: Reinterpreting the pointer of a transparent wrapper to the type it wraps is safe. + unsafe { + let v = v as *const Self; + &*v + } + } + + fn wrap_slice(slice: &[Self::Wrapped]) -> &[Self] + where + Self: Sized, + { + // SAFETY: This is safe because a transparent wrapper is the same in memory as the type it wraps. + unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } + } + + fn unwrap_slice(slice: &[Self]) -> &[Self::Wrapped] + where + Self: Sized, + { + // SAFETY: This is safe because a transparent wrapper is the same in memory as the type it wraps. + unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } + } +} + impl From for MessageType { fn from(value: libloot::metadata::MessageType) -> Self { match value { @@ -56,6 +92,11 @@ impl MessageContent { } } +// SAFETY: MessageContent has #[repr(transparent)] +unsafe impl TransparentWrapper for MessageContent { + type Wrapped = libloot::metadata::MessageContent; +} + impl From for libloot::metadata::MessageContent { fn from(value: MessageContent) -> Self { value.0 @@ -68,48 +109,16 @@ impl From> for libloot::metadata::MessageContent { } } -fn wrap_message_content_ref(unwrapped: &libloot::metadata::MessageContent) -> &MessageContent { - let v = unwrapped as *const libloot::metadata::MessageContent; - // SAFETY: MessageContent is a transparent wrapper, so reinterpreting the pointer is safe. - unsafe { - let v = v as *const MessageContent; - &*v - } -} - -fn wrap_message_contents(slice: &[libloot::metadata::MessageContent]) -> &[MessageContent] { - // SAFETY: This is safe because MessageContent is a transparent wrapper around the libloot type. - unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } -} - -fn unwrap_message_contents(slice: &[MessageContent]) -> &[libloot::metadata::MessageContent] { - // SAFETY: This is safe because MessageContent is a transparent wrapper around the libloot type. - unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } -} - pub type OptionalMessageContentRef = OptionalRef; -impl OptionalRef { - /// # Safety - /// - /// This is safe as long as the pointer in the OptionalRef is still valid. - pub unsafe fn as_ref(&self) -> Result<&MessageContent, EmptyOptionalError> { - if self.0.is_null() { - Err(EmptyOptionalError) - } else { - unsafe { Ok(&*self.0) } - } - } -} - pub fn select_message_content( contents: &[MessageContent], language: &str, ) -> Box { let option = - libloot::metadata::select_message_content(unwrap_message_contents(contents), language); + libloot::metadata::select_message_content(MessageContent::unwrap_slice(contents), language); - Box::new(option.map(wrap_message_content_ref).into()) + Box::new(option.map(MessageContent::wrap_ref).into()) } #[derive(Clone, Debug)] @@ -143,7 +152,7 @@ impl Message { } pub fn content(&self) -> &[MessageContent] { - wrap_message_contents(self.0.content()) + MessageContent::wrap_slice(self.0.content()) } pub fn boxed_clone(&self) -> Box { @@ -160,6 +169,11 @@ impl Message { } } +// SAFETY: Message has #[repr(transparent)] +unsafe impl TransparentWrapper for Message { + type Wrapped = libloot::metadata::Message; +} + impl From for Message { fn from(value: libloot::metadata::Message) -> Self { Self(value) @@ -172,11 +186,6 @@ impl From> for libloot::metadata::Message { } } -fn wrap_messages(slice: &[libloot::metadata::Message]) -> &[Message] { - // SAFETY: This is safe because Message is a transparent wrapper around the libloot type. - unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } -} - #[derive(Clone, Debug)] #[repr(transparent)] pub struct Group(libloot::metadata::Group); @@ -251,35 +260,35 @@ impl PluginMetadata { } pub fn load_after_files(&self) -> &[File] { - wrap_files(self.0.load_after_files()) + File::wrap_slice(self.0.load_after_files()) } pub fn requirements(&self) -> &[File] { - wrap_files(self.0.requirements()) + File::wrap_slice(self.0.requirements()) } pub fn incompatibilities(&self) -> &[File] { - wrap_files(self.0.incompatibilities()) + File::wrap_slice(self.0.incompatibilities()) } pub fn messages(&self) -> &[Message] { - wrap_messages(self.0.messages()) + Message::wrap_slice(self.0.messages()) } pub fn tags(&self) -> &[Tag] { - wrap_tags(self.0.tags()) + Tag::wrap_slice(self.0.tags()) } pub fn dirty_info(&self) -> &[PluginCleaningData] { - wrap_cleaning_data(self.0.dirty_info()) + PluginCleaningData::wrap_slice(self.0.dirty_info()) } pub fn clean_info(&self) -> &[PluginCleaningData] { - wrap_cleaning_data(self.0.clean_info()) + PluginCleaningData::wrap_slice(self.0.clean_info()) } pub fn locations(&self) -> &[Location] { - wrap_locations(self.0.locations()) + Location::wrap_slice(self.0.locations()) } pub fn set_load_after_files(&mut self, files: &[Box]) { @@ -371,12 +380,7 @@ pub fn new_file(name: String) -> Box { impl File { pub fn filename(&self) -> &Filename { - let f = self.0.name() as *const libloot::metadata::Filename; - // SAFETY: Filename is a transparent wrapper, so reinterpreting the pointer is safe. - unsafe { - let f = f as *const Filename; - &*f - } + Filename::wrap_ref(self.0.name()) } pub fn display_name(&self) -> &str { @@ -388,7 +392,7 @@ impl File { } pub fn detail(&self) -> &[MessageContent] { - wrap_message_contents(self.0.detail()) + MessageContent::wrap_slice(self.0.detail()) } pub fn set_detail(&mut self, detail: &[Box]) -> Result<(), VerboseError> { @@ -409,17 +413,17 @@ impl File { } } +// SAFETY: File has #[repr(transparent)] +unsafe impl TransparentWrapper for File { + type Wrapped = libloot::metadata::File; +} + impl From> for libloot::metadata::File { fn from(value: Box) -> Self { value.0 } } -fn wrap_files(slice: &[libloot::metadata::File]) -> &[File] { - // SAFETY: This is safe because File is a transparent wrapper around the libloot type. - unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } -} - #[derive(Clone, Debug)] #[repr(transparent)] pub struct Filename(libloot::metadata::Filename); @@ -440,6 +444,11 @@ impl Filename { } } +// SAFETY: Filename has #[repr(transparent)] +unsafe impl TransparentWrapper for Filename { + type Wrapped = libloot::metadata::Filename; +} + #[derive(Clone, Debug)] #[repr(transparent)] pub struct Tag(libloot::metadata::Tag); @@ -471,17 +480,17 @@ impl Tag { } } +// SAFETY: Tag has #[repr(transparent)] +unsafe impl TransparentWrapper for Tag { + type Wrapped = libloot::metadata::Tag; +} + impl From> for libloot::metadata::Tag { fn from(value: Box) -> Self { value.0 } } -fn wrap_tags(slice: &[libloot::metadata::Tag]) -> &[Tag] { - // SAFETY: This is safe because Tag is a transparent wrapper around the libloot type. - unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } -} - impl TryFrom for libloot::metadata::TagSuggestion { type Error = UnsupportedEnumValueError; @@ -506,7 +515,7 @@ pub fn new_plugin_cleaning_data(crc: u32, cleaning_utility: String) -> Box &[MessageContent] { - wrap_message_contents(self.0.detail()) + MessageContent::wrap_slice(self.0.detail()) } pub fn set_detail(&mut self, detail: &[Box]) -> Result<(), VerboseError> { @@ -539,17 +548,17 @@ impl PluginCleaningData { } } +// SAFETY: PluginCleaningData has #[repr(transparent)] +unsafe impl TransparentWrapper for PluginCleaningData { + type Wrapped = libloot::metadata::PluginCleaningData; +} + impl From> for libloot::metadata::PluginCleaningData { fn from(value: Box) -> Self { value.0 } } -fn wrap_cleaning_data(slice: &[libloot::metadata::PluginCleaningData]) -> &[PluginCleaningData] { - // SAFETY: This is safe because PluginCleaningData is a transparent wrapper around the libloot type. - unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } -} - #[derive(Clone, Debug)] #[repr(transparent)] pub struct Location(libloot::metadata::Location); @@ -576,17 +585,17 @@ impl Location { } } +// SAFETY: Location has #[repr(transparent)] +unsafe impl TransparentWrapper for Location { + type Wrapped = libloot::metadata::Location; +} + impl From> for libloot::metadata::Location { fn from(value: Box) -> Self { value.0 } } -fn wrap_locations(slice: &[libloot::metadata::Location]) -> &[Location] { - // SAFETY: This is safe because Location is a transparent wrapper around the libloot type. - unsafe { std::slice::from_raw_parts(slice.as_ptr().cast(), slice.len()) } -} - pub fn to_vec_of_unwrapped>>(slice: &[Box]) -> Vec { slice.iter().cloned().map(Into::into).collect() } From 2076478a4f157010e0a6b45c5da76c67a9e0833e Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 18 Mar 2025 21:02:36 +0000 Subject: [PATCH 042/206] Replace overwrite boolean with an enum --- cxx/src/database.rs | 18 +++++++++++++--- pyo3/src/database.rs | 18 +++++++++++++--- src/database/mod.rs | 49 ++++++++++++++++++++++++++------------------ src/lib.rs | 2 +- 4 files changed, 60 insertions(+), 27 deletions(-) diff --git a/cxx/src/database.rs b/cxx/src/database.rs index 1f684894..1b6fea2a 100644 --- a/cxx/src/database.rs +++ b/cxx/src/database.rs @@ -4,7 +4,7 @@ use std::{ }; use delegate::delegate; -use libloot::error::DatabaseLockPoisonError; +use libloot::{WriteMode, error::DatabaseLockPoisonError}; use crate::{ UnsupportedEnumValueError, VerboseError, @@ -54,10 +54,16 @@ impl Database { output_path: &str, overwrite: bool, ) -> Result<(), VerboseError> { + let write_mode = if overwrite { + WriteMode::CreateOrTruncate + } else { + WriteMode::Create + }; + self.0 .read() .map_err(|_| DatabaseLockPoisonError)? - .write_user_metadata(Path::new(output_path), overwrite) + .write_user_metadata(Path::new(output_path), write_mode) .map_err(Into::into) } @@ -66,10 +72,16 @@ impl Database { output_path: &str, overwrite: bool, ) -> Result<(), VerboseError> { + let write_mode = if overwrite { + WriteMode::CreateOrTruncate + } else { + WriteMode::Create + }; + self.0 .read() .map_err(|_| DatabaseLockPoisonError)? - .write_minimal_list(Path::new(output_path), overwrite) + .write_minimal_list(Path::new(output_path), write_mode) .map_err(Into::into) } diff --git a/pyo3/src/database.rs b/pyo3/src/database.rs index 003f9eb3..c1b30d16 100644 --- a/pyo3/src/database.rs +++ b/pyo3/src/database.rs @@ -4,7 +4,7 @@ use std::{ sync::{Arc, RwLock}, }; -use libloot::error::DatabaseLockPoisonError; +use libloot::{WriteMode, error::DatabaseLockPoisonError}; use pyo3::{ Bound, PyResult, pyclass, pymethods, types::{PyAnyMethods, PyTypeMethods}, @@ -54,10 +54,16 @@ impl Database { output_path: PathBuf, overwrite: bool, ) -> Result<(), VerboseError> { + let write_mode = if overwrite { + WriteMode::CreateOrTruncate + } else { + WriteMode::Create + }; + self.0 .read() .map_err(|_| DatabaseLockPoisonError)? - .write_user_metadata(&output_path, overwrite) + .write_user_metadata(&output_path, write_mode) .map_err(Into::into) } @@ -66,10 +72,16 @@ impl Database { output_path: PathBuf, overwrite: bool, ) -> Result<(), VerboseError> { + let write_mode = if overwrite { + WriteMode::CreateOrTruncate + } else { + WriteMode::Create + }; + self.0 .read() .map_err(|_| DatabaseLockPoisonError)? - .write_minimal_list(&output_path, overwrite) + .write_minimal_list(&output_path, write_mode) .map_err(Into::into) } diff --git a/src/database/mod.rs b/src/database/mod.rs index b1fdac90..c57d546a 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -20,6 +20,15 @@ use crate::{ }; pub use error::{ConditionEvaluationError, MetadataRetrievalError}; +/// Control behaviour when writing to files. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum WriteMode { + /// Create the file if it does not exist, otherwise error. + Create, + /// Create the file if it does not exist, otherwise replace its contents. + CreateOrTruncate, +} + /// The interface through which metadata can be accessed. #[derive(Debug)] pub struct Database { @@ -85,9 +94,9 @@ impl Database { pub fn write_user_metadata( &self, output_path: &Path, - overwrite: bool, + mode: WriteMode, ) -> Result<(), WriteMetadataError> { - validate_write_path(output_path, overwrite)?; + validate_write_path(output_path, mode)?; self.userlist.save(output_path) } @@ -100,9 +109,9 @@ impl Database { pub fn write_minimal_list( &self, output_path: &Path, - overwrite: bool, + mode: WriteMode, ) -> Result<(), WriteMetadataError> { - validate_write_path(output_path, overwrite)?; + validate_write_path(output_path, mode)?; let mut doc = MetadataDocument::default(); @@ -285,13 +294,13 @@ impl Database { } } -fn validate_write_path(output_path: &Path, overwrite: bool) -> Result<(), WriteMetadataError> { +fn validate_write_path(output_path: &Path, mode: WriteMode) -> Result<(), WriteMetadataError> { if !output_path.parent().map(|p| p.exists()).unwrap_or(false) { Err(WriteMetadataError::new( output_path.into(), WriteMetadataErrorReason::ParentDirectoryNotFound, )) - } else if !overwrite && output_path.exists() { + } else if mode == WriteMode::Create && output_path.exists() { Err(WriteMetadataError::new( output_path.into(), WriteMetadataErrorReason::PathAlreadyExists, @@ -487,7 +496,7 @@ plugins: let output_path = fixture.inner.local_path.join("userlist.yaml"); database - .write_user_metadata(&output_path, false) + .write_user_metadata(&output_path, WriteMode::Create) .unwrap(); let content = std::fs::read_to_string(output_path).unwrap(); @@ -503,7 +512,7 @@ plugins: assert!( database - .write_user_metadata(&output_path, false) + .write_user_metadata(&output_path, WriteMode::Create) .is_ok() ); } @@ -516,7 +525,7 @@ plugins: assert!( database - .write_user_metadata(&output_path, true) + .write_user_metadata(&output_path, WriteMode::CreateOrTruncate) .is_ok() ); } @@ -531,7 +540,7 @@ plugins: assert!( database - .write_user_metadata(&output_path, true) + .write_user_metadata(&output_path, WriteMode::CreateOrTruncate) .is_ok() ); } @@ -544,7 +553,7 @@ plugins: assert!( database - .write_user_metadata(&output_path, false) + .write_user_metadata(&output_path, WriteMode::Create) .is_err() ); } @@ -563,7 +572,7 @@ plugins: assert!( database - .write_user_metadata(&output_path, true) + .write_user_metadata(&output_path, WriteMode::CreateOrTruncate) .is_err() ); } @@ -578,7 +587,7 @@ plugins: assert!( database - .write_user_metadata(&output_path, false) + .write_user_metadata(&output_path, WriteMode::Create) .is_err() ); } @@ -599,7 +608,7 @@ plugins: assert!( database - .write_minimal_list(&output_path, false) + .write_minimal_list(&output_path, WriteMode::Create) .is_ok() ); @@ -643,7 +652,7 @@ plugins: assert!( database - .write_minimal_list(&output_path, false) + .write_minimal_list(&output_path, WriteMode::Create) .is_ok() ); } @@ -656,7 +665,7 @@ plugins: assert!( database - .write_minimal_list(&output_path, true) + .write_minimal_list(&output_path, WriteMode::CreateOrTruncate) .is_ok() ); } @@ -671,7 +680,7 @@ plugins: assert!( database - .write_minimal_list(&output_path, true) + .write_minimal_list(&output_path, WriteMode::CreateOrTruncate) .is_ok() ); } @@ -684,7 +693,7 @@ plugins: assert!( database - .write_minimal_list(&output_path, false) + .write_minimal_list(&output_path, WriteMode::Create) .is_err() ); } @@ -703,7 +712,7 @@ plugins: assert!( database - .write_minimal_list(&output_path, true) + .write_minimal_list(&output_path, WriteMode::CreateOrTruncate) .is_err() ); } @@ -718,7 +727,7 @@ plugins: assert!( database - .write_minimal_list(&output_path, false) + .write_minimal_list(&output_path, WriteMode::Create) .is_err() ); } diff --git a/src/lib.rs b/src/lib.rs index a53caf07..d93af25f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,7 @@ mod sorting; mod tests; mod version; -pub use database::Database; +pub use database::{Database, WriteMode}; pub use game::{Game, GameType}; pub use logging::{LogLevel, set_log_level, set_logging_callback}; pub use plugin::Plugin; From e61aeabfbb020b86d0bc8b017a4c0ae792fe2821 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 22 Mar 2025 09:37:02 +0000 Subject: [PATCH 043/206] Integrate logging in the Python wrapper --- Cargo.lock | 18 ++++++++++++++++++ README.md | 24 +----------------------- pyo3/Cargo.toml | 1 + pyo3/README.md | 34 ++-------------------------------- pyo3/src/lib.rs | 2 ++ 5 files changed, 24 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 591e82ef..dd8f188b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,12 @@ version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "arraydeque" version = "0.5.1" @@ -623,6 +629,7 @@ dependencies = [ "libloot", "libloot-ffi-errors", "pyo3", + "pyo3-log", ] [[package]] @@ -894,6 +901,17 @@ dependencies = [ "pyo3-build-config", ] +[[package]] +name = "pyo3-log" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b78e4983ba15bc62833a0e0941d965bc03690163f1127864f1408db25063466" +dependencies = [ + "arc-swap", + "log", + "pyo3", +] + [[package]] name = "pyo3-macros" version = "0.24.0" diff --git a/README.md b/README.md index 3a0b2e7c..601b878f 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,6 @@ # libloot-rs -This is an **incomplete** and **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++. - -## Current status - -Currently complete: - -- [x] Public API types and function declarations -- [x] Public API doc comments -- [x] Library versioning -- [x] Setting a logging callback -- [x] Parsing metadata from YAML -- [x] Serialising metadata to YAML -- [x] Game-related functionality -- [x] Plugin-related functionality -- [x] Archive-related functionality -- [x] Metadata-related functionality (excluding writing YAML) -- [x] Sorting functionality -- [x] Unit tests -- [x] Integration tests -- [x] C++ FFI -- [ ] Python FFI - -The complete bits should match libloot v0.25.5. +This is an **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++, that should match libloot v0.25.5. ## Build diff --git a/pyo3/Cargo.toml b/pyo3/Cargo.toml index 5bb65565..fde8fe24 100644 --- a/pyo3/Cargo.toml +++ b/pyo3/Cargo.toml @@ -12,3 +12,4 @@ crate-type = ["cdylib"] libloot = { path = ".." } libloot-ffi-errors = { path = "../ffi-errors" } pyo3 = "0.24.0" +pyo3-log = "0.12.2" diff --git a/pyo3/README.md b/pyo3/README.md index cca22f10..087e5267 100644 --- a/pyo3/README.md +++ b/pyo3/README.md @@ -1,37 +1,6 @@ # libloot-pyo3 -An **incomplete** and **experimental** Python wrapper around the libloot Rust implementation, built using [PyO3](https://pyo3.rs). - -## Current coverage - -- [x] `LIBLOOT_VERSION_MAJOR` -- [x] `LIBLOOT_VERSION_MINOR` -- [x] `LIBLOOT_VERSION_PATCH` -- [x] `is_compatible()` -- [x] `libloot_revision()` -- [x] `libloot_version()` -- [ ] `set_logging_callback()` -- [ ] `set_log_level()` -- [x] `EdgeType` -- [x] `GameType` -- [ ] `LogLevel` -- [x] `Database` -- [x] `Game` -- [x] `Plugin` -- [x] `Vertex` -- [x] `File` -- [x] `Filename` -- [x] `Group` -- [x] `Location` -- [x] `Message` -- [x] `MessageContent` -- [x] `PluginCleaningData` -- [x] `PluginMetadata` -- [x] `Tag` -- [x] `MessageType` -- [x] `TagSuggestion` -- [x] `select_message_content()` -- [x] Error types +An **experimental** Python wrapper around the libloot Rust implementation, built using [PyO3](https://pyo3.rs). ## Build @@ -64,3 +33,4 @@ py - The API provides the custom `CyclicInteractionError`, `UndefinedGroupError`, `EspluginError` exception types. - All other errors are raised as `ValueError` exceptions. - There's no equivalent to the C++ interface's `FileAccessError` or `ConditionSyntaxError` classes or the libloadorder and loot-condition-interpreter system error categories. +- The `LogLevel` enum and `set_logging_callback()` and `set_log_level()` functions are not exposed because the logging is integrated with Python's `logging` module instead. diff --git a/pyo3/src/lib.rs b/pyo3/src/lib.rs index 8a871ee5..bfe1d40d 100644 --- a/pyo3/src/lib.rs +++ b/pyo3/src/lib.rs @@ -35,6 +35,8 @@ create_exception!(loot, EspluginError, PyException); /// A Python module implemented in Rust. #[pymodule(name = "loot")] fn libloot_pyo3(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + pyo3_log::init(); + m.add("LIBLOOT_VERSION_MAJOR", libloot::LIBLOOT_VERSION_MAJOR)?; m.add("LIBLOOT_VERSION_MINOR", libloot::LIBLOOT_VERSION_MINOR)?; m.add("LIBLOOT_VERSION_PATCH", libloot::LIBLOOT_VERSION_PATCH)?; From ded5f34b7fb6d1f8b0dc2a4c6dbc1ce9e85d1c4d Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 23 Mar 2025 15:41:53 +0000 Subject: [PATCH 044/206] Improve regex compatibility Neither of the changed regexes were accepted by the regex crate, which thought the fmt regex looked like an invalid repetition count (e.g. "a{5}") and which doesn't support negative lookahead. --- src/metadata/message.rs | 2 +- src/plugin/mod.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 594be6f5..b146107d 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -326,7 +326,7 @@ impl TryFrom<&MarkedYaml> for Message { if !subs.is_empty() { static FMT_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(r"{(\d+)}").expect("hardcoded fmt placeholder regex should be valid") + Regex::new(r"\{(\d+)\}").expect("hardcoded fmt placeholder regex should be valid") }); for mc in &mut content { diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index c3378d74..cb476832 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -415,9 +415,9 @@ fn extract_version(description: &str) -> Result, Box Date: Sun, 23 Mar 2025 17:12:54 +0000 Subject: [PATCH 045/206] Make regex plugin matching more efficient --- src/metadata/plugin_metadata.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 84ff20d0..7eecd345 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -1,4 +1,6 @@ -use fancy_regex::Regex; +use std::sync::LazyLock; + +use fancy_regex::{Captures, Regex}; use saphyr::MarkedYaml; use crate::logging; @@ -227,7 +229,23 @@ impl PluginName { let name = trim_dot_ghost(name).to_string(); if is_regex_name(&name) { - let regex = Regex::new(&format!("(?i)^{}$", &name))?; + // Many regexes are written with capturing groups as that's the + // simplest way to write them, but in plugin metadata they could all + // be non-capturing groups. + // Over the Skyrim SE masterlist, using non-capturing groups saves + // about 15 MB of memory at runtime, which is ~ 25% of the memory + // used by the regex matching caches. + static CAPTURING_GROUP_START_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"(^|[^\\])\(([^?])") + .expect("capturing group start regex should be valid") + }); + + let regex_name = CAPTURING_GROUP_START_REGEX + .replace_all(&name, |captures: &Captures| { + format!("{}(?:{}", &captures[1], &captures[2]) + }); + + let regex = Regex::new(&format!("(?i)^{}$", ®ex_name))?; Ok(Self { string: name, regex: Some(regex), From fdaed322dfa84f735112792a0ab3d7d94690b14f Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 25 Mar 2025 19:33:53 +0000 Subject: [PATCH 046/206] Fix linking debug builds of C++ wrapper --- .cargo/msvcd-config.toml | 6 ++++++ cxx/CMakeLists.txt | 13 ++++++++----- cxx/README.md | 8 ++++++++ cxx/build.rs | 13 ++++++++++++- 4 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 .cargo/msvcd-config.toml diff --git a/.cargo/msvcd-config.toml b/.cargo/msvcd-config.toml new file mode 100644 index 00000000..c2632a08 --- /dev/null +++ b/.cargo/msvcd-config.toml @@ -0,0 +1,6 @@ +# Config for building the CXX wrapper against the debug MSVC runtime, so that it +# can be used from Debug builds of C++ code. +# From +[env] +CFLAGS = "/MDd" +CXXFLAGS = "/MDd" diff --git a/cxx/CMakeLists.txt b/cxx/CMakeLists.txt index e1c6de29..68e71ae3 100644 --- a/cxx/CMakeLists.txt +++ b/cxx/CMakeLists.txt @@ -27,8 +27,13 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(libloot-cxx STATIC IMPORTED) # TODO: Define the Rust libloot library path here for Linux builds. if(MSVC) - set_target_properties(libloot-cxx PROPERTIES IMPORTED_LOCATION "${CMAKE_SOURCE_DIR}/../target/release/libloot_cxx.lib") + set(LIBLOOT_CXX_FILENAME "libloot_cxx.lib") endif() + +set_target_properties(libloot-cxx PROPERTIES + IMPORTED_CONFIGURATIONS "Release;Debug" + IMPORTED_LOCATION "${CMAKE_SOURCE_DIR}/../target/release/${LIBLOOT_CXX_FILENAME}" + IMPORTED_LOCATION_DEBUG "${CMAKE_SOURCE_DIR}/../target/debug/${LIBLOOT_CXX_FILENAME}") target_include_directories(libloot-cxx INTERFACE "${CMAKE_SOURCE_DIR}/../target/cxxbridge") @@ -56,8 +61,7 @@ set(LIBLOOT_SRC_API_CPP_FILES "${CMAKE_SOURCE_DIR}/src/api/plugin.cpp" "${CMAKE_SOURCE_DIR}/src/api/sorting/cyclic_interaction_error.cpp" "${CMAKE_SOURCE_DIR}/src/api/sorting/undefined_group_error.cpp" - "${CMAKE_SOURCE_DIR}/src/api/vertex.cpp" - ) + "${CMAKE_SOURCE_DIR}/src/api/vertex.cpp") set(LIBLOOT_INCLUDE_H_FILES "${CMAKE_SOURCE_DIR}/include/loot/api.h" @@ -92,8 +96,7 @@ set(LIBLOOT_SRC_API_H_FILES "${CMAKE_SOURCE_DIR}/src/api/database.h" "${CMAKE_SOURCE_DIR}/src/api/exception.h" "${CMAKE_SOURCE_DIR}/src/api/game.h" - "${CMAKE_SOURCE_DIR}/src/api/plugin.h" - ) + "${CMAKE_SOURCE_DIR}/src/api/plugin.h") source_group(TREE "${CMAKE_SOURCE_DIR}/src/api" PREFIX "Source Files" diff --git a/cxx/README.md b/cxx/README.md index 86821ad0..ff19250a 100644 --- a/cxx/README.md +++ b/cxx/README.md @@ -17,6 +17,14 @@ cmake -B build . cmake --build build --config RelWithDebInfo ``` +Debug builds need a little extra config to get the static and shared libraries to use the same C runtime library on Windows. + +``` +cargo build --config ../.cargo/msvcd-config.toml +cmake -B build . +cmake --build build --config Debug +``` + This also builds a copy of the public API tests from C++ libloot v0.25.5, which can be run using: ``` diff --git a/cxx/build.rs b/cxx/build.rs index 92388cc9..833a7145 100644 --- a/cxx/build.rs +++ b/cxx/build.rs @@ -3,5 +3,16 @@ fn main() { .std("c++17") .compile("libloot-cxx"); - println!("cargo:rerun-if-changed=src/lib.rs"); + // From + if std::env::var("TARGET").is_ok_and(|s| s.contains("windows-msvc")) { + // MSVC compiler suite + if std::env::var("CFLAGS").is_ok_and(|s| s.contains("/MDd")) { + // debug runtime flag is set + + // Don't link the default CRT + println!("cargo::rustc-link-arg=/nodefaultlib:msvcrt"); + // Link the debug CRT instead + println!("cargo::rustc-link-arg=/defaultlib:msvcrtd"); + } + } } From d6781f6a32c770356db3feccea8ee7dbe2d1e070 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 25 Mar 2025 19:41:07 +0000 Subject: [PATCH 047/206] Refactor YAML code --- src/metadata/file.rs | 2 +- src/metadata/group.rs | 6 +- src/metadata/location.rs | 4 +- src/metadata/message.rs | 10 +-- src/metadata/metadata_document.rs | 73 +------------------- src/metadata/mod.rs | 5 +- src/metadata/plugin_cleaning_data.rs | 6 +- src/metadata/plugin_metadata.rs | 6 +- src/metadata/tag.rs | 4 +- src/metadata/{yaml_emit.rs => yaml/emit.rs} | 12 +--- src/metadata/yaml/merge.rs | 74 +++++++++++++++++++++ src/metadata/yaml/mod.rs | 10 +++ src/metadata/{yaml.rs => yaml/parse.rs} | 22 +++--- 13 files changed, 108 insertions(+), 126 deletions(-) rename src/metadata/{yaml_emit.rs => yaml/emit.rs} (98%) create mode 100644 src/metadata/yaml/merge.rs create mode 100644 src/metadata/yaml/mod.rs rename src/metadata/{yaml.rs => yaml/parse.rs} (91%) diff --git a/src/metadata/file.rs b/src/metadata/file.rs index 13693736..96fdc121 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -7,11 +7,11 @@ use super::{ MessageContent, emit_message_contents, parse_message_contents_yaml, validate_message_contents, }, + yaml::{EmitYaml, YamlEmitter}, yaml::{ YamlObjectType, as_string_node, get_required_string_value, get_string_value, parse_condition, }, - yaml_emit::{EmitYaml, YamlEmitter}, }; /// Represents a file in a game's Data folder, including files in diff --git a/src/metadata/group.rs b/src/metadata/group.rs index f976e71d..4779fa37 100644 --- a/src/metadata/group.rs +++ b/src/metadata/group.rs @@ -1,10 +1,10 @@ use saphyr::MarkedYaml; use super::error::ParseMetadataError; +use super::yaml::{EmitYaml, YamlEmitter}; use super::yaml::{ YamlObjectType, get_as_hash, get_required_string_value, get_string_value, get_strings_vec_value, }; -use super::yaml_emit::{EmitYaml, YamlEmitter}; /// Represents a group to which plugin metadata objects can belong. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -104,10 +104,6 @@ impl TryFrom<&MarkedYaml> for Group { } impl EmitYaml for Group { - fn is_scalar(&self) -> bool { - false - } - fn emit_yaml(&self, emitter: &mut YamlEmitter) { emitter.begin_map(); diff --git a/src/metadata/location.rs b/src/metadata/location.rs index ae7eb639..33d56886 100644 --- a/src/metadata/location.rs +++ b/src/metadata/location.rs @@ -3,9 +3,9 @@ use saphyr::{MarkedYaml, YamlData}; use super::error::ExpectedType; use super::error::ParseMetadataError; +use super::yaml::EmitYaml; +use super::yaml::YamlEmitter; use super::yaml::{YamlObjectType, get_required_string_value}; -use super::yaml_emit::EmitYaml; -use super::yaml_emit::YamlEmitter; /// Represents a URL at which the parent plugin can be found. #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] diff --git a/src/metadata/message.rs b/src/metadata/message.rs index b146107d..1b463495 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -10,11 +10,11 @@ use super::{ ExpectedType, MetadataParsingErrorReason, MultilingualMessageContentsError, ParseMetadataError, }, + yaml::{EmitYaml, YamlEmitter}, yaml::{ YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_strings_vec_value, parse_condition, }, - yaml_emit::{EmitYaml, YamlEmitter}, }; /// Codes used to indicate the type of a message. @@ -388,10 +388,6 @@ impl TryFrom<&MarkedYaml> for Message { } impl EmitYaml for MessageContent { - fn is_scalar(&self) -> bool { - false - } - fn emit_yaml(&self, emitter: &mut YamlEmitter) { emitter.begin_map(); @@ -425,10 +421,6 @@ pub(super) fn emit_message_contents( } impl EmitYaml for Message { - fn is_scalar(&self) -> bool { - false - } - fn emit_yaml(&self, emitter: &mut YamlEmitter) { emitter.begin_map(); diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index dde340f0..861cb653 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -1,7 +1,6 @@ use std::{ collections::{HashMap, HashSet}, path::Path, - sync::LazyLock, }; use saphyr::{MarkedYaml, YamlData}; @@ -11,18 +10,15 @@ use crate::logging; use super::{ error::{ ExpectedType, LoadMetadataError, MetadataDocumentParsingError, ParseMetadataError, - RegexError, WriteMetadataError, YamlMergeKeyError, + RegexError, WriteMetadataError, }, file::Filename, group::Group, message::Message, plugin_metadata::PluginMetadata, - yaml::{YamlObjectType, as_string_node, get_as_slice}, - yaml_emit::{EmitYaml, YamlEmitter}, + yaml::{EmitYaml, YamlEmitter, YamlObjectType, get_as_slice, process_merge_keys}, }; -static MERGE_KEY: LazyLock = LazyLock::new(|| as_string_node("<<")); - #[derive(Clone, Debug, Eq, PartialEq)] pub struct MetadataDocument { bash_tags: Vec, @@ -342,71 +338,6 @@ impl std::default::Default for MetadataDocument { } } -fn process_merge_keys(mut yaml: MarkedYaml) -> Result { - match yaml.data { - YamlData::Array(a) => { - yaml.data = merge_array_elements(a).map(YamlData::Array)?; - Ok(yaml) - } - YamlData::Hash(h) => { - yaml.data = merge_hash_keys(h).map(YamlData::Hash)?; - Ok(yaml) - } - _ => Ok(yaml), - } -} - -fn merge_array_elements( - array: saphyr::AnnotatedArray, -) -> Result, YamlMergeKeyError> { - array.into_iter().map(process_merge_keys).collect() -} - -fn merge_hash_keys( - hash: saphyr::AnnotatedHash, -) -> Result, YamlMergeKeyError> { - let mut hash: saphyr::AnnotatedHash = hash - .into_iter() - .map(|(key, value)| { - process_merge_keys(key) - .and_then(|key| process_merge_keys(value).map(|value| (key, value))) - }) - .collect::>()?; - - if let Some(value) = hash.remove(&MERGE_KEY) { - merge_into_hash(hash, value) - } else { - Ok(hash) - } -} - -fn merge_into_hash( - hash: saphyr::AnnotatedHash, - value: MarkedYaml, -) -> Result, YamlMergeKeyError> { - match value.data { - YamlData::::Array(a) => a.into_iter().try_fold(hash, |acc, e| { - if let YamlData::Hash(h) = e.data { - Ok(merge_hashes(acc, h)) - } else { - Err(YamlMergeKeyError::new(e)) - } - }), - YamlData::::Hash(h) => Ok(merge_hashes(hash, h)), - _ => Err(YamlMergeKeyError::new(value)), - } -} - -fn merge_hashes( - mut hash1: saphyr::AnnotatedHash, - hash2: saphyr::AnnotatedHash, -) -> saphyr::AnnotatedHash { - for (key, value) in hash2 { - hash1.entry(key).or_insert(value); - } - hash1 -} - fn replace_prelude(masterlist: String, prelude: String) -> String { let line_ending = detect_line_ending(&masterlist); if let Some((start, end)) = find_prelude_bounds(&masterlist) { diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index d430da2c..0bf39d25 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -9,7 +9,6 @@ mod plugin_cleaning_data; pub(crate) mod plugin_metadata; mod tag; mod yaml; -mod yaml_emit; pub use file::{File, Filename}; pub use group::Group; @@ -20,8 +19,8 @@ pub use plugin_metadata::PluginMetadata; pub use tag::{Tag, TagSuggestion}; #[cfg(test)] -fn emit(metadata: &T) -> String { - let mut emitter = yaml_emit::YamlEmitter::new(); +fn emit(metadata: &T) -> String { + let mut emitter = yaml::YamlEmitter::new(); metadata.emit_yaml(&mut emitter); emitter.into_string() diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index 550a077e..2365e913 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -6,8 +6,8 @@ use super::{ MessageContent, emit_message_contents, parse_message_contents_yaml, validate_message_contents, }, + yaml::{EmitYaml, YamlEmitter}, yaml::{YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_u32_value}, - yaml_emit::{EmitYaml, YamlEmitter}, }; /// Represents data identifying the plugin under which it is stored as dirty or @@ -181,10 +181,6 @@ impl TryFrom<&MarkedYaml> for PluginCleaningData { } impl EmitYaml for PluginCleaningData { - fn is_scalar(&self) -> bool { - false - } - fn emit_yaml(&self, emitter: &mut YamlEmitter) { emitter.begin_map(); diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 7eecd345..30c8cd99 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -12,10 +12,10 @@ use super::{ message::Message, plugin_cleaning_data::PluginCleaningData, tag::Tag, + yaml::{EmitYaml, YamlEmitter}, yaml::{ YamlObjectType, get_as_hash, get_as_slice, get_required_string_value, get_string_value, }, - yaml_emit::{EmitYaml, YamlEmitter}, }; pub(crate) const GHOST_FILE_EXTENSION: &str = ".ghost"; @@ -376,10 +376,6 @@ fn get_vec<'a, T: TryFrom<&'a MarkedYaml, Error = impl Into> } impl EmitYaml for PluginMetadata { - fn is_scalar(&self) -> bool { - false - } - fn emit_yaml(&self, emitter: &mut YamlEmitter) { emitter.begin_map(); diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index d63a6b84..70543281 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -2,9 +2,9 @@ use saphyr::YamlData; use super::error::ExpectedType; use super::error::ParseMetadataError; +use super::yaml::EmitYaml; +use super::yaml::YamlEmitter; use super::yaml::{YamlObjectType, get_required_string_value, parse_condition}; -use super::yaml_emit::EmitYaml; -use super::yaml_emit::YamlEmitter; /// Represents whether a Bash Tag suggestion is for addition or removal. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] diff --git a/src/metadata/yaml_emit.rs b/src/metadata/yaml/emit.rs similarity index 98% rename from src/metadata/yaml_emit.rs rename to src/metadata/yaml/emit.rs index 8f8b2f41..e0c0fb18 100644 --- a/src/metadata/yaml_emit.rs +++ b/src/metadata/yaml/emit.rs @@ -1,5 +1,7 @@ pub trait EmitYaml { - fn is_scalar(&self) -> bool; + fn is_scalar(&self) -> bool { + false + } fn emit_yaml(&self, emitter: &mut YamlEmitter); } @@ -277,10 +279,6 @@ fn double_quote(value: &str) -> String { } impl EmitYaml for &[T] { - fn is_scalar(&self) -> bool { - false - } - fn emit_yaml(&self, emitter: &mut YamlEmitter) { match self { [] => {} @@ -305,10 +303,6 @@ impl EmitYaml for &[T] { } impl EmitYaml for Vec { - fn is_scalar(&self) -> bool { - false - } - fn emit_yaml(&self, emitter: &mut YamlEmitter) { self.as_slice().emit_yaml(emitter); } diff --git a/src/metadata/yaml/merge.rs b/src/metadata/yaml/merge.rs new file mode 100644 index 00000000..89994125 --- /dev/null +++ b/src/metadata/yaml/merge.rs @@ -0,0 +1,74 @@ +use std::sync::LazyLock; + +use saphyr::{MarkedYaml, YamlData}; + +use crate::metadata::error::YamlMergeKeyError; + +use super::as_string_node; + +static MERGE_KEY: LazyLock = LazyLock::new(|| as_string_node("<<")); + +pub fn process_merge_keys(mut yaml: MarkedYaml) -> Result { + match yaml.data { + YamlData::Array(a) => { + yaml.data = merge_array_elements(a).map(YamlData::Array)?; + Ok(yaml) + } + YamlData::Hash(h) => { + yaml.data = merge_hash_keys(h).map(YamlData::Hash)?; + Ok(yaml) + } + _ => Ok(yaml), + } +} + +fn merge_array_elements( + array: saphyr::AnnotatedArray, +) -> Result, YamlMergeKeyError> { + array.into_iter().map(process_merge_keys).collect() +} + +fn merge_hash_keys( + hash: saphyr::AnnotatedHash, +) -> Result, YamlMergeKeyError> { + let mut hash: saphyr::AnnotatedHash = hash + .into_iter() + .map(|(key, value)| { + process_merge_keys(key) + .and_then(|key| process_merge_keys(value).map(|value| (key, value))) + }) + .collect::>()?; + + if let Some(value) = hash.remove(&MERGE_KEY) { + merge_into_hash(hash, value) + } else { + Ok(hash) + } +} + +fn merge_into_hash( + hash: saphyr::AnnotatedHash, + value: MarkedYaml, +) -> Result, YamlMergeKeyError> { + match value.data { + YamlData::::Array(a) => a.into_iter().try_fold(hash, |acc, e| { + if let YamlData::Hash(h) = e.data { + Ok(merge_hashes(acc, h)) + } else { + Err(YamlMergeKeyError::new(e)) + } + }), + YamlData::::Hash(h) => Ok(merge_hashes(hash, h)), + _ => Err(YamlMergeKeyError::new(value)), + } +} + +fn merge_hashes( + mut hash1: saphyr::AnnotatedHash, + hash2: saphyr::AnnotatedHash, +) -> saphyr::AnnotatedHash { + for (key, value) in hash2 { + hash1.entry(key).or_insert(value); + } + hash1 +} diff --git a/src/metadata/yaml/mod.rs b/src/metadata/yaml/mod.rs new file mode 100644 index 00000000..8eab521a --- /dev/null +++ b/src/metadata/yaml/mod.rs @@ -0,0 +1,10 @@ +mod emit; +mod merge; +mod parse; + +pub use emit::{EmitYaml, YamlEmitter}; +pub use merge::process_merge_keys; +pub use parse::{ + YamlObjectType, as_string_node, get_as_hash, get_as_slice, get_required_string_value, + get_string_value, get_strings_vec_value, get_u32_value, parse_condition, to_yaml, +}; diff --git a/src/metadata/yaml.rs b/src/metadata/yaml/parse.rs similarity index 91% rename from src/metadata/yaml.rs rename to src/metadata/yaml/parse.rs index 7f3ea765..1364ea5e 100644 --- a/src/metadata/yaml.rs +++ b/src/metadata/yaml/parse.rs @@ -1,9 +1,9 @@ use std::str::FromStr; use loot_condition_interpreter::Expression; -use saphyr::{AnnotatedArray, AnnotatedHash, MarkedYaml, Marker, Yaml, YamlData}; +use saphyr::{AnnotatedHash, MarkedYaml, Marker, Yaml, YamlData}; -use super::error::{ExpectedType, MetadataParsingErrorReason, ParseMetadataError}; +use super::super::error::{ExpectedType, MetadataParsingErrorReason, ParseMetadataError}; #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub enum YamlObjectType { @@ -42,24 +42,18 @@ pub fn to_yaml(yaml: &MarkedYaml) -> Yaml { saphyr::YamlData::Integer(v) => Yaml::Integer(*v), saphyr::YamlData::String(v) => Yaml::String(v.clone()), saphyr::YamlData::Boolean(v) => Yaml::Boolean(*v), - saphyr::YamlData::Array(v) => Yaml::Array(to_array(v)), - saphyr::YamlData::Hash(v) => Yaml::Hash(to_hash(v)), + saphyr::YamlData::Array(v) => Yaml::Array(v.iter().map(to_yaml).collect()), + saphyr::YamlData::Hash(v) => Yaml::Hash( + v.iter() + .map(|(key, value)| (to_yaml(key), to_yaml(value))) + .collect(), + ), saphyr::YamlData::Alias(v) => Yaml::Alias(*v), saphyr::YamlData::Null => Yaml::Null, saphyr::YamlData::BadValue => Yaml::BadValue, } } -fn to_array(array: &AnnotatedArray) -> saphyr::Array { - array.iter().map(to_yaml).collect() -} - -fn to_hash(hash: &AnnotatedHash) -> saphyr::Hash { - hash.iter() - .map(|(key, value)| (to_yaml(key), to_yaml(value))) - .collect() -} - pub fn as_string_node(value: &str) -> MarkedYaml { MarkedYaml { span: saphyr_parser::Span::default(), From 415242b299cfcb3c2824da5ee525dd08ac4e8f30 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 25 Mar 2025 19:42:12 +0000 Subject: [PATCH 048/206] Fix formatting of YAML merge key error messages --- src/metadata/error.rs | 3 ++- src/metadata/yaml/merge.rs | 46 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/metadata/error.rs b/src/metadata/error.rs index 50b33752..e3ae9d4a 100644 --- a/src/metadata/error.rs +++ b/src/metadata/error.rs @@ -347,12 +347,13 @@ impl std::fmt::Display for YamlMergeKeyError { let yaml = to_yaml(&self.value); if saphyr::YamlEmitter::new(&mut output).dump(&yaml).is_ok() { + // The emitter starts the dumped YAML with ---\n, so strip that. write!( f, "invalid YAML merge key value at line {} column {}: {}", self.value.span.start.line(), self.value.span.start.col(), - output + output.get(4..).unwrap_or_default() ) } else { write!( diff --git a/src/metadata/yaml/merge.rs b/src/metadata/yaml/merge.rs index 89994125..f16c0b88 100644 --- a/src/metadata/yaml/merge.rs +++ b/src/metadata/yaml/merge.rs @@ -72,3 +72,49 @@ fn merge_hashes( } hash1 } + +#[cfg(test)] +mod tests { + use super::*; + + mod process_merge_keys { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_merge_key_value_has_a_single_value_that_is_not_a_hash() { + let yaml = parse( + " +- &anchor1 test +- <<: *anchor1 + value: test-value-2", + ); + + let error_message = process_merge_keys(yaml).unwrap_err().to_string(); + + assert_eq!( + "invalid YAML merge key value at line 3 column 6: test", + error_message + ); + } + + #[test] + fn should_error_if_merge_key_value_is_an_array_of_non_hash_values() { + let yaml = parse( + " +- &anchor1 {key: test-key} +- &anchor2 test +- <<: [*anchor1, *anchor2] + value: test-value-2", + ); + + let error_message = process_merge_keys(yaml).unwrap_err().to_string(); + + assert_eq!( + "invalid YAML merge key value at line 4 column 17: test", + error_message + ); + } + } +} From 967ce652c15c5129d3ffdf24009339a5f2a497ca Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 25 Mar 2025 19:42:28 +0000 Subject: [PATCH 049/206] Improve test coverage of YAML parsing code --- src/metadata/group.rs | 11 ++++++++++ src/metadata/location.rs | 14 +++++++++++++ src/metadata/plugin_cleaning_data.rs | 30 ++++++++++++++++++++++++++++ src/metadata/plugin_metadata.rs | 7 +++++++ 4 files changed, 62 insertions(+) diff --git a/src/metadata/group.rs b/src/metadata/group.rs index 4779fa37..da5177cd 100644 --- a/src/metadata/group.rs +++ b/src/metadata/group.rs @@ -153,6 +153,17 @@ mod tests { assert!(Group::try_from(&yaml).is_err()); } + #[test] + fn should_error_if_after_is_not_an_array_of_strings() { + let yaml = parse("{name: group1, after: other_group}"); + + assert!(Group::try_from(&yaml).is_err()); + + let yaml = parse("{name: group1, after: [0, 1]}"); + + assert!(Group::try_from(&yaml).is_err()); + } + #[test] fn should_set_all_given_fields() { let yaml = parse("{name: group1, description: text, after: [ other_group ]}"); diff --git a/src/metadata/location.rs b/src/metadata/location.rs index 33d56886..4e062e78 100644 --- a/src/metadata/location.rs +++ b/src/metadata/location.rs @@ -140,6 +140,20 @@ mod tests { assert!(Location::try_from(&yaml).is_err()); } + #[test] + fn should_error_if_link_is_not_a_string() { + let yaml = parse("{link: [https://www.example.com], name: example}"); + + assert!(Location::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_not_a_string() { + let yaml = parse("{link: https://www.example.com, name: [example]}"); + + assert!(Location::try_from(&yaml).is_err()); + } + #[test] fn should_error_if_name_is_missing() { let yaml = parse("{link: https://www.example.com}"); diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index 2365e913..a6ec33dd 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -248,6 +248,36 @@ mod tests { assert!(PluginCleaningData::try_from(&yaml).is_err()); } + #[test] + fn should_error_if_a_count_is_not_a_number() { + let yaml = parse("{crc: 0x12345678, util: cleaner, itm: true}"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + + let yaml = parse("{crc: 0x12345678, util: cleaner, udr: true}"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + + let yaml = parse("{crc: 0x12345678, util: cleaner, nav: true}"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_a_count_does_not_fit_in_a_u32() { + let yaml = parse("{crc: 0x12345678, util: cleaner, itm: -1}"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + + let yaml = parse("{crc: 0x12345678, util: cleaner, udr: -2}"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + + let yaml = parse("{crc: 0x12345678, util: cleaner, nav: -3}"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + } + #[test] fn should_set_all_given_fields() { let yaml = diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 30c8cd99..5fc3c9f4 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -938,6 +938,13 @@ mod tests { assert!(PluginMetadata::try_from(&yaml).is_err()); } + + #[test] + fn should_error_if_a_field_that_should_be_an_array_is_not() { + let yaml = parse("{name: 'Blank.esp', after: Blank.esm}"); + + assert!(PluginMetadata::try_from(&yaml).is_err()); + } } mod emit_yaml { From b90e73c3fbf78c290e9293f13891de5d1949870f Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 25 Mar 2025 19:42:54 +0000 Subject: [PATCH 050/206] Fix string quoting behaviour when emitting YAML --- src/metadata/yaml/emit.rs | 88 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/src/metadata/yaml/emit.rs b/src/metadata/yaml/emit.rs index e0c0fb18..8fc1053d 100644 --- a/src/metadata/yaml/emit.rs +++ b/src/metadata/yaml/emit.rs @@ -178,10 +178,16 @@ fn is_yaml_whitespace(c: char) -> bool { c == ' ' || c == '\t' } +fn is_yaml_line_break(c: char) -> bool { + c == '\x0A' || c == '\x0D' +} + fn is_flow_indicator(c: char) -> bool { matches!(c, '[' | ']' | '{' | '}' | ',') } +/// This disallows multi-line unquoted strings, which YAML does allow in some +/// contexts, but there's no expectation of such strings coming out of libloot. fn can_emit_unquoted(value: &str, style: YamlStyle) -> bool { // if value.is_empty() @@ -230,11 +236,13 @@ fn can_emit_unquoted(value: &str, style: YamlStyle) -> bool { return false; } - if style == YamlStyle::Flow { - !value.contains(is_flow_indicator) - } else { - true + if style == YamlStyle::Flow && value.contains(is_flow_indicator) { + return false; } + + value + .chars() + .all(|c| is_printable(c) && !is_yaml_line_break(c) && c != '\u{FEFF}') } fn single_quote(value: &str) -> String { @@ -245,7 +253,7 @@ fn single_quote(value: &str) -> String { fn double_quote(value: &str) -> String { // - value + let escaped: String = value .chars() .map(|c| { if is_printable(c) { @@ -275,7 +283,9 @@ fn double_quote(value: &str) -> String { } } }) - .collect() + .collect(); + + format!("\"{}\"", escaped) } impl EmitYaml for &[T] { @@ -307,3 +317,69 @@ impl EmitYaml for Vec { self.as_slice().emit_yaml(emitter); } } + +#[cfg(test)] +mod tests { + use super::*; + + mod yaml_emitter { + use super::*; + + mod unquoted_str { + use super::*; + + #[test] + fn unquoted_str_should_emit_string_as_given() { + let value = "hello world"; + let mut emitter = YamlEmitter::new(); + emitter.unquoted_str(value); + + assert_eq!(value, emitter.into_string()); + } + + #[test] + fn unquoted_str_should_fall_back_to_single_quoting_string_if_it_cannot_be_emitted_unquoted() + { + let value = " hello world"; + let mut emitter = YamlEmitter::new(); + emitter.unquoted_str(value); + + assert_eq!(format!("'{}'", value), emitter.into_string()); + } + + #[test] + fn unquoted_str_should_fall_back_to_double_quoting_string_if_it_cannot_be_unquoted_or_single_quoted() + { + let value = "\x1B[1mhello world\x1B[0m"; + let mut emitter = YamlEmitter::new(); + emitter.unquoted_str(value); + + assert_eq!("\"\\e[1mhello world\\e[0m\"", emitter.into_string()); + } + } + + mod single_quoted_str { + use super::*; + + #[test] + fn single_quoted_str_should_emit_string_wrapped_in_single_quotes_and_with_single_quotes_doubled() + { + let value = "hello 'world'"; + let mut emitter = YamlEmitter::new(); + emitter.single_quoted_str(value); + + assert_eq!("'hello ''world'''", emitter.into_string()); + } + + #[test] + fn single_quoted_str_should_fall_back_to_double_quoting_string_if_it_contains_non_printable_characters() + { + let value = "\x1B[1mhello world\x1B[0m"; + let mut emitter = YamlEmitter::new(); + emitter.single_quoted_str(value); + + assert_eq!("\"\\e[1mhello world\\e[0m\"", emitter.into_string()); + } + } + } +} From 4e0dd214955aec436f97c28bfc29db1b814f4c73 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 25 Mar 2025 22:22:49 +0000 Subject: [PATCH 051/206] Add more warnings to the readme --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 601b878f..d6d94549 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,25 @@ This is an **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++, that should match libloot v0.25.5. +See the `cxx` and `pyo3` subdirectories for C++ and Python wrappers respectively. + +Expect: + +- bugs +- Git history to be rewritten without warning +- this Git repository to disappear without warning + +If this experiment is successful it will probably end up being merged into the libloot repository, replacing much of its contents. + +## Outstanding issues + +- I haven't yet attempted to build it on Linux. +- There is no CI. +- It relies on some unreleased improvements to the Rust APIs of libloadorder and loot-condition-interpreter. +- It uses more memory than the C++ implementation, and I haven't finished investigating why or if there's anything I can/should do about that. +- I'm not very confident about the YAML emitter I've written, it needs more test cases and probably another review against the spec. +- The `saphyr` and `saphyr-parser` dependencies that are used to parse metadata YAML have some rough edges, and compared to the other dependencies they are relatively new, unpopular libraries that are maintained by people I've not heard of before, so they may be relatively risky dependencies. + ## Build Make sure you have [Rust](https://www.rust-lang.org/) installed. From 7d2324816af4b63890aa10ceb6c90d08bf9fa579 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 26 Mar 2025 17:56:36 +0000 Subject: [PATCH 052/206] Fix and document Linux build process --- README.md | 37 +++++++++++++++++++-- cxx/CMakeLists.txt | 16 +++------ cxx/README.md | 42 +++++++++++++++++++----- cxx/cmake/tests.cmake | 6 ++-- cxx/src/api/game.cpp | 7 ++-- cxx/src/api/metadata/plugin_metadata.cpp | 1 + pyo3/README.md | 28 +++++++++------- src/archive/find.rs | 6 ++-- src/game.rs | 12 ++++--- 9 files changed, 106 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index d6d94549..2f298770 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ If this experiment is successful it will probably end up being merged into the l ## Outstanding issues -- I haven't yet attempted to build it on Linux. - There is no CI. - It relies on some unreleased improvements to the Rust APIs of libloadorder and loot-condition-interpreter. - It uses more memory than the C++ implementation, and I haven't finished investigating why or if there's anything I can/should do about that. @@ -25,19 +24,51 @@ If this experiment is successful it will probably end up being merged into the l Make sure you have [Rust](https://www.rust-lang.org/) installed. -To build the library, set the `LIBLOOT_REVISION` env var and then run Cargo. Using PowerShell: +To build the library, set the `LIBLOOT_REVISION` env var and then run Cargo. + +Using PowerShell: ```powershell $env:LIBLOOT_REVISION = git rev-parse --short HEAD cargo build --release ``` -The tests include a complete port of libloot's tests, and can be run by first extracting the [testing-plugins](https://github.com/Ortham/testing-plugins) archive to this readme's directory (so that there's a `testing-plugins` directory there), then running: +Using a POSIX shell: + +```sh +export LIBLOOT_REVISION=$(git rev-parse --short HEAD) +cargo build --release +``` + +`LIBLOOT_REVISION` is used to embed the commit hash into the build, if it's not defined then `unknown` will be used instead. + +### Tests + +The tests include a complete port of libloot's tests, and can be run by first extracting the [testing-plugins](https://github.com/Ortham/testing-plugins) archive to this readme's directory (so that there's a `testing-plugins` directory there). + +To do that using `curl` and `tar` in a POSIX shell: + +```sh +curl -sSfL https://github.com/Ortham/testing-plugins/archive/refs/tags/1.6.2.tar.gz | tar -xz --strip=1 --one-top-level=testing-plugins +``` + +To do that in PowerShell: + +```powershell +Invoke-WebRequest https://github.com/Ortham/testing-plugins/archive/refs/tags/1.6.2.zip -OutFile testing-plugins-1.6.2.zip +Expand-Archive testing-plugins-1.6.2.zip . +Move-Item testing-plugins-1.6.2 testing-plugins +Remove-Item testing-plugins-1.6.2.zip +``` + +The tests can then be run using: ``` cargo test ``` +### API documentation + The public API has doc comments copied from libloot, and the API documentation can be built and viewed using: ``` diff --git a/cxx/CMakeLists.txt b/cxx/CMakeLists.txt index 68e71ae3..119255a7 100644 --- a/cxx/CMakeLists.txt +++ b/cxx/CMakeLists.txt @@ -25,9 +25,10 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) ############################## add_library(libloot-cxx STATIC IMPORTED) -# TODO: Define the Rust libloot library path here for Linux builds. if(MSVC) set(LIBLOOT_CXX_FILENAME "libloot_cxx.lib") +else() + set(LIBLOOT_CXX_FILENAME "liblibloot_cxx.a") endif() set_target_properties(libloot-cxx PROPERTIES @@ -145,20 +146,11 @@ target_include_directories(loot SYSTEM PRIVATE ${LIBLOOT_COMMON_SYSTEM_INCLUDE_DIRS}) if(CMAKE_SYSTEM_NAME STREQUAL "Windows") - target_compile_definitions(loot PRIVATE - UNICODE _UNICODE LOOT_EXPORT YAML_CPP_STATIC_DEFINE) + target_compile_definitions(loot PRIVATE UNICODE _UNICODE LOOT_EXPORT) set(LOOT_LIBS ntdll ws2_32 bcrypt) - if(NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") - set(LOOT_LIBS ${LOOT_LIBS} tbb_static) - endif() - target_link_libraries(loot PRIVATE ${LOOT_LIBS}) -else() - set(LOOT_LIBS ICU::data ICU::uc pthread TBB::tbb) - - target_link_libraries(loot PUBLIC ${LOOT_LIBS}) endif() if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") @@ -282,6 +274,8 @@ install(FILES ######################################## if(NOT DEFINED CPACK_PACKAGE_VERSION) + find_package(Git) + if(GIT_FOUND) execute_process(COMMAND ${GIT_EXECUTABLE} describe --tags --long --always --abbrev=7 WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} diff --git a/cxx/README.md b/cxx/README.md index ff19250a..1c929778 100644 --- a/cxx/README.md +++ b/cxx/README.md @@ -2,39 +2,63 @@ This is an **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface that's ABI-compatible with libloot v0.25.5. -## Building - The wrapper has two layers: - a static library built using Cargo, which provides a C++ interface - a shared library built using CMake, which wraps that C++ interface to provide another that is ABI-compatible with C++ libloot. -To build the wrapper: +## Building + +### Windows + +To build a release build with debug info: ``` cargo build --release cmake -B build . -cmake --build build --config RelWithDebInfo +cmake --build build --parallel --config RelWithDebInfo ``` -Debug builds need a little extra config to get the static and shared libraries to use the same C runtime library on Windows. +Debug builds need a little extra config to get the static and shared libraries to use the same C runtime library on Windows: ``` cargo build --config ../.cargo/msvcd-config.toml cmake -B build . -cmake --build build --config Debug +cmake --build build --parallel --config Debug ``` -This also builds a copy of the public API tests from C++ libloot v0.25.5, which can be run using: +### Linux + +To build a release build with debug info: ``` -ctest --test-dir build --output-on-failure -V +cargo build --release +cmake -B build . -DCMAKE_BUILD_TYPE=RelWithDebInfo +cmake --build build --parallel +``` + +to build a debug build: + +``` +cargo build +cmake -B build . -DCMAKE_BUILD_TYPE=Debug +cmake --build build --parallel +``` + +### Tests & Packaging + +The build process also builds a copy of the public API tests from C++ libloot v0.25.5 by default. To skip building the tests, pass `-DLIBLOOT_BUILD_TESTS=OFF` when first running CMake. + +If built, the tests can be run using: + +``` +ctest --test-dir build --output-on-failure --parallel -V ``` To package the build: ``` -cpack --config build/CPackConfig.cmake +cpack --config build/CPackConfig.cmake -C RelWithDebInfo ``` This repository (and so the created package) doesn't currently include any of libloot's documentation, besides the API documentation included in the Rust source code. diff --git a/cxx/cmake/tests.cmake b/cxx/cmake/tests.cmake index efdc73fd..f415887e 100644 --- a/cxx/cmake/tests.cmake +++ b/cxx/cmake/tests.cmake @@ -121,10 +121,10 @@ target_compile_definitions(libloot_internals_tests PRIVATE if(NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") target_compile_definitions(libloot_tests PRIVATE LOOT_STATIC) endif() -endif() -target_link_libraries(libloot_internals_tests PRIVATE ${LOOT_LIBS}) -target_link_libraries(libloot_tests PRIVATE ${LOOT_LIBS}) + target_link_libraries(libloot_internals_tests PRIVATE ${LOOT_LIBS}) + target_link_libraries(libloot_tests PRIVATE ${LOOT_LIBS}) +endif() if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") target_compile_options(libloot_internals_tests PRIVATE "-Wall" "-Wextra") diff --git a/cxx/src/api/game.cpp b/cxx/src/api/game.cpp index 836044b8..324149eb 100644 --- a/cxx/src/api/game.cpp +++ b/cxx/src/api/game.cpp @@ -69,11 +69,10 @@ rust::Box constructGame( const std::filesystem::path& localDataPath) { try { if (localDataPath.empty()) { - return std::move( - loot::rust::new_game(convert(gameType), gamePath.u8string())); + return loot::rust::new_game(convert(gameType), gamePath.u8string()); } else { - return std::move(loot::rust::new_game_with_local_path( - convert(gameType), gamePath.u8string(), localDataPath.u8string())); + return loot::rust::new_game_with_local_path( + convert(gameType), gamePath.u8string(), localDataPath.u8string()); } } catch (const ::rust::Error& e) { std::rethrow_exception(loot::mapError(e)); diff --git a/cxx/src/api/metadata/plugin_metadata.cpp b/cxx/src/api/metadata/plugin_metadata.cpp index c3c9b3f5..2297c2a2 100644 --- a/cxx/src/api/metadata/plugin_metadata.cpp +++ b/cxx/src/api/metadata/plugin_metadata.cpp @@ -24,6 +24,7 @@ #include "loot/metadata/plugin_metadata.h" +#include #include #include diff --git a/pyo3/README.md b/pyo3/README.md index 087e5267..588f7161 100644 --- a/pyo3/README.md +++ b/pyo3/README.md @@ -4,26 +4,32 @@ An **experimental** Python wrapper around the libloot Rust implementation, built ## Build -To build, first set up a Python venv and install [maturin](https://github.com/PyO3/maturin): +To build, first set up a Python virtual environment and install [maturin](https://github.com/PyO3/maturin): -``` +```powershell python -m venv .venv .\.venv\Scripts\activate pip install maturin +``` + +or in a POSIX shell: + +```sh +python -m venv .venv +. .venv/bin/activate +pip install maturin +``` + +Then build the library in the virtual environment: + +``` maturin develop ``` -Then build the library: +The library can then be imported in Python: ``` -.\.venv\Scripts\activate -maturin develop -``` - -Then import it in Python: - -``` -py +python > import loot ``` diff --git a/src/archive/find.rs b/src/archive/find.rs index aa8aea3e..a6e87061 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -174,7 +174,7 @@ fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { return true; } - use std::fs::unix::fs::MetadataExt; + use std::os::unix::fs::MetadataExt; let lhs_metadata = match lhs.metadata() { Ok(m) => m, @@ -474,7 +474,7 @@ mod tests { #[test] #[cfg(not(windows))] fn should_be_false_if_given_case_insensitively_equal_paths_that_exist() { - let tmp_dir = tempdir(); + let tmp_dir = tempdir().unwrap(); let file_path1 = tmp_dir.path().join("test"); let file_path2 = tmp_dir.path().join("TEST"); @@ -483,7 +483,7 @@ mod tests { assert!(file_path1.exists()); assert!(file_path2.exists()); - assert!(!are_file_paths_equivalent(file_path1, file_path2)); + assert!(!are_file_paths_equivalent(&file_path1, &file_path2)); } } } diff --git a/src/game.rs b/src/game.rs index 1b6e218c..39dc6c0d 100644 --- a/src/game.rs +++ b/src/game.rs @@ -893,6 +893,8 @@ mod tests { #[cfg(not(windows))] #[apply(all_game_types)] fn should_succeed_for_morrowind_if_given_valid_game_path(game_type: GameType) { + let fixture = Fixture::new(game_type); + if matches!(game_type, GameType::TES3 | GameType::OpenMW) { assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); } else { @@ -902,7 +904,7 @@ mod tests { #[test] fn should_succeed_if_given_a_relative_game_path() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::TES3); let game_path = make_relative(&fixture.game_path); assert!(game_path.is_relative()); @@ -912,7 +914,7 @@ mod tests { #[test] fn should_succeed_if_given_an_absolute_game_path() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::TES3); assert!(fixture.game_path.is_absolute()); assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); @@ -920,7 +922,7 @@ mod tests { #[test] fn should_succeed_if_given_a_symlink_path() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::TES3); let game_path = fixture.game_path.with_extension("symlink"); symlink_dir(&fixture.game_path, &game_path); @@ -932,7 +934,7 @@ mod tests { #[cfg(windows)] #[test] fn should_succeed_if_given_a_junction_link_path() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::TES3); let game_path = fixture.game_path.with_extension("junction"); junction_link(&fixture.game_path, &game_path); @@ -943,7 +945,7 @@ mod tests { #[test] fn should_error_if_given_a_game_path_that_does_not_exist() { let game_path = Path::new("missing"); - match Game::new(GameType::TES4, game_path) { + match Game::new(GameType::TES3, game_path) { Err(GameHandleCreationError::NotADirectory(p)) => { assert_eq!(game_path, p) } From 6538a91468b6e8d6b61ead5eefa8b1816ef9a4ef Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 26 Mar 2025 20:06:13 +0000 Subject: [PATCH 053/206] Fix relative path tests They'd fail when the current working directory was on a different drive to the default temporary directory. --- src/game.rs | 4 ++-- src/tests.rs | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/game.rs b/src/game.rs index 39dc6c0d..a4f3d4e2 100644 --- a/src/game.rs +++ b/src/game.rs @@ -904,7 +904,7 @@ mod tests { #[test] fn should_succeed_if_given_a_relative_game_path() { - let fixture = Fixture::new(GameType::TES3); + let fixture = Fixture::in_path(GameType::TES3, Path::new("target")); let game_path = make_relative(&fixture.game_path); assert!(game_path.is_relative()); @@ -972,7 +972,7 @@ mod tests { #[test] fn should_succeed_if_given_relative_paths() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::in_path(GameType::TES3, Path::new("target")); let game_path = make_relative(&fixture.game_path); assert!(game_path.is_relative()); diff --git a/src/tests.rs b/src/tests.rs index d4fd2631..b7a4be8c 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -179,6 +179,20 @@ impl Fixture { .prefix("libloot-t\u{00E9}st-") .tempdir() .unwrap(); + + Self::with_tempdir(game_type, temp_dir) + } + + pub fn in_path(game_type: GameType, in_path: &Path) -> Fixture { + let temp_dir = tempfile::Builder::new() + .prefix("libloot-t\u{00E9}st-") + .tempdir_in(in_path) + .unwrap(); + + Self::with_tempdir(game_type, temp_dir) + } + + fn with_tempdir(game_type: GameType, temp_dir: TempDir) -> Fixture { let root_path = temp_dir.path(); let game_path = root_path.join("games/game"); let local_path = root_path.join("local/game"); From 8054a8a31a23237b5b3338004fb680b3506adf8a Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 26 Mar 2025 21:43:27 +0000 Subject: [PATCH 054/206] Allow the relevant Rust target triple to be supplied to CMake --- cxx/CMakeLists.txt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/cxx/CMakeLists.txt b/cxx/CMakeLists.txt index 119255a7..11cfda75 100644 --- a/cxx/CMakeLists.txt +++ b/cxx/CMakeLists.txt @@ -25,17 +25,24 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) ############################## add_library(libloot-cxx STATIC IMPORTED) + if(MSVC) set(LIBLOOT_CXX_FILENAME "libloot_cxx.lib") else() set(LIBLOOT_CXX_FILENAME "liblibloot_cxx.a") endif() +if(DEFINED RUST_TARGET) + set(TARGET_PATH "${CMAKE_SOURCE_DIR}/../target/${RUST_TARGET}") +else() + set(TARGET_PATH "${CMAKE_SOURCE_DIR}/../target") +endif() + set_target_properties(libloot-cxx PROPERTIES IMPORTED_CONFIGURATIONS "Release;Debug" - IMPORTED_LOCATION "${CMAKE_SOURCE_DIR}/../target/release/${LIBLOOT_CXX_FILENAME}" - IMPORTED_LOCATION_DEBUG "${CMAKE_SOURCE_DIR}/../target/debug/${LIBLOOT_CXX_FILENAME}") -target_include_directories(libloot-cxx INTERFACE "${CMAKE_SOURCE_DIR}/../target/cxxbridge") + IMPORTED_LOCATION "${TARGET_PATH}/release/${LIBLOOT_CXX_FILENAME}" + IMPORTED_LOCATION_DEBUG "${TARGET_PATH}/debug/${LIBLOOT_CXX_FILENAME}") +target_include_directories(libloot-cxx INTERFACE "${TARGET_PATH}/cxxbridge") ############################## From f19dc4ef322428bdbb81d358d4b8c7b9af5814dd Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 26 Mar 2025 17:57:24 +0000 Subject: [PATCH 055/206] Add a GitHub Actions CI workflow --- .github/workflows/ci.yml | 193 +++++++++++++++++++++++++++++++++++++++ README.md | 1 - 2 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c3da1a7a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,193 @@ +name: CI + +on: + push: + # Don't run this workflow when a tag is pushed. + branches: + - '*' + pull_request: + +env: + CARGO_TERM_COLOR: always + CMAKE_CONFIG: RelWithDebInfo + +jobs: + rustfmt: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Check formatting + run: | + cargo fmt -- --version + cargo fmt --all -- --check + + clippy: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Run clippy + run: | + cargo clippy -- --version + cargo clippy --all --all-targets -- -Dwarnings + + build: + strategy: + matrix: + target: + - os: windows-2019 + platform: Win32 + triple: i686-pc-windows-msvc + architecture: x86 + + - os: windows-2019 + platform: x64 + triple: x86_64-pc-windows-msvc + architecture: x64 + + - os: ubuntu-24.04 + platform: x86_64 + triple: x86_64-unknown-linux-gnu + architecture: x64 + + runs-on: ${{ matrix.target.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + architecture: ${{ matrix.target.architecture }} + + - name: pip cache + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-maturin + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Install Rust i686-pc-windows-msvc target + run: rustup target add i686-pc-windows-msvc + if: matrix.target.platform == 'Win32' + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Prepare test resources + shell: pwsh + run: | + Invoke-WebRequest https://github.com/Ortham/testing-plugins/archive/refs/tags/1.6.2.zip -OutFile testing-plugins-1.6.2.zip + Expand-Archive testing-plugins-1.6.2.zip . + Move-Item testing-plugins-1.6.2 testing-plugins + Remove-Item testing-plugins-1.6.2.zip + + - name: Set LIBLOOT_REVISION + shell: bash + run: echo "LIBLOOT_REVISION=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + + - name: Build and run tests with code coverage + run: cargo llvm-cov --lcov --output-path lcov.info --target ${{ matrix.target.triple }} + + - name: Upload code coverage to Coveralls + uses: coverallsapp/github-action@v2 + if: github.event_name == 'push' + + - name: Build the Python wrapper + shell: bash + working-directory: pyo3 + run: | + python -m venv .venv + + if [[ "${{ runner.os }}" == "Windows" ]] + then + ./.venv/Scripts/activate + else + . .venv/bin/activate + fi + + pip install maturin + + maturin build --target ${{ matrix.target.triple }} --release + + - name: Build the CXX wrapper layer + working-directory: cxx + run: cargo build --release --target ${{ matrix.target.triple }} + + - name: Get descriptive libloot version + id: get-libloot-version + shell: bash + run: | + GIT_DESCRIBE=$(git describe --tags --long --always --abbrev=7) + LIBLOOT_DESC_REF=${GIT_DESCRIBE}_${GITHUB_REF#refs/*/} + LIBLOOT_SAFE_DESC_REF=${LIBLOOT_DESC_REF//[\/<>\"|]/_} + echo "version=$LIBLOOT_SAFE_DESC_REF" >> $GITHUB_OUTPUT + + - name: Build the C++ wrapper (Linux) + working-directory: cxx + run: | + cmake \ + -DCMAKE_BUILD_TYPE=${{ env.CMAKE_CONFIG }} \ + -DCPACK_PACKAGE_VERSION="${{ steps.get-libloot-version.outputs.version }}" \ + -DRUST_TARGET="${{ matrix.target.triple }}" \ + -B build + cmake --build build --parallel + if: runner.os == 'Linux' + + - name: Build the C++ wrapper (Windows) + working-directory: cxx + run: | + cmake -G "Visual Studio 16 2019" ` + -A ${{ matrix.target.platform }} ` + -DCPACK_PACKAGE_VERSION="${{ steps.get-libloot-version.outputs.version }}" ` + -DRUST_TARGET="${{ matrix.target.triple }}" ` + -B build + cmake --build build --parallel --config ${{ env.CMAKE_CONFIG }} + if: runner.os == 'Windows' + + - name: Run the C++ wrapper tests + working-directory: cxx + run: ctest --test-dir build --build-config ${{ env.CMAKE_CONFIG }} --output-on-failure --parallel + + - name: Package the C++ wrapper + working-directory: cxx/build + run: cpack -C ${{ env.CMAKE_CONFIG }} + + - name: Get archive name + id: archive-name + shell: bash + run: | + VERSION="${{ steps.get-libloot-version.outputs.version }}" + + if [[ "${{ runner.os }}" == "Windows" ]] + then + EXTENSION=7z + if [[ "${{ matrix.target.platform }}" == "Win32" ]] + then + PLATFORM=win32 + else + PLATFORM=win64 + fi + else + EXTENSION=tar.xz + PLATFORM=Linux + fi + + echo "filename=libloot-${VERSION}-${PLATFORM}.${EXTENSION}" >> $GITHUB_OUTPUT + + - name: Upload archive + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.archive-name.outputs.filename }} + path: cxx/build/package/${{ steps.archive-name.outputs.filename }} + if: github.event_name == 'push' diff --git a/README.md b/README.md index 2f298770..47cb100a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ If this experiment is successful it will probably end up being merged into the l ## Outstanding issues -- There is no CI. - It relies on some unreleased improvements to the Rust APIs of libloadorder and loot-condition-interpreter. - It uses more memory than the C++ implementation, and I haven't finished investigating why or if there's anything I can/should do about that. - I'm not very confident about the YAML emitter I've written, it needs more test cases and probably another review against the spec. From 23a00630e4d20e25509d83734f5b0d1b9f528c31 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 26 Mar 2025 21:21:16 +0000 Subject: [PATCH 056/206] Update libloadorder to v18.3.0 --- Cargo.lock | 116 ++++++++++++++++++++++++++++++++++++++++++++--------- Cargo.toml | 2 +- 2 files changed, 97 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd8f188b..c29d67ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -564,8 +564,9 @@ checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" [[package]] name = "libloadorder" -version = "18.2.2" -source = "git+https://github.com/Ortham/libloadorder.git?rev=6245dbc6824c4751daef5ab01dfe7b9497f5446f#6245dbc6824c4751daef5ab01dfe7b9497f5446f" +version = "18.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "034ffaf7bf5bd34669741500d4ccddc5dbc62c491cdb0c5e103c592da535dbeb" dependencies = [ "dirs", "encoding_rs", @@ -575,7 +576,7 @@ dependencies = [ "regex", "rust-ini", "unicase", - "windows", + "windows 0.61.1", ] [[package]] @@ -597,7 +598,7 @@ dependencies = [ "saphyr-parser", "tempfile", "unicase", - "windows", + "windows 0.60.0", ] [[package]] @@ -1401,11 +1402,24 @@ version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddf874e74c7a99773e62b1c671427abf01a425e77c3d3fb9fb1e4883ea934529" dependencies = [ - "windows-collections", - "windows-core", - "windows-future", + "windows-collections 0.1.1", + "windows-core 0.60.1", + "windows-future 0.1.1", "windows-link", - "windows-numerics", + "windows-numerics 0.1.1", +] + +[[package]] +name = "windows" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.0", + "windows-future 0.2.0", + "windows-link", + "windows-numerics 0.2.0", ] [[package]] @@ -1414,7 +1428,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5467f79cc1ba3f52ebb2ed41dbb459b8e7db636cc3429458d9a852e15bc24dec" dependencies = [ - "windows-core", + "windows-core 0.60.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.0", ] [[package]] @@ -1423,11 +1446,24 @@ version = "0.60.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247" dependencies = [ - "windows-implement", + "windows-implement 0.59.0", "windows-interface", "windows-link", "windows-result", - "windows-strings", + "windows-strings 0.3.1", +] + +[[package]] +name = "windows-core" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +dependencies = [ + "windows-implement 0.60.0", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings 0.4.0", ] [[package]] @@ -1436,7 +1472,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0" dependencies = [ - "windows-core", + "windows-core 0.60.1", + "windows-link", +] + +[[package]] +name = "windows-future" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" +dependencies = [ + "windows-core 0.61.0", "windows-link", ] @@ -1452,10 +1498,21 @@ dependencies = [ ] [[package]] -name = "windows-interface" -version = "0.59.0" +name = "windows-implement" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26fd936d991781ea39e87c3a27285081e3c0da5ca0fcbc02d368cc6f52ff01" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", @@ -1464,9 +1521,9 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" [[package]] name = "windows-numerics" @@ -1474,15 +1531,25 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed" dependencies = [ - "windows-core", + "windows-core 0.60.1", + "windows-link", +] + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.0", "windows-link", ] [[package]] name = "windows-result" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06374efe858fab7e4f881500e6e86ec8bc28f9462c47e5a9941a0142ad86b189" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" dependencies = [ "windows-link", ] @@ -1496,6 +1563,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index 4b79a434..022097d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ members = ["cxx", "ffi-errors", "pyo3"] [workspace.dependencies] esplugin = "6.1.1" -libloadorder = { git = "https://github.com/Ortham/libloadorder.git", rev = "6245dbc6824c4751daef5ab01dfe7b9497f5446f" } +libloadorder = "18.3.0" loot-condition-interpreter = { git = "https://github.com/loot/loot-condition-interpreter.git", rev = "f7d9947fa434037743ce81e2d409184ec0bfb693" } [profile.release] From fb6d3b1260c61858b90e9a733afb4eaaaeaaea71 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 26 Mar 2025 21:21:55 +0000 Subject: [PATCH 057/206] Update windows crate to v0.61.1 --- Cargo.lock | 97 +++++++----------------------------------------------- Cargo.toml | 2 +- 2 files changed, 12 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c29d67ef..037cc46d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -576,7 +576,7 @@ dependencies = [ "regex", "rust-ini", "unicase", - "windows 0.61.1", + "windows", ] [[package]] @@ -598,7 +598,7 @@ dependencies = [ "saphyr-parser", "tempfile", "unicase", - "windows 0.60.0", + "windows", ] [[package]] @@ -1396,39 +1396,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf874e74c7a99773e62b1c671427abf01a425e77c3d3fb9fb1e4883ea934529" -dependencies = [ - "windows-collections 0.1.1", - "windows-core 0.60.1", - "windows-future 0.1.1", - "windows-link", - "windows-numerics 0.1.1", -] - [[package]] name = "windows" version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" dependencies = [ - "windows-collections 0.2.0", - "windows-core 0.61.0", - "windows-future 0.2.0", + "windows-collections", + "windows-core", + "windows-future", "windows-link", - "windows-numerics 0.2.0", -] - -[[package]] -name = "windows-collections" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5467f79cc1ba3f52ebb2ed41dbb459b8e7db636cc3429458d9a852e15bc24dec" -dependencies = [ - "windows-core 0.60.1", + "windows-numerics", ] [[package]] @@ -1437,20 +1415,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.0", -] - -[[package]] -name = "windows-core" -version = "0.60.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247" -dependencies = [ - "windows-implement 0.59.0", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings 0.3.1", + "windows-core", ] [[package]] @@ -1459,21 +1424,11 @@ version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" dependencies = [ - "windows-implement 0.60.0", + "windows-implement", "windows-interface", "windows-link", "windows-result", - "windows-strings 0.4.0", -] - -[[package]] -name = "windows-future" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0" -dependencies = [ - "windows-core 0.60.1", - "windows-link", + "windows-strings", ] [[package]] @@ -1482,21 +1437,10 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" dependencies = [ - "windows-core 0.61.0", + "windows-core", "windows-link", ] -[[package]] -name = "windows-implement" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-implement" version = "0.60.0" @@ -1525,23 +1469,13 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" -[[package]] -name = "windows-numerics" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed" -dependencies = [ - "windows-core 0.60.1", - "windows-link", -] - [[package]] name = "windows-numerics" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.0", + "windows-core", "windows-link", ] @@ -1554,15 +1488,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-strings" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-strings" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 022097d9..7db3c744 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ rayon = "1.10.0" rustc-hash = "2.1.1" [target.'cfg(windows)'.dependencies] -windows = { version = "0.60.0", features = ["Win32_Storage_FileSystem"] } +windows = { version = "0.61.1", features = ["Win32_Storage_FileSystem"] } [dev-dependencies] rstest = "0.25.0" From e7a5bfd5ab800c6997cff328ef83f9ab45e0eb6b Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 26 Mar 2025 21:22:20 +0000 Subject: [PATCH 058/206] Update loot-condition-interpreter to v5.3.1 --- Cargo.lock | 5 +++-- Cargo.toml | 2 +- README.md | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 037cc46d..e8c8e09d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -666,8 +666,9 @@ checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" [[package]] name = "loot-condition-interpreter" -version = "5.2.0" -source = "git+https://github.com/loot/loot-condition-interpreter.git?rev=f7d9947fa434037743ce81e2d409184ec0bfb693#f7d9947fa434037743ce81e2d409184ec0bfb693" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94d58882c85aba2f4fca0951301b58e03428a8a02e51ccb0cf5f5b28d7869281" dependencies = [ "crc32fast", "esplugin", diff --git a/Cargo.toml b/Cargo.toml index 7db3c744..e0c421ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ members = ["cxx", "ffi-errors", "pyo3"] [workspace.dependencies] esplugin = "6.1.1" libloadorder = "18.3.0" -loot-condition-interpreter = { git = "https://github.com/loot/loot-condition-interpreter.git", rev = "f7d9947fa434037743ce81e2d409184ec0bfb693" } +loot-condition-interpreter = "5.3.1" [profile.release] debug = "limited" diff --git a/README.md b/README.md index 47cb100a..6d3ac4b0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ If this experiment is successful it will probably end up being merged into the l ## Outstanding issues -- It relies on some unreleased improvements to the Rust APIs of libloadorder and loot-condition-interpreter. - It uses more memory than the C++ implementation, and I haven't finished investigating why or if there's anything I can/should do about that. - I'm not very confident about the YAML emitter I've written, it needs more test cases and probably another review against the spec. - The `saphyr` and `saphyr-parser` dependencies that are used to parse metadata YAML have some rough edges, and compared to the other dependencies they are relatively new, unpopular libraries that are maintained by people I've not heard of before, so they may be relatively risky dependencies. From b17691dbdd6ebb0d4e357aadd5fb73937cccefde Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 27 Mar 2025 17:40:19 +0000 Subject: [PATCH 059/206] Some minor cleanup of metadata code --- src/metadata/file.rs | 4 ++-- src/metadata/message.rs | 10 ++-------- src/metadata/metadata_document.rs | 7 +++---- src/metadata/plugin_metadata.rs | 24 ++++++++++++++++-------- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/metadata/file.rs b/src/metadata/file.rs index 96fdc121..593f98ba 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -145,7 +145,7 @@ impl TryFrom<&MarkedYaml> for File { fn try_from(value: &MarkedYaml) -> Result { match &value.data { YamlData::String(s) => Ok(File { - name: Filename(UniCase::new(s.to_string())), + name: Filename::new(s.clone()), display_name: None, detail: Vec::new(), condition: None, @@ -168,7 +168,7 @@ impl TryFrom<&MarkedYaml> for File { let condition = parse_condition(h, YamlObjectType::File)?; Ok(File { - name: Filename(UniCase::new(name.to_string())), + name: Filename::new(name.to_string()), display_name: display_name.map(|(_, s)| s.to_string()), detail, condition, diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 1b463495..55d424d5 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -173,10 +173,7 @@ impl Message { pub fn new(message_type: MessageType, content: String) -> Self { Self { message_type, - content: vec![MessageContent { - text: content, - language: MessageContent::DEFAULT_LANGUAGE.to_string(), - }], + content: vec![MessageContent::new(content)], condition: None, } } @@ -268,10 +265,7 @@ pub(crate) fn parse_message_contents_yaml( ) -> Result, ParseMetadataError> { let contents = match &value.data { YamlData::String(s) => { - vec![MessageContent { - text: s.to_string(), - language: MessageContent::DEFAULT_LANGUAGE.to_string(), - }] + vec![MessageContent::new(s.clone())] } YamlData::Array(a) => a .iter() diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index 861cb653..8619e0d5 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -124,15 +124,14 @@ impl MetadataDocument { regex_plugins.push(plugin); } else { let filename = Filename::new(plugin.name().to_string()); - if plugins.contains_key(&filename) { + if let Some(old) = plugins.insert(filename, plugin) { return Err(ParseMetadataError::duplicate_entry( plugin_yaml.span.start, - plugin.name().to_string(), + old.name().to_string(), YamlObjectType::PluginMetadata, ) .into()); - } - plugins.insert(filename, plugin); + }; } } diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 5fc3c9f4..880f4b04 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -190,7 +190,7 @@ impl PluginMetadata { /// Returns `true` if the plugin name contains any of the characters `:\*?|` /// and `false` otherwise. pub fn is_regex_plugin(&self) -> bool { - self.name.regex.is_some() + self.name.is_regex() } /// Check if the given plugin name matches this plugin metadata object's @@ -201,13 +201,7 @@ impl PluginMetadata { /// case-insensitively. The given plugin name must be literal, i.e. not a /// regular expression. pub fn name_matches(&self, other_name: &str) -> bool { - if let Some(regex) = &self.name.regex { - regex.is_match(other_name).inspect_err(|e| { - logging::error!("Encountered an error while trying to match the regex {} to the string {}: {}", regex.as_str(), other_name, e); - }).unwrap_or(false) - } else { - unicase::eq(self.name.string.as_str(), other_name) - } + self.name.matches(other_name) } /// Serialises the plugin metadata as YAML. @@ -257,6 +251,20 @@ impl PluginName { }) } } + + fn matches(&self, other_name: &str) -> bool { + if let Some(regex) = &self.regex { + regex.is_match(other_name).inspect_err(|e| { + logging::error!("Encountered an error while trying to match the regex {} to the string {}: {}", regex.as_str(), other_name, e); + }).unwrap_or(false) + } else { + unicase::eq(self.string.as_str(), other_name) + } + } + + fn is_regex(&self) -> bool { + self.regex.is_some() + } } impl std::cmp::PartialEq for PluginName { From b996406e8f2eacae07e8e8aaeec3fe8a8605196f Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 27 Mar 2025 17:53:47 +0000 Subject: [PATCH 060/206] Shrink metadata vectors some metadata vectors during YAML parsing For the Skyrim SE masterlist this saved ~ 3 MB of memory, and there was no noticeable impact doing the same for other metadata collection fields. --- src/metadata/message.rs | 4 +++- src/metadata/plugin_metadata.rs | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 55d424d5..ea176358 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -263,7 +263,7 @@ pub(crate) fn parse_message_contents_yaml( key: &'static str, parent_yaml_type: YamlObjectType, ) -> Result, ParseMetadataError> { - let contents = match &value.data { + let mut contents = match &value.data { YamlData::String(s) => { vec![MessageContent::new(s.clone())] } @@ -281,6 +281,8 @@ pub(crate) fn parse_message_contents_yaml( } }; + contents.shrink_to_fit(); + if validate_message_contents(&contents).is_err() { Err(ParseMetadataError::new( value.span.start, diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 880f4b04..38316265 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -377,10 +377,14 @@ fn get_vec<'a, T: TryFrom<&'a MarkedYaml, Error = impl Into> hash: &'a saphyr::AnnotatedHash, key: &'static str, ) -> Result, ParseMetadataError> { - get_as_slice(hash, key, YamlObjectType::PluginMetadata)? + let mut vec = get_as_slice(hash, key, YamlObjectType::PluginMetadata)? .iter() .map(|e| T::try_from(e).map_err(Into::into)) - .collect::, _>>() + .collect::, _>>()?; + + vec.shrink_to_fit(); + + Ok(vec) } impl EmitYaml for PluginMetadata { From b4f3f8e8393d373f80ef64e89d0da98c12fa57ab Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Fri, 28 Mar 2025 17:59:28 +0000 Subject: [PATCH 061/206] Reduce repetition of regex error type --- src/metadata/error.rs | 9 +++++---- src/metadata/plugin_metadata.rs | 4 ++-- src/plugin/error.rs | 8 +++++--- src/plugin/mod.rs | 4 ++-- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/metadata/error.rs b/src/metadata/error.rs index e3ae9d4a..d74cbfe8 100644 --- a/src/metadata/error.rs +++ b/src/metadata/error.rs @@ -1,6 +1,7 @@ //! Holds all error types related to LOOT metadata. use std::path::PathBuf; +use fancy_regex::Error as RegexImplError; use saphyr::Marker; use crate::metadata::MessageContent; @@ -124,7 +125,7 @@ impl From for ParseMetadataError { pub(super) enum MetadataParsingErrorReason { InvalidCondition(Box<(String, loot_condition_interpreter::Error)>), MissingKey(&'static str, YamlObjectType), - InvalidRegex(Box), + InvalidRegex(Box), InvalidMultilingualMessageContents, UnexpectedType(ExpectedType, YamlObjectType), UnexpectedValueType(&'static str, ExpectedType, YamlObjectType), @@ -209,7 +210,7 @@ impl std::fmt::Display for ExpectedType { /// Represents an error encountered while parsing and compiling a regex plugin /// name. #[derive(Clone, Debug)] -pub struct RegexError(Box); +pub struct RegexError(Box); impl std::fmt::Display for RegexError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -223,8 +224,8 @@ impl std::error::Error for RegexError { } } -impl From> for RegexError { - fn from(value: Box) -> Self { +impl From> for RegexError { + fn from(value: Box) -> Self { Self(value) } } diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 38316265..c8659f06 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -1,6 +1,6 @@ use std::sync::LazyLock; -use fancy_regex::{Captures, Regex}; +use fancy_regex::{Captures, Error as RegexImplError, Regex}; use saphyr::MarkedYaml; use crate::logging; @@ -219,7 +219,7 @@ struct PluginName { } impl PluginName { - fn new(name: &str) -> Result> { + fn new(name: &str) -> Result> { let name = trim_dot_ghost(name).to_string(); if is_regex_name(&name) { diff --git a/src/plugin/error.rs b/src/plugin/error.rs index 78cee23c..7bef946a 100644 --- a/src/plugin/error.rs +++ b/src/plugin/error.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +use fancy_regex::Error as RegexImplError; + /// Represents an error that occurred while reading a parsed plugin's data. #[derive(Debug)] pub struct PluginDataError(esplugin::Error); @@ -28,7 +30,7 @@ pub(crate) enum LoadPluginError { InvalidFilename(InvalidFilenameReason), IoError(std::io::Error), ParsingError(esplugin::Error), - RegexError(Box), + RegexError(Box), } impl std::fmt::Display for LoadPluginError { @@ -65,8 +67,8 @@ impl From for LoadPluginError { } } -impl From> for LoadPluginError { - fn from(value: Box) -> Self { +impl From> for LoadPluginError { + fn from(value: Box) -> Self { LoadPluginError::RegexError(value) } } diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index cb476832..c2183771 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -10,7 +10,7 @@ use std::{ }; use esplugin::ParseOptions; -use fancy_regex::Regex; +use fancy_regex::{Error as RegexImplError, Regex}; use crate::{ GameType, @@ -410,7 +410,7 @@ fn extract_bash_tags(description: &str) -> Vec { Vec::new() } -fn extract_version(description: &str) -> Result, Box> { +fn extract_version(description: &str) -> Result, Box> { static VERSION_REGEXES: LazyLock> = LazyLock::new(|| { // The string below matches the range of version strings supported by // Pseudosem v1.0.1, excluding space separators, as they make version From ee8365eba84b74d31be1ce0d6b7dd54195954abf Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Fri, 28 Mar 2025 18:42:39 +0000 Subject: [PATCH 062/206] Undo case-insensitive regex workaround It turns out that the original approach does work after all, I'm not sure why I thought it didn't. --- src/lib.rs | 9 +++++++++ src/metadata/plugin_metadata.rs | 4 ++-- src/plugin/mod.rs | 9 +++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index d93af25f..1b880ca6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,8 @@ mod sorting; mod tests; mod version; +use fancy_regex::{Error as RegexImplError, Regex, RegexBuilder}; + pub use database::{Database, WriteMode}; pub use game::{Game, GameType}; pub use logging::{LogLevel, set_log_level, set_logging_callback}; @@ -19,3 +21,10 @@ pub use version::{ LIBLOOT_VERSION_MAJOR, LIBLOOT_VERSION_MINOR, LIBLOOT_VERSION_PATCH, is_compatible, libloot_revision, libloot_version, }; + +fn case_insensitive_regex(value: &str) -> Result> { + RegexBuilder::new(value) + .case_insensitive(true) + .build() + .map_err(Into::into) +} diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index c8659f06..103cf6f5 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -3,7 +3,7 @@ use std::sync::LazyLock; use fancy_regex::{Captures, Error as RegexImplError, Regex}; use saphyr::MarkedYaml; -use crate::logging; +use crate::{case_insensitive_regex, logging}; use super::{ error::{MetadataParsingErrorReason, ParseMetadataError, RegexError}, @@ -239,7 +239,7 @@ impl PluginName { format!("{}(?:{}", &captures[1], &captures[2]) }); - let regex = Regex::new(&format!("(?i)^{}$", ®ex_name))?; + let regex = case_insensitive_regex(&format!("^{}$", ®ex_name))?; Ok(Self { string: name, regex: Some(regex), diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index c2183771..d829450b 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -15,6 +15,7 @@ use fancy_regex::{Error as RegexImplError, Regex}; use crate::{ GameType, archive::{assets_in_archives, do_assets_overlap, find_associated_archives}, + case_insensitive_regex, game::GameCache, logging, metadata::plugin_metadata::trim_dot_ghost, @@ -426,16 +427,16 @@ fn extract_version(description: &str) -> Result, Box Date: Fri, 28 Mar 2025 19:24:02 +0000 Subject: [PATCH 063/206] Fix capturing group replacement It didn't correctly handle "\\(" or "((" sequences. --- src/metadata/plugin_metadata.rs | 107 +++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 17 deletions(-) diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 103cf6f5..bd126f0e 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -1,6 +1,6 @@ -use std::sync::LazyLock; +use std::borrow::Cow; -use fancy_regex::{Captures, Error as RegexImplError, Regex}; +use fancy_regex::{Error as RegexImplError, Regex}; use saphyr::MarkedYaml; use crate::{case_insensitive_regex, logging}; @@ -223,23 +223,10 @@ impl PluginName { let name = trim_dot_ghost(name).to_string(); if is_regex_name(&name) { - // Many regexes are written with capturing groups as that's the - // simplest way to write them, but in plugin metadata they could all - // be non-capturing groups. - // Over the Skyrim SE masterlist, using non-capturing groups saves - // about 15 MB of memory at runtime, which is ~ 25% of the memory - // used by the regex matching caches. - static CAPTURING_GROUP_START_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(r"(^|[^\\])\(([^?])") - .expect("capturing group start regex should be valid") - }); + let non_capturing_name = replace_capturing_groups(&name); - let regex_name = CAPTURING_GROUP_START_REGEX - .replace_all(&name, |captures: &Captures| { - format!("{}(?:{}", &captures[1], &captures[2]) - }); + let regex = case_insensitive_regex(&format!("^{}$", &non_capturing_name))?; - let regex = case_insensitive_regex(&format!("^{}$", ®ex_name))?; Ok(Self { string: name, regex: Some(regex), @@ -325,6 +312,43 @@ fn merge_vecs(target: &mut Vec, source: &[T]) { } } +fn replace_capturing_groups(regex_string: &str) -> Cow<'_, str> { + let mut output = String::new(); + let mut prefix_length = 0; + let mut remainder = regex_string; + while let Some(pos) = remainder.find('(') { + let (before, after) = match remainder.split_at_checked(pos + 1) { + Some(t) => t, + None => break, + }; + + if after.starts_with('?') || (before.ends_with("\\(") && !before.ends_with("\\\\(")) { + if output.is_empty() { + // No need to copy the string yet. + prefix_length += before.len(); + } else { + output.push_str(before); + } + } else { + if output.is_empty() { + output.push_str(®ex_string[..prefix_length]); + } + + output.push_str(before); + output.push_str("?:"); + } + + remainder = after; + } + + if output.is_empty() { + Cow::Borrowed(regex_string) + } else { + output.push_str(remainder); + Cow::Owned(output) + } +} + impl TryFrom<&MarkedYaml> for PluginMetadata { type Error = ParseMetadataError; @@ -1162,4 +1186,53 @@ mod tests { ); } } + + mod replace_capturing_groups { + use super::*; + + #[test] + fn should_replace_capturing_groups_with_non_capturing_groups() { + let input = r"(a)?\((b)\)((?x)|\\(d))(?:e)\("; + + let output = replace_capturing_groups(input); + + assert_eq!(r"(?:a)?\((?:b)\)(?:(?x)|\\(?:d))(?:e)\(", output); + } + + #[test] + fn should_not_clone_string_if_no_opening_parentheses_are_found() { + let input = "no parentheses"; + + match replace_capturing_groups(input) { + Cow::Borrowed(output) => assert_eq!(input, output), + Cow::Owned(output) => panic!("Expected borrowed output, got {}", output), + } + } + + #[test] + fn should_not_clone_string_if_no_capturing_groups_are_found() { + let input = "no paren(?:thes)es"; + + match replace_capturing_groups(input) { + Cow::Borrowed(output) => assert_eq!(input, output), + Cow::Owned(output) => panic!("Expected borrowed output, got {}", output), + } + + let input = "no paren(?:th(?:e)s)es"; + + match replace_capturing_groups(input) { + Cow::Borrowed(output) => assert_eq!(input, output), + Cow::Owned(output) => panic!("Expected borrowed output, got {}", output), + } + } + + #[test] + fn should_clone_non_capturing_prefix_correctly() { + let input = r"(?:a)?\(?:(b)\)((?x)|\\(d))(?:e)\("; + + let output = replace_capturing_groups(input); + + assert_eq!(r"(?:a)?\(?:(?:b)\)(?:(?x)|\\(?:d))(?:e)\(", output); + } + } } From 74a834e039aa9ed8c51311a770f83b4d3e8d8d38 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 29 Mar 2025 17:30:14 +0000 Subject: [PATCH 064/206] Improve emitting strings in YAML Consistently avoid literal tabs and line breaks because they complicate the output, and also avoid literal BOMs consistently. --- README.md | 1 - src/metadata/yaml/emit.rs | 236 +++++++++++++++++++++++++++++++------- 2 files changed, 192 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 6d3ac4b0..4a572279 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ If this experiment is successful it will probably end up being merged into the l ## Outstanding issues - It uses more memory than the C++ implementation, and I haven't finished investigating why or if there's anything I can/should do about that. -- I'm not very confident about the YAML emitter I've written, it needs more test cases and probably another review against the spec. - The `saphyr` and `saphyr-parser` dependencies that are used to parse metadata YAML have some rough edges, and compared to the other dependencies they are relatively new, unpopular libraries that are maintained by people I've not heard of before, so they may be relatively risky dependencies. ## Build diff --git a/src/metadata/yaml/emit.rs b/src/metadata/yaml/emit.rs index 8fc1053d..33eb6266 100644 --- a/src/metadata/yaml/emit.rs +++ b/src/metadata/yaml/emit.rs @@ -21,7 +21,12 @@ enum YamlBlock { #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] enum YamlStyle { + /// YAML flow style Flow, + /// YAML block style + /// + /// This is only respected for sequences. Mappings and scalars are always + /// emitted in flow style. Block, } @@ -89,7 +94,9 @@ impl YamlEmitter { } } - /// This assumes that the given key is valid to be written as an unquoted string, and expects a string literal so that it's obvious that a given value is valid. + /// This assumes that the given key is valid to be written as an unquoted + /// string, and expects a string literal so that it's obvious that a given + /// value is valid. pub fn map_key(&mut self, key: &'static str) { match self.scope.last() { Some(&YamlBlock::Map) => { @@ -136,7 +143,8 @@ impl YamlEmitter { } fn write_indent(&mut self) { - // If in a map, no indent is needed, but an array needs an indent, and a map in an array needs an indent. + // If in a map, no indent is needed, but an array needs an indent, and a + // map in an array needs an indent. if !self.scope.is_empty() { for _ in 0..self.scope.len() - 1 { self.write(Self::INDENT_UNIT); @@ -161,33 +169,29 @@ impl YamlEmitter { } } -fn can_single_quote(value: &str) -> bool { - // Single-quoted strings are restricted to printable characters - // - value.chars().all(is_printable) -} - -fn is_printable(c: char) -> bool { - // - matches!(c, - '\x09' | '\x0A' | '\x0D' | '\x20'..='\x7E' | '\u{0085}' | '\u{00A0}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{010000}'..='\u{10FFFF}' - ) -} - fn is_yaml_whitespace(c: char) -> bool { + // c == ' ' || c == '\t' } -fn is_yaml_line_break(c: char) -> bool { - c == '\x0A' || c == '\x0D' +fn is_flow_indicator(c: char) -> bool { + // + matches!(c, '[' | ']' | '{' | '}' | ',') } -fn is_flow_indicator(c: char) -> bool { - matches!(c, '[' | ']' | '{' | '}' | ',') +fn should_escape(c: char) -> bool { + // This isn't defined by the YAML spec, but is based on guidance in + // , plus a extra few + // characters (\t, \r, \n, \x7F, \x85 and \uFEFF). + // Surrogates are not represented because Rust's char type is defined to not + // hold them. + matches!(c, '\x00'..='\x1F' | '\x7F' | '\u{0080}'..='\u{009F}' | '\u{FEFF}' | '\u{FFFE}' | '\u{FFFF}') } /// This disallows multi-line unquoted strings, which YAML does allow in some /// contexts, but there's no expectation of such strings coming out of libloot. +/// It also disallows strings containing tabs (\t), DEL (\x7F) and NEL (\x85), +/// which YAML does allow. fn can_emit_unquoted(value: &str, style: YamlStyle) -> bool { // if value.is_empty() @@ -240,9 +244,18 @@ fn can_emit_unquoted(value: &str, style: YamlStyle) -> bool { return false; } - value - .chars() - .all(|c| is_printable(c) && !is_yaml_line_break(c) && c != '\u{FEFF}') + !value.chars().any(should_escape) +} + +/// This disallows line breaks, which are allowed by YAML, but they can't be +/// escaped in single-quoted strings and the rules about emitting multi-line +/// YAML strings are relatively complicated so just avoid having to deal with +/// them. A couple of other characters are disallowed that YAML allows (e.g. +/// tab and the BOM). +fn can_single_quote(value: &str) -> bool { + // Single-quoted strings are restricted to printable characters + // + !value.chars().any(should_escape) } fn single_quote(value: &str) -> String { @@ -256,9 +269,7 @@ fn double_quote(value: &str) -> String { let escaped: String = value .chars() .map(|c| { - if is_printable(c) { - c.to_string() - } else { + if should_escape(c) { match c { '\x00' => "\\0".to_string(), '\x07' => "\\a".to_string(), @@ -277,10 +288,12 @@ fn double_quote(value: &str) -> String { '\u{00A0}' => "\\_".to_string(), '\u{2028}' => "\\L".to_string(), '\u{2029}' => "\\P".to_string(), - '\u{00}'..='\u{FF}' => format!("\\x{:2X}", u32::from(c)), - '\u{0100}'..='\u{FFFF}' => format!("\\u{:4X}", u32::from(c)), - c => format!("\\U{:8X}", u32::from(c)), + '\u{00}'..='\u{FF}' => format!("\\x{:02X}", u32::from(c)), + '\u{0100}'..='\u{FFFF}' => format!("\\u{:04X}", u32::from(c)), + c => format!("\\U{:08X}", u32::from(c)), } + } else { + c.to_string() } }) .collect(); @@ -328,33 +341,168 @@ mod tests { mod unquoted_str { use super::*; + fn emit(str: &str) -> String { + let mut emitter = YamlEmitter::new(); + emitter.unquoted_str(str); + emitter.into_string() + } + #[test] - fn unquoted_str_should_emit_string_as_given() { + fn should_emit_string_as_given() { let value = "hello world"; - let mut emitter = YamlEmitter::new(); - emitter.unquoted_str(value); - assert_eq!(value, emitter.into_string()); + assert_eq!(value, emit(value)); } #[test] - fn unquoted_str_should_fall_back_to_single_quoting_string_if_it_cannot_be_emitted_unquoted() - { - let value = " hello world"; - let mut emitter = YamlEmitter::new(); - emitter.unquoted_str(value); - - assert_eq!(format!("'{}'", value), emitter.into_string()); + fn should_fall_back_to_quoting_string_if_it_cannot_be_emitted_unquoted() { + assert_eq!("''", emit("")); + assert_eq!("' a'", emit(" a")); + assert_eq!("'a '", emit("a ")); + assert_eq!("',a'", emit(",a")); + assert_eq!("'[a'", emit("[a")); + assert_eq!("']a'", emit("]a")); + assert_eq!("'{a'", emit("{a")); + assert_eq!("'}a'", emit("}a")); + assert_eq!("'#a'", emit("#a")); + assert_eq!("'&a'", emit("&a")); + assert_eq!("'*a'", emit("*a")); + assert_eq!("'!a'", emit("!a")); + assert_eq!("'|a'", emit("|a")); + assert_eq!("'>a'", emit(">a")); + assert_eq!("'''a'", emit("'a")); + assert_eq!("'\"a'", emit("\"a")); + assert_eq!("'%a'", emit("%a")); + assert_eq!("'@a'", emit("@a")); + assert_eq!("'`a'", emit("`a")); + assert_eq!("'? a'", emit("? a")); + assert_eq!("\"?\\ta\"", emit("?\ta")); + assert_eq!("'- a'", emit("- a")); + assert_eq!("\"-\\ta\"", emit("-\ta")); + assert_eq!("'a: b'", emit("a: b")); + assert_eq!("\"a:\\tb\"", emit("a:\tb")); + assert_eq!("'a #b'", emit("a #b")); + assert_eq!("\"a\\t#b\"", emit("a\t#b")); } #[test] - fn unquoted_str_should_fall_back_to_double_quoting_string_if_it_cannot_be_unquoted_or_single_quoted() + fn should_fall_back_to_single_quoting_string_that_contains_a_flow_indicator_when_style_is_flow() { - let value = "\x1B[1mhello world\x1B[0m"; - let mut emitter = YamlEmitter::new(); - emitter.unquoted_str(value); + fn emit_flow(str: &str) -> String { + let mut emitter = YamlEmitter::new(); + emitter.set_flow_style(); + emitter.unquoted_str(str); + emitter.into_string() + } - assert_eq!("\"\\e[1mhello world\\e[0m\"", emitter.into_string()); + assert_eq!("a[b", emit("a[b")); + assert_eq!("a]b", emit("a]b")); + assert_eq!("a{b", emit("a{b")); + assert_eq!("a}b", emit("a}b")); + assert_eq!("a,b", emit("a,b")); + + assert_eq!("'a[b'", emit_flow("a[b")); + assert_eq!("'a]b'", emit_flow("a]b")); + assert_eq!("'a{b'", emit_flow("a{b")); + assert_eq!("'a}b'", emit_flow("a}b")); + assert_eq!("'a,b'", emit_flow("a,b")); + } + + #[test] + fn should_fall_back_to_double_quoting_string_if_it_cannot_be_unquoted_or_single_quoted() + { + assert_eq!( + "\"\\e[1mhello world\\e[0m\"", + emit("\x1B[1mhello world\x1B[0m") + ); + } + + #[test] + fn should_fall_back_to_double_quoting_if_string_contains_a_c0_control_character() { + assert_eq!("\"\\0\"", emit("\x00")); + assert_eq!("\"\\x01\"", emit("\x01")); + assert_eq!("\"\\x02\"", emit("\x02")); + assert_eq!("\"\\x03\"", emit("\x03")); + assert_eq!("\"\\x04\"", emit("\x04")); + assert_eq!("\"\\x05\"", emit("\x05")); + assert_eq!("\"\\x06\"", emit("\x06")); + assert_eq!("\"\\a\"", emit("\x07")); + assert_eq!("\"\\b\"", emit("\x08")); + assert_eq!("\"\\t\"", emit("\x09")); + assert_eq!("\"\\n\"", emit("\x0A")); + assert_eq!("\"\\v\"", emit("\x0B")); + assert_eq!("\"\\f\"", emit("\x0C")); + assert_eq!("\"\\r\"", emit("\x0D")); + assert_eq!("\"\\x0E\"", emit("\x0E")); + assert_eq!("\"\\x0F\"", emit("\x0F")); + assert_eq!("\"\\x10\"", emit("\x10")); + assert_eq!("\"\\x11\"", emit("\x11")); + assert_eq!("\"\\x12\"", emit("\x12")); + assert_eq!("\"\\x13\"", emit("\x13")); + assert_eq!("\"\\x14\"", emit("\x14")); + assert_eq!("\"\\x15\"", emit("\x15")); + assert_eq!("\"\\x16\"", emit("\x16")); + assert_eq!("\"\\x17\"", emit("\x17")); + assert_eq!("\"\\x18\"", emit("\x18")); + assert_eq!("\"\\x19\"", emit("\x19")); + assert_eq!("\"\\x1A\"", emit("\x1A")); + assert_eq!("\"\\e\"", emit("\x1B")); + assert_eq!("\"\\x1C\"", emit("\x1C")); + assert_eq!("\"\\x1D\"", emit("\x1D")); + assert_eq!("\"\\x1E\"", emit("\x1E")); + assert_eq!("\"\\x1F\"", emit("\x1F")); + } + + #[test] + fn should_fall_back_to_double_quoting_if_string_contains_a_del_character() { + assert_eq!("\"\\x7F\"", emit("\x7F")); + } + + #[test] + fn should_fall_back_to_double_quoting_if_string_contains_a_c1_control_character() { + assert_eq!("\"\\x80\"", emit("\u{0080}")); + assert_eq!("\"\\x81\"", emit("\u{0081}")); + assert_eq!("\"\\x82\"", emit("\u{0082}")); + assert_eq!("\"\\x83\"", emit("\u{0083}")); + assert_eq!("\"\\x84\"", emit("\u{0084}")); + assert_eq!("\"\\N\"", emit("\u{0085}")); + assert_eq!("\"\\x86\"", emit("\u{0086}")); + assert_eq!("\"\\x87\"", emit("\u{0087}")); + assert_eq!("\"\\x88\"", emit("\u{0088}")); + assert_eq!("\"\\x89\"", emit("\u{0089}")); + assert_eq!("\"\\x8A\"", emit("\u{008A}")); + assert_eq!("\"\\x8B\"", emit("\u{008B}")); + assert_eq!("\"\\x8C\"", emit("\u{008C}")); + assert_eq!("\"\\x8D\"", emit("\u{008D}")); + assert_eq!("\"\\x8E\"", emit("\u{008E}")); + assert_eq!("\"\\x8F\"", emit("\u{008F}")); + assert_eq!("\"\\x90\"", emit("\u{0090}")); + assert_eq!("\"\\x91\"", emit("\u{0091}")); + assert_eq!("\"\\x92\"", emit("\u{0092}")); + assert_eq!("\"\\x93\"", emit("\u{0093}")); + assert_eq!("\"\\x94\"", emit("\u{0094}")); + assert_eq!("\"\\x95\"", emit("\u{0095}")); + assert_eq!("\"\\x96\"", emit("\u{0096}")); + assert_eq!("\"\\x97\"", emit("\u{0097}")); + assert_eq!("\"\\x98\"", emit("\u{0098}")); + assert_eq!("\"\\x99\"", emit("\u{0099}")); + assert_eq!("\"\\x9A\"", emit("\u{009A}")); + assert_eq!("\"\\x9B\"", emit("\u{009B}")); + assert_eq!("\"\\x9C\"", emit("\u{009C}")); + assert_eq!("\"\\x9D\"", emit("\u{009D}")); + assert_eq!("\"\\x9E\"", emit("\u{009E}")); + assert_eq!("\"\\x9F\"", emit("\u{009F}")); + } + + #[test] + fn should_fall_back_to_double_quoting_if_string_contains_a_bom() { + assert_eq!("\"\\uFEFF\"", emit("\u{FEFF}")); + } + + #[test] + fn should_fall_back_to_double_quoting_if_string_contains_unicode_fffe_or_ffff() { + assert_eq!("\"\\uFFFE\"", emit("\u{FFFE}")); + assert_eq!("\"\\uFFFF\"", emit("\u{FFFF}")); } } From 422ed8cce327d3cab99354d4e0f2dc51aeb83674 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 30 Mar 2025 08:38:43 +0100 Subject: [PATCH 065/206] Hide MarkedYaml from the public API This gives the freedom to changing the YAML parsing implementation without changing the public API. --- src/metadata/file.rs | 29 ++++++------- src/metadata/group.rs | 28 ++++++------- src/metadata/location.rs | 30 ++++++------- src/metadata/message.rs | 63 +++++++++++++--------------- src/metadata/metadata_document.rs | 8 ++-- src/metadata/plugin_cleaning_data.rs | 42 +++++++++---------- src/metadata/plugin_metadata.rs | 28 ++++++------- src/metadata/tag.rs | 34 +++++++-------- src/metadata/yaml/mod.rs | 5 ++- src/metadata/yaml/parse.rs | 6 +++ 10 files changed, 133 insertions(+), 140 deletions(-) diff --git a/src/metadata/file.rs b/src/metadata/file.rs index 593f98ba..25fa7c93 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -7,10 +7,9 @@ use super::{ MessageContent, emit_message_contents, parse_message_contents_yaml, validate_message_contents, }, - yaml::{EmitYaml, YamlEmitter}, yaml::{ - YamlObjectType, as_string_node, get_required_string_value, get_string_value, - parse_condition, + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, as_string_node, + get_required_string_value, get_string_value, parse_condition, }, }; @@ -139,10 +138,8 @@ impl std::fmt::Display for Filename { } } -impl TryFrom<&MarkedYaml> for File { - type Error = ParseMetadataError; - - fn try_from(value: &MarkedYaml) -> Result { +impl TryFromYaml for File { + fn try_from_yaml(value: &MarkedYaml) -> Result { match &value.data { YamlData::String(s) => Ok(File { name: Filename::new(s.clone()), @@ -249,7 +246,7 @@ mod tests { fn should_only_set_name_if_decoding_from_scalar() { let yaml = parse("name1"); - let file = File::try_from(&yaml).unwrap(); + let file = File::try_from_yaml(&yaml).unwrap(); assert_eq!("name1", file.name().as_str()); assert!(file.display_name().is_none()); @@ -261,21 +258,21 @@ mod tests { fn should_error_if_given_a_list() { let yaml = parse("[0, 1, 2]"); - assert!(File::try_from(&yaml).is_err()); + assert!(File::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_name_is_missing() { let yaml = parse("{display: display1}"); - assert!(File::try_from(&yaml).is_err()); + assert!(File::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_given_an_invalid_condition() { let yaml = parse("{name: name1, condition: invalid}"); - assert!(File::try_from(&yaml).is_err()); + assert!(File::try_from_yaml(&yaml).is_err()); } #[test] @@ -284,7 +281,7 @@ mod tests { "{name: name1, display: display1, condition: 'file(\"Foo.esp\")', detail: 'details'}", ); - let file = File::try_from(&yaml).unwrap(); + let file = File::try_from_yaml(&yaml).unwrap(); assert_eq!("name1", file.name().as_str()); assert_eq!("display1", file.display_name().unwrap()); @@ -296,7 +293,7 @@ mod tests { fn should_leave_optional_fields_empty_if_not_present() { let yaml = parse("{name: name1}"); - let file = File::try_from(&yaml).unwrap(); + let file = File::try_from_yaml(&yaml).unwrap(); assert_eq!("name1", file.name().as_str()); assert!(file.display_name().is_none()); @@ -310,7 +307,7 @@ mod tests { "{name: name1, detail: [{text: english, lang: en}, {text: french, lang: fr}]}", ); - let file = File::try_from(&yaml).unwrap(); + let file = File::try_from_yaml(&yaml).unwrap(); assert_eq!( &[ @@ -325,7 +322,7 @@ mod tests { fn should_not_error_if_one_detail_is_given_and_it_is_not_english() { let yaml = parse("name: name1\ndetail:\n - lang: fr\n text: content1"); - let file = File::try_from(&yaml).unwrap(); + let file = File::try_from_yaml(&yaml).unwrap(); assert_eq!( &[MessageContent::new("content1".into()).with_language("fr".into())], @@ -339,7 +336,7 @@ mod tests { "name: name1\ndetail:\n - lang: de\n text: content1\n - lang: fr\n text: content2", ); - assert!(File::try_from(&yaml).is_err()); + assert!(File::try_from_yaml(&yaml).is_err()); } } diff --git a/src/metadata/group.rs b/src/metadata/group.rs index da5177cd..00c20a0c 100644 --- a/src/metadata/group.rs +++ b/src/metadata/group.rs @@ -1,9 +1,11 @@ use saphyr::MarkedYaml; -use super::error::ParseMetadataError; -use super::yaml::{EmitYaml, YamlEmitter}; -use super::yaml::{ - YamlObjectType, get_as_hash, get_required_string_value, get_string_value, get_strings_vec_value, +use super::{ + error::ParseMetadataError, + yaml::{ + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, get_as_hash, get_required_string_value, + get_string_value, get_strings_vec_value, + }, }; /// Represents a group to which plugin metadata objects can belong. @@ -82,10 +84,8 @@ impl std::default::Default for Group { } } -impl TryFrom<&MarkedYaml> for Group { - type Error = ParseMetadataError; - - fn try_from(value: &MarkedYaml) -> Result { +impl TryFromYaml for Group { + fn try_from_yaml(value: &MarkedYaml) -> Result { let hash = get_as_hash(value, YamlObjectType::Group)?; let name = @@ -143,32 +143,32 @@ mod tests { fn should_error_if_given_a_list() { let yaml = parse("[0, 1, 2]"); - assert!(Group::try_from(&yaml).is_err()); + assert!(Group::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_name_is_missing() { let yaml = parse("{description: text}"); - assert!(Group::try_from(&yaml).is_err()); + assert!(Group::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_after_is_not_an_array_of_strings() { let yaml = parse("{name: group1, after: other_group}"); - assert!(Group::try_from(&yaml).is_err()); + assert!(Group::try_from_yaml(&yaml).is_err()); let yaml = parse("{name: group1, after: [0, 1]}"); - assert!(Group::try_from(&yaml).is_err()); + assert!(Group::try_from_yaml(&yaml).is_err()); } #[test] fn should_set_all_given_fields() { let yaml = parse("{name: group1, description: text, after: [ other_group ]}"); - let group = Group::try_from(&yaml).unwrap(); + let group = Group::try_from_yaml(&yaml).unwrap(); assert_eq!("group1", group.name()); assert_eq!("text", group.description().unwrap()); @@ -179,7 +179,7 @@ mod tests { fn should_leave_optional_fields_empty_if_not_present() { let yaml = parse("{name: group1}"); - let group = Group::try_from(&yaml).unwrap(); + let group = Group::try_from_yaml(&yaml).unwrap(); assert_eq!("group1", group.name()); assert!(group.description().is_none()); diff --git a/src/metadata/location.rs b/src/metadata/location.rs index 4e062e78..dcbb88d4 100644 --- a/src/metadata/location.rs +++ b/src/metadata/location.rs @@ -1,11 +1,9 @@ use saphyr::{MarkedYaml, YamlData}; -use super::error::ExpectedType; -use super::error::ParseMetadataError; - -use super::yaml::EmitYaml; -use super::yaml::YamlEmitter; -use super::yaml::{YamlObjectType, get_required_string_value}; +use super::{ + error::{ExpectedType, ParseMetadataError}, + yaml::{EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, get_required_string_value}, +}; /// Represents a URL at which the parent plugin can be found. #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -48,10 +46,8 @@ impl Location { } } -impl TryFrom<&MarkedYaml> for Location { - type Error = ParseMetadataError; - - fn try_from(value: &MarkedYaml) -> Result { +impl TryFromYaml for Location { + fn try_from_yaml(value: &MarkedYaml) -> Result { match &value.data { YamlData::String(s) => Ok(Location { url: s.clone(), @@ -120,7 +116,7 @@ mod tests { fn should_only_set_name_if_decoding_from_scalar() { let yaml = parse("https://www.example.com"); - let location = Location::try_from(&yaml).unwrap(); + let location = Location::try_from_yaml(&yaml).unwrap(); assert_eq!("https://www.example.com", location.url()); assert!(location.name().is_none()); @@ -130,42 +126,42 @@ mod tests { fn should_error_if_given_a_list() { let yaml = parse("[0, 1, 2]"); - assert!(Location::try_from(&yaml).is_err()); + assert!(Location::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_link_is_missing() { let yaml = parse("{name: example}"); - assert!(Location::try_from(&yaml).is_err()); + assert!(Location::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_link_is_not_a_string() { let yaml = parse("{link: [https://www.example.com], name: example}"); - assert!(Location::try_from(&yaml).is_err()); + assert!(Location::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_name_is_not_a_string() { let yaml = parse("{link: https://www.example.com, name: [example]}"); - assert!(Location::try_from(&yaml).is_err()); + assert!(Location::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_name_is_missing() { let yaml = parse("{link: https://www.example.com}"); - assert!(Location::try_from(&yaml).is_err()); + assert!(Location::try_from_yaml(&yaml).is_err()); } #[test] fn should_set_all_fields() { let yaml = parse("{link: https://www.example.com, name: example}"); - let location = Location::try_from(&yaml).unwrap(); + let location = Location::try_from_yaml(&yaml).unwrap(); assert_eq!("https://www.example.com", location.url()); assert_eq!("example", location.name().unwrap()); diff --git a/src/metadata/message.rs b/src/metadata/message.rs index ea176358..ddf65138 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -10,10 +10,9 @@ use super::{ ExpectedType, MetadataParsingErrorReason, MultilingualMessageContentsError, ParseMetadataError, }, - yaml::{EmitYaml, YamlEmitter}, yaml::{ - YamlObjectType, as_string_node, get_as_hash, get_required_string_value, - get_strings_vec_value, parse_condition, + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, as_string_node, get_as_hash, + get_required_string_value, get_strings_vec_value, parse_condition, }, }; @@ -239,10 +238,8 @@ pub(crate) fn validate_message_contents( Ok(()) } -impl TryFrom<&MarkedYaml> for MessageContent { - type Error = ParseMetadataError; - - fn try_from(value: &MarkedYaml) -> Result { +impl TryFromYaml for MessageContent { + fn try_from_yaml(value: &MarkedYaml) -> Result { let hash = get_as_hash(value, YamlObjectType::MessageContent)?; let text = @@ -269,7 +266,7 @@ pub(crate) fn parse_message_contents_yaml( } YamlData::Array(a) => a .iter() - .map(MessageContent::try_from) + .map(MessageContent::try_from_yaml) .collect::, _>>()?, _ => { return Err(ParseMetadataError::unexpected_value_type( @@ -293,10 +290,8 @@ pub(crate) fn parse_message_contents_yaml( } } -impl TryFrom<&MarkedYaml> for Message { - type Error = ParseMetadataError; - - fn try_from(value: &MarkedYaml) -> Result { +impl TryFromYaml for Message { + fn try_from_yaml(value: &MarkedYaml) -> Result { let hash = get_as_hash(value, YamlObjectType::Message)?; let message_type = @@ -554,21 +549,21 @@ mod tests { fn should_error_if_given_a_scalar() { let yaml = parse("content"); - assert!(MessageContent::try_from(&yaml).is_err()); + assert!(MessageContent::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_given_a_list() { let yaml = parse("[0, 1, 2]"); - assert!(MessageContent::try_from(&yaml).is_err()); + assert!(MessageContent::try_from_yaml(&yaml).is_err()); } #[test] fn should_set_all_given_fields() { let yaml = parse("{text: content, lang: fr}"); - let content = MessageContent::try_from(&yaml).unwrap(); + let content = MessageContent::try_from_yaml(&yaml).unwrap(); assert_eq!("content", content.text()); assert_eq!("fr", content.language()); @@ -603,35 +598,35 @@ mod tests { fn should_error_if_given_a_scalar() { let yaml = parse("content"); - assert!(Message::try_from(&yaml).is_err()); + assert!(Message::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_given_a_list() { let yaml = parse("[0, 1, 2]"); - assert!(Message::try_from(&yaml).is_err()); + assert!(Message::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_content_is_missing() { let yaml = parse("{type: say}"); - assert!(Message::try_from(&yaml).is_err()); + assert!(Message::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_given_an_invalid_condition() { let yaml = parse("{type: say, content: text, condition: invalid}"); - assert!(Message::try_from(&yaml).is_err()); + assert!(Message::try_from_yaml(&yaml).is_err()); } #[test] fn should_set_all_given_fields() { let yaml = parse("{type: say, content: text, condition: 'file(\"Foo.esp\")'}"); - let message = Message::try_from(&yaml).unwrap(); + let message = Message::try_from_yaml(&yaml).unwrap(); assert_eq!(MessageType::Say, message.message_type()); assert_eq!(&[MessageContent::new("text".into())], message.content()); @@ -642,7 +637,7 @@ mod tests { fn should_leave_optional_fields_empty_if_not_present() { let yaml = parse("{type: say, content: text}"); - let message = Message::try_from(&yaml).unwrap(); + let message = Message::try_from_yaml(&yaml).unwrap(); assert_eq!(MessageType::Say, message.message_type()); assert_eq!(&[MessageContent::new("text".into())], message.content()); @@ -653,17 +648,17 @@ mod tests { fn should_set_say_warn_and_error_message_types() { let yaml = parse("{type: say, content: text}"); - let message = Message::try_from(&yaml).unwrap(); + let message = Message::try_from_yaml(&yaml).unwrap(); assert_eq!(MessageType::Say, message.message_type()); let yaml = parse("{type: warn, content: text}"); - let message = Message::try_from(&yaml).unwrap(); + let message = Message::try_from_yaml(&yaml).unwrap(); assert_eq!(MessageType::Warn, message.message_type()); let yaml = parse("{type: error, content: text}"); - let message = Message::try_from(&yaml).unwrap(); + let message = Message::try_from_yaml(&yaml).unwrap(); assert_eq!(MessageType::Error, message.message_type()); } @@ -671,7 +666,7 @@ mod tests { fn should_use_say_if_message_type_is_unrecognised() { let yaml = parse("{type: info, content: text}"); - let message = Message::try_from(&yaml).unwrap(); + let message = Message::try_from_yaml(&yaml).unwrap(); assert_eq!(MessageType::Say, message.message_type()); } @@ -681,7 +676,7 @@ mod tests { "{type: say, content: [{text: english, lang: en}, {text: french, lang: fr}]}", ); - let message = Message::try_from(&yaml).unwrap(); + let message = Message::try_from_yaml(&yaml).unwrap(); assert_eq!( &[ @@ -696,7 +691,7 @@ mod tests { fn should_not_error_if_one_content_object_is_given_and_it_is_not_english() { let yaml = parse("type: say\ncontent:\n - lang: fr\n text: content1"); - let message = Message::try_from(&yaml).unwrap(); + let message = Message::try_from_yaml(&yaml).unwrap(); assert_eq!( &[MessageContent::new("content1".into()).with_language("fr".into())], @@ -710,14 +705,14 @@ mod tests { "type: say\ncontent:\n - lang: de\n text: content1\n - lang: fr\n text: content2", ); - assert!(Message::try_from(&yaml).is_err()); + assert!(Message::try_from_yaml(&yaml).is_err()); } #[test] fn should_apply_substitutions_when_there_is_only_one_content_string() { let yaml = parse("type: say\ncontent: con{0}tent1\nsubs:\n - sub1"); - let message = Message::try_from(&yaml).unwrap(); + let message = Message::try_from_yaml(&yaml).unwrap(); assert_eq!("consub1tent1", message.content()[0].text()); } @@ -728,7 +723,7 @@ mod tests { "type: say\ncontent:\n - lang: en\n text: content1 {0}\n - lang: fr\n text: content2 {0}\nsubs:\n - sub", ); - let message = Message::try_from(&yaml).unwrap(); + let message = Message::try_from_yaml(&yaml).unwrap(); assert_eq!("content1 sub", message.content()[0].text()); assert_eq!("content2 sub", message.content()[1].text()); @@ -738,21 +733,21 @@ mod tests { fn should_error_if_the_message_has_more_substitutions_than_expected() { let yaml = parse("{type: say, content: 'content1', subs: [sub1]}"); - assert!(Message::try_from(&yaml).is_err()); + assert!(Message::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_the_content_string_expects_more_substitutions_than_exist() { let yaml = parse("{type: say, content: '{0} {1}', subs: [sub1]}"); - assert!(Message::try_from(&yaml).is_err()); + assert!(Message::try_from_yaml(&yaml).is_err()); } #[test] fn should_ignore_substution_syntax_if_no_substitutions_exist() { let yaml = parse("{type: say, content: 'content {0}'}"); - let message = Message::try_from(&yaml).unwrap(); + let message = Message::try_from_yaml(&yaml).unwrap(); assert_eq!("content {0}", message.content()[0].text()); } @@ -763,7 +758,7 @@ mod tests { "{type: say, content: 'content %1% %2% %3% %4% %5% %6% %7% %8% %9% %10% %11%', subs: [a, b, c, d, e, f, g, h, i, j, k]}", ); - let message = Message::try_from(&yaml).unwrap(); + let message = Message::try_from_yaml(&yaml).unwrap(); assert_eq!("content a b c d e f g h i j k", message.content()[0].text()); } diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index 8619e0d5..c8da2450 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -16,7 +16,7 @@ use super::{ group::Group, message::Message, plugin_metadata::PluginMetadata, - yaml::{EmitYaml, YamlEmitter, YamlObjectType, get_as_slice, process_merge_keys}, + yaml::{EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, get_as_slice, process_merge_keys}, }; #[derive(Clone, Debug, Eq, PartialEq)] @@ -119,7 +119,7 @@ impl MetadataDocument { let mut plugins: HashMap = HashMap::new(); let mut regex_plugins: Vec = Vec::new(); for plugin_yaml in get_as_slice(&doc, "plugins", YamlObjectType::MetadataDocument)? { - let plugin = PluginMetadata::try_from(plugin_yaml)?; + let plugin = PluginMetadata::try_from_yaml(plugin_yaml)?; if plugin.is_regex_plugin() { regex_plugins.push(plugin); } else { @@ -137,7 +137,7 @@ impl MetadataDocument { let messages = get_as_slice(&doc, "globals", YamlObjectType::MetadataDocument)? .iter() - .map(Message::try_from) + .map(Message::try_from_yaml) .collect::, _>>()?; let mut bash_tags = Vec::new(); @@ -171,7 +171,7 @@ impl MetadataDocument { let mut group_names = HashSet::new(); let mut groups = Vec::new(); for group_yaml in get_as_slice(&doc, "groups", YamlObjectType::MetadataDocument)? { - let group = Group::try_from(group_yaml)?; + let group = Group::try_from_yaml(group_yaml)?; let name = group.name().to_string(); if group_names.contains(&name) { diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index a6ec33dd..ef09b09c 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -6,8 +6,10 @@ use super::{ MessageContent, emit_message_contents, parse_message_contents_yaml, validate_message_contents, }, - yaml::{EmitYaml, YamlEmitter}, - yaml::{YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_u32_value}, + yaml::{ + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, as_string_node, get_as_hash, + get_required_string_value, get_u32_value, + }, }; /// Represents data identifying the plugin under which it is stored as dirty or @@ -134,10 +136,8 @@ impl PluginCleaningData { } } -impl TryFrom<&MarkedYaml> for PluginCleaningData { - type Error = ParseMetadataError; - - fn try_from(value: &MarkedYaml) -> Result { +impl TryFromYaml for PluginCleaningData { + fn try_from_yaml(value: &MarkedYaml) -> Result { let hash = get_as_hash(value, YamlObjectType::PluginCleaningData)?; let crc = match get_u32_value(hash, "crc", YamlObjectType::PluginCleaningData)? { @@ -224,58 +224,58 @@ mod tests { fn should_error_if_given_a_scalar() { let yaml = parse("0x12345678"); - assert!(PluginCleaningData::try_from(&yaml).is_err()); + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_given_a_list() { let yaml = parse("[0, 1, 2]"); - assert!(PluginCleaningData::try_from(&yaml).is_err()); + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_crc_is_missing() { let yaml = parse("{util: cleaner}"); - assert!(PluginCleaningData::try_from(&yaml).is_err()); + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_util_is_missing() { let yaml = parse("{crc: 0x12345678}"); - assert!(PluginCleaningData::try_from(&yaml).is_err()); + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_a_count_is_not_a_number() { let yaml = parse("{crc: 0x12345678, util: cleaner, itm: true}"); - assert!(PluginCleaningData::try_from(&yaml).is_err()); + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); let yaml = parse("{crc: 0x12345678, util: cleaner, udr: true}"); - assert!(PluginCleaningData::try_from(&yaml).is_err()); + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); let yaml = parse("{crc: 0x12345678, util: cleaner, nav: true}"); - assert!(PluginCleaningData::try_from(&yaml).is_err()); + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_a_count_does_not_fit_in_a_u32() { let yaml = parse("{crc: 0x12345678, util: cleaner, itm: -1}"); - assert!(PluginCleaningData::try_from(&yaml).is_err()); + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); let yaml = parse("{crc: 0x12345678, util: cleaner, udr: -2}"); - assert!(PluginCleaningData::try_from(&yaml).is_err()); + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); let yaml = parse("{crc: 0x12345678, util: cleaner, nav: -3}"); - assert!(PluginCleaningData::try_from(&yaml).is_err()); + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); } #[test] @@ -283,7 +283,7 @@ mod tests { let yaml = parse("{crc: 0x12345678, util: cleaner, detail: info, itm: 2, udr: 10, nav: 30}"); - let data = PluginCleaningData::try_from(&yaml).unwrap(); + let data = PluginCleaningData::try_from_yaml(&yaml).unwrap(); assert_eq!(0x12345678, data.crc()); assert_eq!("cleaner", data.cleaning_utility()); @@ -297,7 +297,7 @@ mod tests { fn should_leave_optional_fields_at_defaults_if_not_present() { let yaml = parse("{crc: 0x12345678, util: cleaner}"); - let data = PluginCleaningData::try_from(&yaml).unwrap(); + let data = PluginCleaningData::try_from_yaml(&yaml).unwrap(); assert_eq!(0x12345678, data.crc()); assert_eq!("cleaner", data.cleaning_utility()); @@ -313,7 +313,7 @@ mod tests { "{crc: 0x12345678, util: cleaner, detail: [{text: english, lang: en}, {text: french, lang: fr}]}", ); - let data = PluginCleaningData::try_from(&yaml).unwrap(); + let data = PluginCleaningData::try_from_yaml(&yaml).unwrap(); assert_eq!( &[ @@ -329,7 +329,7 @@ mod tests { let yaml = parse("crc: 0x12345678\nutil: cleaner\ndetail:\n - lang: fr\n text: content1"); - let data = PluginCleaningData::try_from(&yaml).unwrap(); + let data = PluginCleaningData::try_from_yaml(&yaml).unwrap(); assert_eq!( &[MessageContent::new("content1".into()).with_language("fr".into())], @@ -343,7 +343,7 @@ mod tests { "crc: 0x12345678\nutil: cleaner\ndetail:\n - lang: de\n text: content1\n - lang: fr\n text: content2", ); - assert!(PluginCleaningData::try_from(&yaml).is_err()); + assert!(PluginCleaningData::try_from_yaml(&yaml).is_err()); } } diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index bd126f0e..33ee8e67 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -12,9 +12,9 @@ use super::{ message::Message, plugin_cleaning_data::PluginCleaningData, tag::Tag, - yaml::{EmitYaml, YamlEmitter}, yaml::{ - YamlObjectType, get_as_hash, get_as_slice, get_required_string_value, get_string_value, + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, get_as_hash, get_as_slice, + get_required_string_value, get_string_value, }, }; @@ -349,10 +349,8 @@ fn replace_capturing_groups(regex_string: &str) -> Cow<'_, str> { } } -impl TryFrom<&MarkedYaml> for PluginMetadata { - type Error = ParseMetadataError; - - fn try_from(value: &MarkedYaml) -> Result { +impl TryFromYaml for PluginMetadata { + fn try_from_yaml(value: &MarkedYaml) -> Result { let hash = get_as_hash(value, YamlObjectType::PluginMetadata)?; let name = get_required_string_value( @@ -397,13 +395,13 @@ impl TryFrom<&MarkedYaml> for PluginMetadata { } } -fn get_vec<'a, T: TryFrom<&'a MarkedYaml, Error = impl Into>>( - hash: &'a saphyr::AnnotatedHash, +fn get_vec( + hash: &saphyr::AnnotatedHash, key: &'static str, ) -> Result, ParseMetadataError> { let mut vec = get_as_slice(hash, key, YamlObjectType::PluginMetadata)? .iter() - .map(|e| T::try_from(e).map_err(Into::into)) + .map(|e| T::try_from_yaml(e)) .collect::, _>>()?; vec.shrink_to_fit(); @@ -872,14 +870,14 @@ mod tests { fn should_error_if_given_a_scalar() { let yaml = parse("name1"); - assert!(PluginMetadata::try_from(&yaml).is_err()); + assert!(PluginMetadata::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_given_a_list() { let yaml = parse("[0, 1, 2]"); - assert!(PluginMetadata::try_from(&yaml).is_err()); + assert!(PluginMetadata::try_from_yaml(&yaml).is_err()); } #[test] @@ -908,7 +906,7 @@ mod tests { - 'https://www.example.com'", ); - let plugin = PluginMetadata::try_from(&yaml).unwrap(); + let plugin = PluginMetadata::try_from_yaml(&yaml).unwrap(); assert_eq!(BLANK_ESP, plugin.name()); assert_eq!(&[File::new(BLANK_ESM.into())], plugin.load_after_files()); @@ -955,7 +953,7 @@ mod tests { util: 'utility'", ); - let plugin = PluginMetadata::try_from(&yaml).unwrap(); + let plugin = PluginMetadata::try_from_yaml(&yaml).unwrap(); assert_eq!("Blank\\.esp", plugin.name()); assert_eq!( @@ -972,14 +970,14 @@ mod tests { fn should_error_if_regex_name_is_invalid() { let yaml = parse("{name: 'RagnvaldBook(Farengar(+Ragnvald)?)?\\.esp'}"); - assert!(PluginMetadata::try_from(&yaml).is_err()); + assert!(PluginMetadata::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_a_field_that_should_be_an_array_is_not() { let yaml = parse("{name: 'Blank.esp', after: Blank.esm}"); - assert!(PluginMetadata::try_from(&yaml).is_err()); + assert!(PluginMetadata::try_from_yaml(&yaml).is_err()); } } diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index 70543281..682ab75b 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -1,10 +1,12 @@ -use saphyr::YamlData; +use saphyr::{MarkedYaml, YamlData}; -use super::error::ExpectedType; -use super::error::ParseMetadataError; -use super::yaml::EmitYaml; -use super::yaml::YamlEmitter; -use super::yaml::{YamlObjectType, get_required_string_value, parse_condition}; +use super::{ + error::{ExpectedType, ParseMetadataError}, + yaml::{ + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, get_required_string_value, + parse_condition, + }, +}; /// Represents whether a Bash Tag suggestion is for addition or removal. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -62,10 +64,8 @@ impl Tag { } } -impl TryFrom<&saphyr::MarkedYaml> for Tag { - type Error = ParseMetadataError; - - fn try_from(value: &saphyr::MarkedYaml) -> Result { +impl TryFromYaml for Tag { + fn try_from_yaml(value: &MarkedYaml) -> Result { match &value.data { YamlData::String(s) => { let (name, suggestion) = name_and_suggestion(s); @@ -146,7 +146,7 @@ mod tests { fn should_only_set_name_and_suggestion_if_decoding_from_scalar() { let yaml = parse("Relev"); - let tag = Tag::try_from(&yaml).unwrap(); + let tag = Tag::try_from_yaml(&yaml).unwrap(); assert_eq!("Relev", tag.name()); assert!(tag.is_addition()); @@ -157,7 +157,7 @@ mod tests { fn should_only_set_name_and_suggestion_if_decoding_from_scalar_with_leading_hyphen() { let yaml = parse("-Relev"); - let tag = Tag::try_from(&yaml).unwrap(); + let tag = Tag::try_from_yaml(&yaml).unwrap(); assert_eq!("Relev", tag.name()); assert!(!tag.is_addition()); @@ -168,28 +168,28 @@ mod tests { fn should_error_if_given_a_list() { let yaml = parse("[0, 1, 2]"); - assert!(Tag::try_from(&yaml).is_err()); + assert!(Tag::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_name_is_missing() { let yaml = parse("{condition: 'file(\"Foo.esp\")'}"); - assert!(Tag::try_from(&yaml).is_err()); + assert!(Tag::try_from_yaml(&yaml).is_err()); } #[test] fn should_error_if_given_an_invalid_condition() { let yaml = parse("{name: Relev, condition: invalid}"); - assert!(Tag::try_from(&yaml).is_err()); + assert!(Tag::try_from_yaml(&yaml).is_err()); } #[test] fn should_set_all_fields() { let yaml = parse("{name: Relev, condition: 'file(\"Foo.esp\")'}"); - let tag = Tag::try_from(&yaml).unwrap(); + let tag = Tag::try_from_yaml(&yaml).unwrap(); assert_eq!("Relev", tag.name()); assert!(tag.is_addition()); @@ -200,7 +200,7 @@ mod tests { fn should_leave_optional_fields_empty_if_not_present() { let yaml = parse("{name: Relev}"); - let tag = Tag::try_from(&yaml).unwrap(); + let tag = Tag::try_from_yaml(&yaml).unwrap(); assert_eq!("Relev", tag.name()); assert!(tag.is_addition()); diff --git a/src/metadata/yaml/mod.rs b/src/metadata/yaml/mod.rs index 8eab521a..90d1040e 100644 --- a/src/metadata/yaml/mod.rs +++ b/src/metadata/yaml/mod.rs @@ -5,6 +5,7 @@ mod parse; pub use emit::{EmitYaml, YamlEmitter}; pub use merge::process_merge_keys; pub use parse::{ - YamlObjectType, as_string_node, get_as_hash, get_as_slice, get_required_string_value, - get_string_value, get_strings_vec_value, get_u32_value, parse_condition, to_yaml, + TryFromYaml, YamlObjectType, as_string_node, get_as_hash, get_as_slice, + get_required_string_value, get_string_value, get_strings_vec_value, get_u32_value, + parse_condition, to_yaml, }; diff --git a/src/metadata/yaml/parse.rs b/src/metadata/yaml/parse.rs index 1364ea5e..8b426887 100644 --- a/src/metadata/yaml/parse.rs +++ b/src/metadata/yaml/parse.rs @@ -192,3 +192,9 @@ pub fn parse_condition( None => Ok(None), } } + +/// This is effectively TryFrom<&MarkedYaml>, but implementing it doesn't make +/// MarkedYaml part of the crate's public API. +pub trait TryFromYaml: Sized { + fn try_from_yaml(value: &MarkedYaml) -> Result; +} From ac5356cfa7c8330d85bbab3a8b2182fe12f33903 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 30 Mar 2025 13:43:49 +0100 Subject: [PATCH 066/206] Remove pip caching in CI It's not actually used. --- .github/workflows/ci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3da1a7a..21aa3779 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,12 +61,6 @@ jobs: python-version: '3.13' architecture: ${{ matrix.target.architecture }} - - name: pip cache - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-maturin - - uses: actions/cache@v4 with: path: | From c8d7654e939873116a68198a46d7e75a3c344b5c Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 30 Mar 2025 13:40:53 +0100 Subject: [PATCH 067/206] Include architecture in CI cache key So that the two Windows builds don't try to use the same caches. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21aa3779..854114f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-${{ matrix.target.architecture }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: Install Rust i686-pc-windows-msvc target run: rustup target add i686-pc-windows-msvc From dc3db737f7ae9f173d605414339a9db32b9fdd05 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 30 Mar 2025 16:29:10 +0100 Subject: [PATCH 068/206] Reorder dependencies in Cargo.toml --- Cargo.toml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e0c421ed..333f460d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,17 +6,18 @@ license = "GPL-3.0" [dependencies] crc32fast = "1.4.2" -esplugin.workspace = true -log = { version = "0.4.26", features = ["std"] } -libloadorder.workspace = true -loot-condition-interpreter.workspace = true -petgraph = "0.7.1" fancy-regex = "0.14.0" +log = { version = "0.4.26", features = ["std"] } +petgraph = "0.7.1" +rayon = "1.10.0" +rustc-hash = "2.1.1" saphyr = "0.0.3" saphyr-parser = "0.0.3" unicase = "2.8.1" -rayon = "1.10.0" -rustc-hash = "2.1.1" + +esplugin.workspace = true +libloadorder.workspace = true +loot-condition-interpreter.workspace = true [target.'cfg(windows)'.dependencies] windows = { version = "0.61.1", features = ["Win32_Storage_FileSystem"] } From 43ac3bbe2999ba6dcb2d00375efd792710bca72a Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 7 Apr 2025 08:39:42 +0100 Subject: [PATCH 069/206] Remove unnecessary cfg attribute --- src/sorting/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sorting/mod.rs b/src/sorting/mod.rs index 54b033dc..7c282860 100644 --- a/src/sorting/mod.rs +++ b/src/sorting/mod.rs @@ -44,7 +44,6 @@ mod test { } } - #[cfg(test)] impl SortingPlugin for TestPlugin { fn name(&self) -> &str { &self.name From e6b1e263a95cbca04492df4c7c4e22a87984c74b Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 7 Apr 2025 08:39:57 +0100 Subject: [PATCH 070/206] Pass prelude by reference --- src/metadata/metadata_document.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index c8da2450..cd357eaa 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -78,7 +78,7 @@ impl MetadataDocument { let prelude = std::fs::read_to_string(prelude_path) .map_err(|e| LoadMetadataError::from_io_error(masterlist_path.into(), e))?; - let masterlist = replace_prelude(masterlist, prelude); + let masterlist = replace_prelude(masterlist, &prelude); self.load_from_str(&masterlist) .map_err(|e| LoadMetadataError::new(masterlist_path.into(), e))?; @@ -337,7 +337,7 @@ impl std::default::Default for MetadataDocument { } } -fn replace_prelude(masterlist: String, prelude: String) -> String { +fn replace_prelude(masterlist: String, prelude: &str) -> String { let line_ending = detect_line_ending(&masterlist); if let Some((start, end)) = find_prelude_bounds(&masterlist) { let prelude = indent_prelude(prelude, line_ending); @@ -392,7 +392,7 @@ fn find_prelude_bounds(masterlist: &str) -> Option<(usize, usize)> { Some((start, masterlist.len())) } -fn indent_prelude(prelude: String, line_ending: &str) -> String { +fn indent_prelude(prelude: &str, line_ending: &str) -> String { let prelude = ("\n ".to_string() + &prelude.replace("\n", "\n ")) .replace(&format!(" {}", line_ending), line_ending); @@ -781,7 +781,7 @@ plugins: #[test] fn should_return_an_empty_string_if_given_empty_strings() { - let result = replace_prelude(String::new(), String::new()); + let result = replace_prelude(String::new(), ""); assert!(result.is_empty()); } @@ -796,7 +796,7 @@ plugins: - name: a.esp "; - let result = replace_prelude(masterlist.into(), prelude.into()); + let result = replace_prelude(masterlist.into(), prelude); assert_eq!(masterlist, result); } @@ -806,7 +806,7 @@ plugins: let prelude = "globals: [{type: note, content: A message.}]"; let masterlist = "{prelude: {}, plugins: [{name: a.esp}]}"; - let result = replace_prelude(masterlist.into(), prelude.into()); + let result = replace_prelude(masterlist.into(), prelude); assert_eq!(masterlist, result); } @@ -824,7 +824,7 @@ plugins: - name: a.esp "; - let result = replace_prelude(masterlist.into(), prelude.into()); + let result = replace_prelude(masterlist.into(), prelude); let expected_result = "prelude: globals: @@ -851,7 +851,7 @@ prelude: "; - let result = replace_prelude(masterlist.into(), prelude.into()); + let result = replace_prelude(masterlist.into(), prelude); let expected_result = "plugins: - name: a.esp @@ -882,7 +882,7 @@ plugins: - name: a.esp "; - let result = replace_prelude(masterlist.into(), prelude.into()); + let result = replace_prelude(masterlist.into(), prelude); let expected_result = " common: @@ -915,7 +915,7 @@ plugins: - name: a.esp "; - let result = replace_prelude(masterlist.into(), prelude.into()); + let result = replace_prelude(masterlist.into(), prelude); let expected_result = "prelude: globals: @@ -939,7 +939,7 @@ plugins: - name: a.esp "; - let result = replace_prelude(masterlist.into(), prelude.into()); + let result = replace_prelude(masterlist.into(), prelude); let expected_result = "prelude: globals: [{type: note, content: A message.}] @@ -965,7 +965,7 @@ plugins: - name: a.esp "; - let result = replace_prelude(masterlist.into(), prelude.into()); + let result = replace_prelude(masterlist.into(), prelude); let expected_result = "prelude: globals: @@ -993,7 +993,7 @@ plugins: - name: a.esp "; - let result = replace_prelude(masterlist.into(), prelude.into()); + let result = replace_prelude(masterlist.into(), prelude); let expected_result = "prelude: globals: From 76d041050902188cfbdd0fc32a1c2b4453629587 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 9 Apr 2025 17:23:31 +0100 Subject: [PATCH 071/206] Update dependencies --- Cargo.lock | 256 +++++++++++++++++++++++++++++++---------------------- Cargo.toml | 2 +- 2 files changed, 152 insertions(+), 106 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e8c8e09d..0aa6c2e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,7 +11,7 @@ dependencies = [ "cfg-if", "once_cell", "version_check", - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -83,17 +83,11 @@ dependencies = [ "generic-array", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "cc" -version = "1.2.16" +version = "1.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +checksum = "525046617d8376e3db1deffb079e91cef90a89fc3ca5c185bbf8c9ecdd15cd5c" dependencies = [ "shlex", ] @@ -106,18 +100,18 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.5.31" +version = "4.5.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "027bb0d98429ae334a8698531da7077bdf906419543a35a55c2cb1b66437d767" +checksum = "d8aa86934b44c19c50f87cc2790e19f54f7a67aedb64101c2e1a2e5ecfb73944" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.31" +version = "4.5.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5589e0cba072e0f3d23791efac0fd8627b49c829c196a492e88168e6a669d863" +checksum = "2414dbb2dd0695280da6ea9261e327479e9d37b0630f6b53ba2a11c60c679fd9" dependencies = [ "anstyle", "clap_lex", @@ -132,10 +126,11 @@ checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "codespan-reporting" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ + "serde", "termcolor", "unicode-width", ] @@ -221,9 +216,9 @@ dependencies = [ [[package]] name = "cxx" -version = "1.0.143" +version = "1.0.156" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050906babad73f9b32a91cecc3063ff1e2235226dd2367dd839fd6fbc941c68a" +checksum = "aa3a202fc4f3dd6d2ce5a2f87b04fb2becc00f5643ee9c4743ba10777efb314f" dependencies = [ "cc", "cxxbridge-cmd", @@ -235,9 +230,9 @@ dependencies = [ [[package]] name = "cxx-build" -version = "1.0.143" +version = "1.0.156" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875d58f2ac56025a775b91a424515b5adf1e68205765f2c90e6dd81e269ae004" +checksum = "644bdf46f34f6325783f76a8ad8e737ab995a302d7868b5236a1ba55008883e0" dependencies = [ "cc", "codespan-reporting", @@ -249,9 +244,9 @@ dependencies = [ [[package]] name = "cxxbridge-cmd" -version = "1.0.143" +version = "1.0.156" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19c3062da294104183e1c34ea9887941d4d8c74f6195ce9fbb430ac4b5290ede" +checksum = "8e8cefbebcb74ed0b4a08b76139e6c29d8884a0bb94d02c6f35de821a14a6e39" dependencies = [ "clap", "codespan-reporting", @@ -262,15 +257,15 @@ dependencies = [ [[package]] name = "cxxbridge-flags" -version = "1.0.143" +version = "1.0.156" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4b358173a166833ddef75fe468579f71727c789b8082d4cc77c38d08f656c59" +checksum = "604e3eff62e2f27289d618f621491a068330c3c9f8eb06555dabc292c123596e" [[package]] name = "cxxbridge-macro" -version = "1.0.143" +version = "1.0.156" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9531217f3b5f7728244d2b7312bc6660f6b3e4cdbc118f4f1fbce48cb401a0f" +checksum = "130c3a05501d9c15dedbf08f2ff9af60f8e78422e3dffac1f43e2d83c5b489a1" dependencies = [ "proc-macro2", "quote", @@ -289,9 +284,9 @@ dependencies = [ [[package]] name = "delegate" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297806318ef30ad066b15792a8372858020ae3ca2e414ee6c2133b1eb9e9e945" +checksum = "b9b6483c2bbed26f97861cf57651d4f2b731964a28cd2257f934a4b452480d21" dependencies = [ "proc-macro2", "quote", @@ -346,9 +341,9 @@ dependencies = [ [[package]] name = "either" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7914353092ddf589ad78f25c5c1c21b7f80b0ff8621e7c814c3485b5306da9d" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "encoding_rs" @@ -367,9 +362,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" dependencies = [ "libc", "windows-sys", @@ -412,9 +407,9 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "foldhash" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "futures-core" @@ -482,14 +477,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" dependencies = [ "cfg-if", "libc", - "wasi 0.13.3+wasi-0.2.2", - "windows-targets", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", ] [[package]] @@ -513,6 +508,11 @@ name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashlink" @@ -531,9 +531,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "indexmap" -version = "2.7.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", "hashbrown 0.15.2", @@ -558,9 +558,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.170" +version = "0.2.171" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" [[package]] name = "libloadorder" @@ -654,15 +654,15 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.4.15" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" [[package]] name = "log" -version = "0.4.26" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "loot-condition-interpreter" @@ -726,9 +726,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.3" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "option-ext" @@ -767,20 +767,20 @@ checksum = "7a7cf3f8ecebb0f4895f4892a8be0a0dc81b498f9d56735cb769dc31bf00815b" [[package]] name = "pest" -version = "2.7.15" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" +checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" dependencies = [ "memchr", - "thiserror 2.0.11", + "thiserror 2.0.12", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.7.15" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e" +checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" dependencies = [ "pest", "pest_generator", @@ -788,9 +788,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.15" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b" +checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" dependencies = [ "pest", "pest_meta", @@ -801,9 +801,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.7.15" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea" +checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" dependencies = [ "once_cell", "pest", @@ -812,12 +812,14 @@ dependencies = [ [[package]] name = "petgraph" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +checksum = "7a98c6720655620a521dcc722d0ad66cd8afd5d86e34a89ef691c50b7b24de06" dependencies = [ "fixedbitset", + "hashbrown 0.15.2", "indexmap", + "serde", ] [[package]] @@ -840,36 +842,36 @@ checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" [[package]] name = "ppv-lite86" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy", + "zerocopy 0.8.24", ] [[package]] name = "proc-macro-crate" -version = "3.2.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" dependencies = [ "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.93" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" dependencies = [ "unicode-ident", ] [[package]] name = "pyo3" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f1c6c3591120564d64db2261bec5f910ae454f01def849b9c22835a84695e86" +checksum = "17da310086b068fbdcefbba30aeb3721d5bb9af8db4987d6735b2183ca567229" dependencies = [ "cfg-if", "indoc", @@ -885,9 +887,9 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9b6c2b34cf71427ea37c7001aefbaeb85886a074795e35f161f5aecc7620a7a" +checksum = "e27165889bd793000a098bb966adc4300c312497ea25cf7a690a9f0ac5aa5fc1" dependencies = [ "once_cell", "target-lexicon", @@ -895,9 +897,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5507651906a46432cdda02cd02dd0319f6064f1374c9147c45b978621d2c3a9c" +checksum = "05280526e1dbf6b420062f3ef228b78c0c54ba94e157f5cb724a609d0f2faabc" dependencies = [ "libc", "pyo3-build-config", @@ -905,9 +907,9 @@ dependencies = [ [[package]] name = "pyo3-log" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b78e4983ba15bc62833a0e0941d965bc03690163f1127864f1408db25063466" +checksum = "7079e412e909af5d6be7c04a7f29f6a2837a080410e1c529c9dee2c367383db4" dependencies = [ "arc-swap", "log", @@ -916,9 +918,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0d394b5b4fd8d97d48336bb0dd2aebabad39f1d294edd6bcd2cccf2eefe6f42" +checksum = "5c3ce5686aa4d3f63359a5100c62a127c9f15e8398e5fdeb5deef1fed5cd5f44" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -928,9 +930,9 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd72da09cfa943b1080f621f024d2ef7e2773df7badd51aa30a2be1f8caa7c8e" +checksum = "f4cf6faa0cbfb0ed08e89beb8103ae9724eb4750e3a78084ba4017cbe94f3855" dependencies = [ "heck", "proc-macro2", @@ -941,13 +943,19 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.38" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + [[package]] name = "rand" version = "0.8.5" @@ -1006,7 +1014,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.15", "libredox", - "thiserror 2.0.11", + "thiserror 2.0.12", ] [[package]] @@ -1114,9 +1122,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.44" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" dependencies = [ "bitflags", "errno", @@ -1165,6 +1173,26 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.10.8" @@ -1199,9 +1227,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.98" +version = "2.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" dependencies = [ "proc-macro2", "quote", @@ -1216,13 +1244,12 @@ checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" [[package]] name = "tempfile" -version = "3.17.1" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230" +checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" dependencies = [ - "cfg-if", "fastrand", - "getrandom 0.3.1", + "getrandom 0.3.2", "once_cell", "rustix", "windows-sys", @@ -1248,11 +1275,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl 2.0.11", + "thiserror-impl 2.0.12", ] [[package]] @@ -1268,9 +1295,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", @@ -1329,15 +1356,15 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-ident" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-width" -version = "0.1.14" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unindent" @@ -1359,9 +1386,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasi" -version = "0.13.3+wasi-0.2.2" +version = "0.14.2+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" dependencies = [ "wit-bindgen-rt", ] @@ -1573,18 +1600,18 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.3" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" +checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen-rt" -version = "0.33.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ "bitflags", ] @@ -1595,8 +1622,16 @@ version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ - "byteorder", - "zerocopy-derive", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +dependencies = [ + "zerocopy-derive 0.8.24", ] [[package]] @@ -1609,3 +1644,14 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zerocopy-derive" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 333f460d..7a754321 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ license = "GPL-3.0" crc32fast = "1.4.2" fancy-regex = "0.14.0" log = { version = "0.4.26", features = ["std"] } -petgraph = "0.7.1" +petgraph = "0.8.1" rayon = "1.10.0" rustc-hash = "2.1.1" saphyr = "0.0.3" From cc60338b7d27f2a91e7bff6907776cc50b99ad20 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 10 Apr 2025 17:52:28 +0100 Subject: [PATCH 072/206] Improve PluginName encapsulation --- src/metadata/plugin_metadata.rs | 34 ++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 33ee8e67..1db5dd70 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -47,7 +47,7 @@ impl PluginMetadata { /// Get the plugin name. pub fn name(&self) -> &str { - &self.name.string + self.name.as_str() } /// Get the plugin's group. @@ -252,11 +252,15 @@ impl PluginName { fn is_regex(&self) -> bool { self.regex.is_some() } + + fn as_str(&self) -> &str { + &self.string + } } impl std::cmp::PartialEq for PluginName { fn eq(&self, other: &Self) -> bool { - self.string == other.string + self.as_str() == other.as_str() } } @@ -270,13 +274,13 @@ impl std::cmp::PartialOrd for PluginName { impl std::cmp::Ord for PluginName { fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.string.cmp(&other.string) + self.as_str().cmp(other.as_str()) } } impl std::hash::Hash for PluginName { fn hash(&self, state: &mut H) { - self.string.hash(state); + self.as_str().hash(state); } } @@ -853,7 +857,7 @@ mod tests { assert_eq!( format!( "name: '{}'\nafter: ['{}']", - plugin.name.string, + plugin.name(), plugin.load_after[0].name() ), yaml @@ -990,7 +994,7 @@ mod tests { let plugin = PluginMetadata::new("test.esp").unwrap(); let yaml = emit(&plugin); - assert_eq!(format!("name: '{}'", plugin.name.string), yaml); + assert_eq!(format!("name: '{}'", plugin.name()), yaml); } #[test] @@ -1002,8 +1006,8 @@ mod tests { assert_eq!( format!( "name: '{}'\ngroup: '{}'", - plugin.name.string, - plugin.group.unwrap() + plugin.name(), + plugin.group.as_ref().unwrap() ), yaml ); @@ -1018,7 +1022,7 @@ mod tests { assert_eq!( format!( "name: '{}'\nafter: ['{}']", - plugin.name.string, + plugin.name(), plugin.load_after[0].name() ), yaml @@ -1036,7 +1040,7 @@ mod tests { assert_eq!( format!( "name: '{}'\nafter:\n - name: '{}'\n condition: '{}'", - plugin.name.string, + plugin.name(), plugin.load_after[0].name(), plugin.load_after[0].condition().unwrap(), ), @@ -1056,7 +1060,7 @@ mod tests { assert_eq!( format!( "name: '{}'\nafter:\n - '{}'\n - '{}'", - plugin.name.string, + plugin.name(), plugin.load_after[0].name(), plugin.load_after[1].name(), ), @@ -1073,7 +1077,7 @@ mod tests { assert_eq!( format!( "name: '{}'\nreq: ['{}']", - plugin.name.string, + plugin.name(), plugin.requirements[0].name() ), yaml @@ -1089,7 +1093,7 @@ mod tests { assert_eq!( format!( "name: '{}'\ninc: ['{}']", - plugin.name.string, + plugin.name(), plugin.incompatibilities[0].name() ), yaml @@ -1108,7 +1112,7 @@ mod tests { assert_eq!( format!( "name: '{}'\nmsg:\n - type: {}\n content: '{}'\n - type: {}\n content: '{}'", - plugin.name.string, + plugin.name(), plugin.messages[0].message_type(), plugin.messages[0].content()[0].text(), plugin.messages[1].message_type(), @@ -1127,7 +1131,7 @@ mod tests { assert_eq!( format!( "name: '{}'\ntag: [{}]", - plugin.name.string, + plugin.name(), plugin.tags[0].name() ), yaml From 0f48696609ec6d10359939719c313758b10454b0 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Fri, 11 Apr 2025 21:44:49 +0100 Subject: [PATCH 073/206] Reduce size of File struct --- src/metadata/file.rs | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/metadata/file.rs b/src/metadata/file.rs index 25fa7c93..406052d4 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -110,14 +110,14 @@ impl File { } /// Represents a case-insensitive filename. -#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] -pub struct Filename(UniCase); +#[derive(Clone, Debug, Default)] +pub struct Filename(String); impl Filename { /// Construct a Filename using the given string. #[must_use] pub fn new(s: String) -> Self { - Filename(UniCase::new(s)) + Filename(s) } /// Get this Filename as a string. @@ -126,6 +126,32 @@ impl Filename { } } +impl PartialEq for Filename { + fn eq(&self, other: &Self) -> bool { + unicase::eq(&self.0, &other.0) + } +} + +impl Eq for Filename {} + +impl PartialOrd for Filename { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Filename { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + UniCase::new(&self.0).cmp(&UniCase::new(&other.0)) + } +} + +impl std::hash::Hash for Filename { + fn hash(&self, state: &mut H) { + UniCase::new(&self.0).hash(state); + } +} + impl AsRef for &Filename { fn as_ref(&self) -> &str { &self.0 From a249b66f422a1e7340a1d5ba1801478b4137096f Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Fri, 11 Apr 2025 21:45:36 +0100 Subject: [PATCH 074/206] Remove unnecessary semicolon --- src/metadata/metadata_document.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index cd357eaa..d0aff4b9 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -131,7 +131,7 @@ impl MetadataDocument { YamlObjectType::PluginMetadata, ) .into()); - }; + } } } From 0b0bc4eac696a4b7f73ff5528215fc06eef7c55c Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Fri, 11 Apr 2025 21:51:09 +0100 Subject: [PATCH 075/206] Remove some unnecessary turbofishes --- src/database/conditions.rs | 4 ++-- src/game.rs | 2 +- src/logging.rs | 4 ++-- src/metadata/plugin_metadata.rs | 16 ++++++++-------- src/metadata/yaml/merge.rs | 4 ++-- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/database/conditions.rs b/src/database/conditions.rs index 9e777998..9ce0a71a 100644 --- a/src/database/conditions.rs +++ b/src/database/conditions.rs @@ -85,7 +85,7 @@ fn filter_files_on_conditions( files .iter() .filter_map(|file| filter_map_on_condition(file, file.condition(), state)) - .collect::, _>>() + .collect() } fn filter_cleaning_data_on_conditions( @@ -104,7 +104,7 @@ fn filter_cleaning_data_on_conditions( filter_map_on_condition(i, Some(condition.as_str()), state) }) - .collect::, _>>() + .collect() } #[cfg(test)] diff --git a/src/game.rs b/src/game.rs index a4f3d4e2..dff8dd91 100644 --- a/src/game.rs +++ b/src/game.rs @@ -618,7 +618,7 @@ fn validate_plugin_paths( let resolved_path = resolve_plugin_path(game_type, data_path, path); validate_plugin_path_and_header(game_type, &resolved_path) }) - .collect::>() + .collect() } fn find_archives( diff --git a/src/logging.rs b/src/logging.rs index db752298..8b218dd8 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -213,7 +213,7 @@ mod tests { fn should_support_a_closure_with_captured_state() { let _lock = TEST_LOCK.lock().unwrap(); - let messages = Arc::new(Mutex::new(Vec::<(LogLevel, String)>::new())); + let messages = Arc::new(Mutex::new(Vec::new())); let cloned_messages = messages.clone(); let callback = move |level, message: &str| { if let Ok(mut messages) = cloned_messages.lock() { @@ -238,7 +238,7 @@ mod tests { let callback = |_, _: &str| {}; set_logging_callback(callback); - let messages = Arc::new(Mutex::new(Vec::<(LogLevel, String)>::new())); + let messages = Arc::new(Mutex::new(Vec::new())); let cloned_messages = messages.clone(); let callback = move |level, message: &str| { if let Ok(mut messages) = cloned_messages.lock() { diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 1db5dd70..bdb48856 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -375,14 +375,14 @@ impl TryFromYaml for PluginMetadata { let group = get_string_value(hash, "group", YamlObjectType::PluginMetadata)?; - let load_after = get_vec::(hash, "after")?; - let requirements = get_vec::(hash, "req")?; - let incompatibilities = get_vec::(hash, "inc")?; - let messages = get_vec::(hash, "msg")?; - let tags = get_vec::(hash, "tag")?; - let dirty_info = get_vec::(hash, "dirty")?; - let clean_info = get_vec::(hash, "clean")?; - let locations = get_vec::(hash, "url")?; + let load_after = get_vec(hash, "after")?; + let requirements = get_vec(hash, "req")?; + let incompatibilities = get_vec(hash, "inc")?; + let messages = get_vec(hash, "msg")?; + let tags = get_vec(hash, "tag")?; + let dirty_info = get_vec(hash, "dirty")?; + let clean_info = get_vec(hash, "clean")?; + let locations = get_vec(hash, "url")?; Ok(PluginMetadata { name, diff --git a/src/metadata/yaml/merge.rs b/src/metadata/yaml/merge.rs index f16c0b88..24b4dd02 100644 --- a/src/metadata/yaml/merge.rs +++ b/src/metadata/yaml/merge.rs @@ -51,14 +51,14 @@ fn merge_into_hash( value: MarkedYaml, ) -> Result, YamlMergeKeyError> { match value.data { - YamlData::::Array(a) => a.into_iter().try_fold(hash, |acc, e| { + YamlData::Array(a) => a.into_iter().try_fold(hash, |acc, e| { if let YamlData::Hash(h) = e.data { Ok(merge_hashes(acc, h)) } else { Err(YamlMergeKeyError::new(e)) } }), - YamlData::::Hash(h) => Ok(merge_hashes(hash, h)), + YamlData::Hash(h) => Ok(merge_hashes(hash, h)), _ => Err(YamlMergeKeyError::new(value)), } } From f69a7ced1debcf3f3fcb5aa90c9c23930b3d8159 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Fri, 11 Apr 2025 21:51:47 +0100 Subject: [PATCH 076/206] Deduplicate setting condition --- src/metadata/tag.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index 682ab75b..b9276c36 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -38,7 +38,7 @@ impl Tag { /// Set the condition string. #[must_use] pub fn with_condition(mut self, condition: String) -> Self { - self.condition = Some(condition); + self.set_condition(condition); self } From 57057b25ce4649b2cdf91cd1c7d0ce6f002d8f54 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 14 Apr 2025 18:31:58 +0100 Subject: [PATCH 077/206] Store fixed-size sequences as boxed slices I.e. Box<[T]> and Box instead of Vec or String. --- src/metadata/file.rs | 22 +++--- src/metadata/group.rs | 18 ++--- src/metadata/location.rs | 14 ++-- src/metadata/message.rs | 46 ++++++------ src/metadata/plugin_cleaning_data.rs | 12 +-- src/metadata/plugin_metadata.rs | 105 ++++++++++++++------------- src/metadata/tag.rs | 14 ++-- src/metadata/yaml/emit.rs | 6 ++ src/metadata/yaml/parse.rs | 6 +- src/plugin/mod.rs | 13 ++-- src/sorting/groups.rs | 36 +++++---- src/sorting/plugins.rs | 52 ++++++------- src/sorting/validate.rs | 8 +- 13 files changed, 183 insertions(+), 169 deletions(-) diff --git a/src/metadata/file.rs b/src/metadata/file.rs index 406052d4..676b3701 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -18,9 +18,9 @@ use super::{ #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct File { name: Filename, - display_name: Option, - detail: Vec, - condition: Option, + display_name: Option>, + detail: Box<[MessageContent]>, + condition: Option>, } impl File { @@ -72,7 +72,7 @@ impl File { /// Set the name to be displayed for the file in messages, formatted using /// CommonMark. pub fn set_display_name(&mut self, display_name: String) -> &mut Self { - self.display_name = Some(display_name); + self.display_name = Some(display_name.into_boxed_str()); self } @@ -93,7 +93,7 @@ impl File { detail: Vec, ) -> Result<&mut Self, MultilingualMessageContentsError> { validate_message_contents(&detail)?; - self.detail = detail; + self.detail = detail.into_boxed_slice(); Ok(self) } @@ -104,20 +104,20 @@ impl File { /// Set the condition string. pub fn set_condition(&mut self, condition: String) -> &mut Self { - self.condition = Some(condition); + self.condition = Some(condition.into_boxed_str()); self } } /// Represents a case-insensitive filename. #[derive(Clone, Debug, Default)] -pub struct Filename(String); +pub struct Filename(Box); impl Filename { /// Construct a Filename using the given string. #[must_use] pub fn new(s: String) -> Self { - Filename(s) + Filename(s.into()) } /// Get this Filename as a string. @@ -170,7 +170,7 @@ impl TryFromYaml for File { YamlData::String(s) => Ok(File { name: Filename::new(s.clone()), display_name: None, - detail: Vec::new(), + detail: Box::default(), condition: None, }), YamlData::Hash(h) => { @@ -185,14 +185,14 @@ impl TryFromYaml for File { "detail", YamlObjectType::PluginCleaningData, )?, - None => Vec::new(), + None => Box::default(), }; let condition = parse_condition(h, YamlObjectType::File)?; Ok(File { name: Filename::new(name.to_string()), - display_name: display_name.map(|(_, s)| s.to_string()), + display_name: display_name.map(|(_, s)| s.into()), detail, condition, }) diff --git a/src/metadata/group.rs b/src/metadata/group.rs index 00c20a0c..cbbb235c 100644 --- a/src/metadata/group.rs +++ b/src/metadata/group.rs @@ -11,9 +11,9 @@ use super::{ /// Represents a group to which plugin metadata objects can belong. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct Group { - name: String, - description: Option, - after_groups: Vec, + name: Box, + description: Option>, + after_groups: Box<[String]>, } impl Group { @@ -21,7 +21,7 @@ impl Group { #[must_use] pub fn new(name: String) -> Self { Self { - name, + name: name.into_boxed_str(), ..Default::default() } } @@ -55,7 +55,7 @@ impl Group { /// Set a description for the group. pub fn set_description(&mut self, description: String) -> &mut Self { - self.description = Some(description); + self.description = Some(description.into_boxed_str()); self } @@ -66,7 +66,7 @@ impl Group { /// Set the names of the groups that this group loads after. pub fn set_after_groups(&mut self, after_groups: Vec) -> &mut Self { - self.after_groups = after_groups; + self.after_groups = after_groups.into_boxed_slice(); self } } @@ -77,7 +77,7 @@ impl std::default::Default for Group { #[must_use] fn default() -> Self { Self { - name: Group::DEFAULT_NAME.to_string(), + name: Group::DEFAULT_NAME.into(), description: Default::default(), after_groups: Default::default(), } @@ -96,8 +96,8 @@ impl TryFromYaml for Group { let after = get_strings_vec_value(hash, "after", YamlObjectType::Group)?; Ok(Group { - name: name.to_string(), - description: description.map(|d| d.1.to_string()), + name: name.into(), + description: description.map(|d| d.1.into()), after_groups: after.iter().map(|a| a.to_string()).collect(), }) } diff --git a/src/metadata/location.rs b/src/metadata/location.rs index dcbb88d4..67f0faf2 100644 --- a/src/metadata/location.rs +++ b/src/metadata/location.rs @@ -8,8 +8,8 @@ use super::{ /// Represents a URL at which the parent plugin can be found. #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct Location { - url: String, - name: Option, + url: Box, + name: Option>, } impl Location { @@ -17,7 +17,7 @@ impl Location { #[must_use] pub fn new(url: String) -> Self { Location { - url, + url: url.into_boxed_str(), ..Default::default() } } @@ -41,7 +41,7 @@ impl Location { /// Set a name for the URL, eg. the page or site name. pub fn set_name(&mut self, name: String) -> &mut Self { - self.name = Some(name); + self.name = Some(name.into_boxed_str()); self } } @@ -50,7 +50,7 @@ impl TryFromYaml for Location { fn try_from_yaml(value: &MarkedYaml) -> Result { match &value.data { YamlData::String(s) => Ok(Location { - url: s.clone(), + url: s.clone().into_boxed_str(), name: None, }), YamlData::Hash(h) => { @@ -68,8 +68,8 @@ impl TryFromYaml for Location { )?; Ok(Location { - url: link.to_string(), - name: Some(name.to_string()), + url: link.into(), + name: Some(name.into()), }) } _ => Err(ParseMetadataError::unexpected_type( diff --git a/src/metadata/message.rs b/src/metadata/message.rs index ddf65138..fd4de675 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -43,8 +43,8 @@ impl std::fmt::Display for MessageType { /// Represents a message's localised text content. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct MessageContent { - text: String, - language: String, + text: Box, + language: Box, } impl MessageContent { @@ -56,7 +56,7 @@ impl MessageContent { #[must_use] pub fn new(text: String) -> Self { MessageContent { - text, + text: text.into_boxed_str(), ..Default::default() } } @@ -80,7 +80,7 @@ impl MessageContent { /// Set the language code to the given value. pub fn set_language(&mut self, language: String) -> &mut Self { - self.language = language; + self.language = language.into_boxed_str(); self } } @@ -128,10 +128,10 @@ pub fn select_message_content<'a>( let mut english = None; for mc in content { - if mc.language == language { + if mc.language.as_ref() == language { return Some(mc); } else if matched.is_none() { - if language_code.is_some_and(|c| c == mc.language) { + if language_code.is_some_and(|c| c == mc.language.as_ref()) { matched = Some(mc); } else if language_code.is_none() { if let Some((content_language_code, _)) = mc.language.split_once('_') { @@ -141,7 +141,7 @@ pub fn select_message_content<'a>( } } - if mc.language == MessageContent::DEFAULT_LANGUAGE { + if mc.language.as_ref() == MessageContent::DEFAULT_LANGUAGE { english = Some(mc); } } @@ -161,8 +161,8 @@ pub fn select_message_content<'a>( #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct Message { message_type: MessageType, - content: Vec, - condition: Option, + content: Box<[MessageContent]>, + condition: Option>, } impl Message { @@ -172,7 +172,7 @@ impl Message { pub fn new(message_type: MessageType, content: String) -> Self { Self { message_type, - content: vec![MessageContent::new(content)], + content: Box::new([MessageContent::new(content)]), condition: None, } } @@ -188,7 +188,7 @@ impl Message { Ok(Self { message_type, - content, + content: content.into_boxed_slice(), condition: None, }) } @@ -217,7 +217,7 @@ impl Message { /// Set the condition string. pub fn set_condition(&mut self, condition: String) -> &mut Self { - self.condition = Some(condition); + self.condition = Some(condition.into_boxed_str()); self } } @@ -228,7 +228,7 @@ pub(crate) fn validate_message_contents( if contents.len() > 1 { let english_string_exists = contents .iter() - .any(|c| c.language == MessageContent::DEFAULT_LANGUAGE); + .any(|c| c.language.as_ref() == MessageContent::DEFAULT_LANGUAGE); if !english_string_exists { return Err(MultilingualMessageContentsError {}); @@ -249,8 +249,8 @@ impl TryFromYaml for MessageContent { get_required_string_value(value.span.start, hash, "lang", YamlObjectType::Message)?; Ok(MessageContent { - text: text.to_string(), - language: language.to_string(), + text: text.into(), + language: language.into(), }) } } @@ -259,15 +259,13 @@ pub(crate) fn parse_message_contents_yaml( value: &MarkedYaml, key: &'static str, parent_yaml_type: YamlObjectType, -) -> Result, ParseMetadataError> { - let mut contents = match &value.data { - YamlData::String(s) => { - vec![MessageContent::new(s.clone())] - } +) -> Result, ParseMetadataError> { + let contents = match &value.data { + YamlData::String(s) => Box::new([MessageContent::new(s.clone())]), YamlData::Array(a) => a .iter() .map(MessageContent::try_from_yaml) - .collect::, _>>()?, + .collect::, _>>()?, _ => { return Err(ParseMetadataError::unexpected_value_type( value.span.start, @@ -278,8 +276,6 @@ pub(crate) fn parse_message_contents_yaml( } }; - contents.shrink_to_fit(); - if validate_message_contents(&contents).is_err() { Err(ParseMetadataError::new( value.span.start, @@ -342,7 +338,7 @@ impl TryFromYaml for Message { }); if let Cow::Owned(text) = result { - mc.text = text; + mc.text = text.into_boxed_str(); } } @@ -356,7 +352,7 @@ impl TryFromYaml for Message { )); } - mc.text = mc.text.replace(&placeholder, sub); + mc.text = mc.text.replace(&placeholder, sub).into_boxed_str(); } if let Ok(Some(m)) = FMT_REGEX.find(&mc.text) { diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index ef09b09c..24392e4a 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -20,8 +20,8 @@ pub struct PluginCleaningData { itm_count: u32, deleted_reference_count: u32, deleted_navmesh_count: u32, - cleaning_utility: String, - detail: Vec, + cleaning_utility: Box, + detail: Box<[MessageContent]>, } impl PluginCleaningData { @@ -32,7 +32,7 @@ impl PluginCleaningData { pub fn new(crc: u32, cleaning_utility: String) -> Self { Self { crc, - cleaning_utility, + cleaning_utility: cleaning_utility.into_boxed_str(), ..Default::default() } } @@ -131,7 +131,7 @@ impl PluginCleaningData { detail: Vec, ) -> Result<&mut Self, MultilingualMessageContentsError> { validate_message_contents(&detail)?; - self.detail = detail; + self.detail = detail.into_boxed_slice(); Ok(self) } } @@ -166,7 +166,7 @@ impl TryFromYaml for PluginCleaningData { Some(n) => { parse_message_contents_yaml(n, "detail", YamlObjectType::PluginCleaningData)? } - None => Vec::new(), + None => Box::default(), }; Ok(PluginCleaningData { @@ -174,7 +174,7 @@ impl TryFromYaml for PluginCleaningData { itm_count: itm, deleted_reference_count: udr, deleted_navmesh_count: nav, - cleaning_utility: util.to_string(), + cleaning_utility: util.into(), detail, }) } diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index bdb48856..1368d583 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -24,15 +24,15 @@ pub(crate) const GHOST_FILE_EXTENSION: &str = ".ghost"; #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct PluginMetadata { name: PluginName, - group: Option, - load_after: Vec, - requirements: Vec, - incompatibilities: Vec, - messages: Vec, - tags: Vec, - dirty_info: Vec, - clean_info: Vec, - locations: Vec, + group: Option>, + load_after: Box<[File]>, + requirements: Box<[File]>, + incompatibilities: Box<[File]>, + messages: Box<[Message]>, + tags: Box<[Tag]>, + dirty_info: Box<[PluginCleaningData]>, + clean_info: Box<[PluginCleaningData]>, + locations: Box<[Location]>, } impl PluginMetadata { @@ -99,7 +99,7 @@ impl PluginMetadata { /// Set the plugin's group. pub fn set_group(&mut self, group: String) { - self.group = Some(group) + self.group = Some(group.into_boxed_str()) } /// Unsets the plugin's group, so that it is implicitly a member of the @@ -110,42 +110,42 @@ impl PluginMetadata { /// Get the plugins that the plugin must load after. pub fn set_load_after_files(&mut self, files: Vec) { - self.load_after = files; + self.load_after = files.into_boxed_slice(); } /// Get the files that the plugin requires to be installed. pub fn set_requirements(&mut self, files: Vec) { - self.requirements = files; + self.requirements = files.into_boxed_slice(); } /// Get the files that the plugin is incompatible with. pub fn set_incompatibilities(&mut self, files: Vec) { - self.incompatibilities = files; + self.incompatibilities = files.into_boxed_slice(); } /// Get the plugin's messages. pub fn set_messages(&mut self, messages: Vec) { - self.messages = messages; + self.messages = messages.into_boxed_slice(); } /// Get the plugin's Bash Tag suggestions. pub fn set_tags(&mut self, tags: Vec) { - self.tags = tags; + self.tags = tags.into_boxed_slice(); } /// Get the plugin's dirty plugin information. pub fn set_dirty_info(&mut self, dirty_info: Vec) { - self.dirty_info = dirty_info; + self.dirty_info = dirty_info.into_boxed_slice(); } /// Get the plugin's clean plugin information. pub fn set_clean_info(&mut self, clean_info: Vec) { - self.clean_info = clean_info; + self.clean_info = clean_info.into_boxed_slice(); } /// Get the locations at which this plugin can be found. pub fn set_locations(&mut self, locations: Vec) { - self.locations = locations; + self.locations = locations.into_boxed_slice(); } /// Merge metadata from the given [PluginMetadata] object into this object. @@ -162,14 +162,21 @@ impl PluginMetadata { self.group = plugin.group.clone(); } - merge_vecs(&mut self.load_after, &plugin.load_after); - merge_vecs(&mut self.requirements, &plugin.requirements); - merge_vecs(&mut self.incompatibilities, &plugin.incompatibilities); - merge_vecs(&mut self.tags, &plugin.tags); - self.messages.extend(plugin.messages.iter().cloned()); - merge_vecs(&mut self.dirty_info, &plugin.dirty_info); - merge_vecs(&mut self.clean_info, &plugin.clean_info); - merge_vecs(&mut self.locations, &plugin.locations); + merge_slices(&mut self.load_after, &plugin.load_after); + merge_slices(&mut self.requirements, &plugin.requirements); + merge_slices(&mut self.incompatibilities, &plugin.incompatibilities); + merge_slices(&mut self.tags, &plugin.tags); + + self.messages = self + .messages + .iter() + .chain(plugin.messages.iter()) + .cloned() + .collect(); + + merge_slices(&mut self.dirty_info, &plugin.dirty_info); + merge_slices(&mut self.clean_info, &plugin.clean_info); + merge_slices(&mut self.locations, &plugin.locations); } /// Check if no plugin metadata is set. @@ -214,13 +221,13 @@ impl PluginMetadata { #[derive(Clone, Debug, Default)] struct PluginName { - string: String, + string: Box, regex: Option, } impl PluginName { fn new(name: &str) -> Result> { - let name = trim_dot_ghost(name).to_string(); + let name: Box = trim_dot_ghost(name).into(); if is_regex_name(&name) { let non_capturing_name = replace_capturing_groups(&name); @@ -245,7 +252,7 @@ impl PluginName { logging::error!("Encountered an error while trying to match the regex {} to the string {}: {}", regex.as_str(), other_name, e); }).unwrap_or(false) } else { - unicase::eq(self.string.as_str(), other_name) + unicase::eq(self.string.as_ref(), other_name) } } @@ -307,13 +314,15 @@ fn is_regex_name(name: &str) -> bool { name.contains(|c| ":\\*?|".chars().any(|n| c == n)) } -fn merge_vecs(target: &mut Vec, source: &[T]) { - let initial_target_len = target.len(); +fn merge_slices(target: &mut Box<[T]>, source: &[T]) { + let mut vec = target.to_vec(); for element in source { - if !target[..initial_target_len].contains(element) { - target.push(element.clone()) + if !target.contains(element) { + vec.push(element.clone()) } } + + *target = vec.into_boxed_slice() } fn replace_capturing_groups(regex_string: &str) -> Cow<'_, str> { @@ -375,18 +384,18 @@ impl TryFromYaml for PluginMetadata { let group = get_string_value(hash, "group", YamlObjectType::PluginMetadata)?; - let load_after = get_vec(hash, "after")?; - let requirements = get_vec(hash, "req")?; - let incompatibilities = get_vec(hash, "inc")?; - let messages = get_vec(hash, "msg")?; - let tags = get_vec(hash, "tag")?; - let dirty_info = get_vec(hash, "dirty")?; - let clean_info = get_vec(hash, "clean")?; - let locations = get_vec(hash, "url")?; + let load_after = get_boxed_slice(hash, "after")?; + let requirements = get_boxed_slice(hash, "req")?; + let incompatibilities = get_boxed_slice(hash, "inc")?; + let messages = get_boxed_slice(hash, "msg")?; + let tags = get_boxed_slice(hash, "tag")?; + let dirty_info = get_boxed_slice(hash, "dirty")?; + let clean_info = get_boxed_slice(hash, "clean")?; + let locations = get_boxed_slice(hash, "url")?; Ok(PluginMetadata { name, - group: group.map(|g| g.1.to_string()), + group: group.map(|g| g.1.into()), load_after, requirements, incompatibilities, @@ -399,18 +408,14 @@ impl TryFromYaml for PluginMetadata { } } -fn get_vec( +fn get_boxed_slice( hash: &saphyr::AnnotatedHash, key: &'static str, -) -> Result, ParseMetadataError> { - let mut vec = get_as_slice(hash, key, YamlObjectType::PluginMetadata)? +) -> Result, ParseMetadataError> { + get_as_slice(hash, key, YamlObjectType::PluginMetadata)? .iter() .map(|e| T::try_from_yaml(e)) - .collect::, _>>()?; - - vec.shrink_to_fit(); - - Ok(vec) + .collect() } impl EmitYaml for PluginMetadata { diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index b9276c36..b3db1d13 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -19,9 +19,9 @@ pub enum TagSuggestion { /// Represents a Bash Tag suggestion for a plugin. #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct Tag { - name: String, + name: Box, suggestion: TagSuggestion, - condition: Option, + condition: Option>, } impl Tag { @@ -29,7 +29,7 @@ impl Tag { #[must_use] pub fn new(name: String, suggestion: TagSuggestion) -> Self { Self { - name, + name: name.into_boxed_str(), suggestion, condition: None, } @@ -59,7 +59,7 @@ impl Tag { /// Set the condition string. pub fn set_condition(&mut self, condition: String) -> &mut Self { - self.condition = Some(condition); + self.condition = Some(condition.into_boxed_str()); self } } @@ -97,11 +97,11 @@ impl TryFromYaml for Tag { } } -fn name_and_suggestion(value: &str) -> (String, TagSuggestion) { +fn name_and_suggestion(value: &str) -> (Box, TagSuggestion) { if let Some(name) = value.strip_prefix("-") { - (name.to_string(), TagSuggestion::Removal) + (name.into(), TagSuggestion::Removal) } else { - (value.to_string(), TagSuggestion::Addition) + (value.into(), TagSuggestion::Addition) } } diff --git a/src/metadata/yaml/emit.rs b/src/metadata/yaml/emit.rs index 33eb6266..01f94dd0 100644 --- a/src/metadata/yaml/emit.rs +++ b/src/metadata/yaml/emit.rs @@ -331,6 +331,12 @@ impl EmitYaml for Vec { } } +impl EmitYaml for Box<[T]> { + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + self.as_ref().emit_yaml(emitter); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/metadata/yaml/parse.rs b/src/metadata/yaml/parse.rs index 8b426887..9318cd15 100644 --- a/src/metadata/yaml/parse.rs +++ b/src/metadata/yaml/parse.rs @@ -110,7 +110,7 @@ pub fn get_strings_vec_value<'a>( ExpectedType::String, )), }) - .collect::, _>>(), + .collect(), None => Err(ParseMetadataError::unexpected_value_type( n.span.start, key, @@ -180,14 +180,14 @@ pub fn get_as_slice<'a>( pub fn parse_condition( hash: &saphyr::AnnotatedHash, yaml_type: YamlObjectType, -) -> Result, ParseMetadataError> { +) -> Result>, ParseMetadataError> { match get_string_value(hash, "condition", yaml_type)? { Some((marker, s)) => { let s = s.to_string(); if let Err(e) = Expression::from_str(&s) { return Err(ParseMetadataError::invalid_condition(marker, s, e)); } - Ok(Some(s)) + Ok(Some(s.into_boxed_str())) } None => Ok(None), } diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index d829450b..178f7619 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -48,8 +48,8 @@ pub struct Plugin { game_type: GameType, crc: Option, version: Option, - tags: Vec, - archive_paths: Vec, + tags: Box<[String]>, + archive_paths: Box<[PathBuf]>, archive_assets: BTreeMap>, } @@ -70,8 +70,8 @@ impl Plugin { }; let mut version = None; - let mut tags = Vec::new(); - let mut archive_paths = Vec::new(); + let mut tags = Box::default(); + let mut archive_paths = Box::default(); let mut archive_assets = BTreeMap::new(); let plugin = if game_type != GameType::OpenMW || !has_ascii_extension(plugin_path, "omwscripts") { @@ -79,11 +79,12 @@ impl Plugin { plugin.parse_file(parse_options)?; if let Some(description) = plugin.description()? { - tags = extract_bash_tags(&description); + tags = extract_bash_tags(&description).into_boxed_slice(); version = extract_version(&description)?; } - archive_paths = find_associated_archives(game_type, game_cache, plugin_path); + archive_paths = + find_associated_archives(game_type, game_cache, plugin_path).into_boxed_slice(); if load_scope == LoadScope::WholePlugin { archive_assets = assets_in_archives(&archive_paths); diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs index feba93b6..66bde9a0 100644 --- a/src/sorting/groups.rs +++ b/src/sorting/groups.rs @@ -21,7 +21,7 @@ use super::{ error::GroupsPathError, }; -pub type GroupsGraph = Graph; +pub type GroupsGraph = Graph, EdgeType>; pub fn build_groups_graph( masterlist_groups: &[Group], @@ -49,7 +49,7 @@ pub fn build_groups_graph( EdgeType::UserLoadAfter, )?; - if let Some(cycle) = find_cycle(&graph, |node| node.clone()) { + if let Some(cycle) = find_cycle(&graph, |node| node.clone().into_string()) { Err(CyclicInteractionError::new(cycle).into()) } else { Ok(graph) @@ -72,7 +72,7 @@ fn add_groups<'a>( for group in groups { let key = group.name(); if !group_nodes.contains_key(key) { - let node_index = graph.add_node(group.name().to_string()); + let node_index = graph.add_node(group.name().into()); group_nodes.insert(key, node_index); } } @@ -114,7 +114,7 @@ pub fn find_path( from_group_name: &str, to_group_name: &str, ) -> Result, GroupsPathError> { - let float_graph: Graph<&String, f32> = graph.map( + let float_graph: Graph<&Box, f32> = graph.map( |_, n| n, |_, e| { if *e == EdgeType::UserLoadAfter { @@ -132,7 +132,7 @@ pub fn find_path( let paths = bellman_ford(&float_graph, from_vertex).map_err(|_| PathfindingError::NegativeCycle)?; - let mut path = vec![Vertex::new(graph[to_vertex].clone())]; + let mut path = vec![Vertex::new(graph[to_vertex].clone().into_string())]; let mut current = to_vertex; while current != from_vertex { let preceding_vertex = match paths.predecessors.get(current.index()) { @@ -147,7 +147,10 @@ pub fn find_path( return Ok(Vec::new()); } _ => { - return Err(PathfindingError::PrecedingNodeNotFound(graph[current].clone()).into()); + return Err(PathfindingError::PrecedingNodeNotFound( + graph[current].clone().into_string(), + ) + .into()); } }; @@ -164,14 +167,15 @@ pub fn find_path( Some(e) => e, None => { return Err(PathfindingError::EdgeNotFound { - from_group: graph[*preceding_vertex].clone(), - to_group: graph[current].clone(), + from_group: graph[*preceding_vertex].clone().into_string(), + to_group: graph[current].clone().into_string(), } .into()); } }; - let vertex = Vertex::new(graph[*preceding_vertex].clone()).with_out_edge_type(graph[edge]); + let vertex = Vertex::new(graph[*preceding_vertex].clone().into_string()) + .with_out_edge_type(graph[edge]); path.push(vertex); current = *preceding_vertex; @@ -183,13 +187,15 @@ pub fn find_path( } fn find_node_by_weight( - graph: &Graph, + graph: &Graph, EdgeType>, weight: &str, ) -> Result { - match graph - .node_indices() - .find(|i| graph.node_weight(*i).map(|w| *w == weight).unwrap_or(false)) - { + match graph.node_indices().find(|i| { + graph + .node_weight(*i) + .map(|w| w.as_ref() == weight) + .unwrap_or(false) + }) { Some(n) => Ok(n), None => { logging::error!("Can't find group with name {}", weight); @@ -267,7 +273,7 @@ impl<'a> DfsVisitor<'a> for GroupsPathLengthVisitor { pub fn get_default_group_node(graph: &GroupsGraph) -> Result { graph .node_indices() - .find(|n| graph[*n] == Group::DEFAULT_NAME) + .find(|n| graph[*n].as_ref() == Group::DEFAULT_NAME) .ok_or_else(|| UndefinedGroupError::new(Group::DEFAULT_NAME.to_string())) } diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 9b5a190b..57c393d7 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -32,12 +32,12 @@ pub struct PluginSortingData<'a, T: SortingPlugin> { load_order_index: usize, - pub(super) group: String, + pub(super) group: Box, group_is_user_metadata: bool, - pub(super) masterlist_load_after: Vec, - pub(super) user_load_after: Vec, - pub(super) masterlist_req: Vec, - pub(super) user_req: Vec, + pub(super) masterlist_load_after: Box<[String]>, + pub(super) user_load_after: Box<[String]>, + pub(super) masterlist_req: Box<[String]>, + pub(super) user_req: Box<[String]>, } impl<'a, T: SortingPlugin> PluginSortingData<'a, T> { @@ -58,7 +58,7 @@ impl<'a, T: SortingPlugin> PluginSortingData<'a, T> { .and_then(|m| m.group()) .or_else(|| masterlist_metadata.and_then(|m| m.group())) .unwrap_or(Group::DEFAULT_NAME) - .to_string(), + .into(), group_is_user_metadata: user_metadata.and_then(|m| m.group()).is_some(), masterlist_load_after: masterlist_metadata .map(|m| to_filenames(m.load_after_files())) @@ -141,7 +141,7 @@ impl SortingPlugin for Plugin { } } -fn to_filenames(files: &[File]) -> Vec { +fn to_filenames(files: &[File]) -> Box<[String]> { files .iter() .map(|f| f.name().as_str().to_string()) @@ -987,8 +987,8 @@ struct PathCacher<'a> { fn get_plugins_in_groups( graph: &InnerPluginsGraph, -) -> HashMap> { - let mut plugins_in_groups: HashMap> = HashMap::default(); +) -> HashMap, Vec> { + let mut plugins_in_groups: HashMap, Vec> = HashMap::default(); for node in graph.node_indices() { let group_name = graph[node].group.clone(); @@ -1047,7 +1047,7 @@ type GroupNodeIndex = NodeIndex; struct GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> { plugins_graph: &'a mut PluginsGraph<'b, T>, groups_graph: &'e GroupsGraph, - groups_plugins: &'c HashMap>, + groups_plugins: &'c HashMap, Vec>, finished_group_vertices: &'d mut HashSet, group_node_to_ignore_as_source: Option, edge_stack: Vec<(EdgeReference<'e, EdgeType>, &'c [PluginNodeIndex])>, @@ -1058,7 +1058,7 @@ impl<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, fn new( plugins_graph: &'a mut PluginsGraph<'b, T>, groups_graph: &'e GroupsGraph, - groups_plugins: &'c HashMap>, + groups_plugins: &'c HashMap, Vec>, finished_group_vertices: &'d mut HashSet, group_node_to_ignore_as_source: Option, ) -> Self { @@ -1080,7 +1080,7 @@ impl<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, fn find_plugins_in_group(&self, node_index: GroupNodeIndex) -> &'c [PluginNodeIndex] { self.groups_plugins - .get(&self.groups_graph[node_index]) + .get(self.groups_graph[node_index].as_ref()) .map(|v| v.as_slice()) .unwrap_or_default() } @@ -3269,7 +3269,7 @@ mod tests { let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); let mut a = fixture.sorting_data(PLUGIN_A); - a.masterlist_load_after = vec![PLUGIN_B.into()]; + a.masterlist_load_after = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; @@ -3285,7 +3285,7 @@ mod tests { let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); let mut a = fixture.sorting_data(PLUGIN_A); - a.masterlist_req = vec![PLUGIN_B.into()]; + a.masterlist_req = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; @@ -3381,7 +3381,7 @@ mod tests { fixture.get_plugin_mut(PLUGIN_A).is_master = true; let mut a = fixture.sorting_data(PLUGIN_A); - a.masterlist_load_after = vec![PLUGIN_B.into()]; + a.masterlist_load_after = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; @@ -3407,7 +3407,7 @@ mod tests { fixture.get_plugin_mut(PLUGIN_A).is_master = true; let mut a = fixture.sorting_data(PLUGIN_A); - a.user_load_after = vec![PLUGIN_B.into()]; + a.user_load_after = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; @@ -3433,7 +3433,7 @@ mod tests { fixture.get_plugin_mut(PLUGIN_A).is_master = true; let mut a = fixture.sorting_data(PLUGIN_A); - a.masterlist_req = vec![PLUGIN_B.into()]; + a.masterlist_req = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; @@ -3459,7 +3459,7 @@ mod tests { fixture.get_plugin_mut(PLUGIN_A).is_master = true; let mut a = fixture.sorting_data(PLUGIN_A); - a.user_req = vec![PLUGIN_B.into()]; + a.user_req = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; @@ -3560,7 +3560,7 @@ mod tests { b.is_blueprint_plugin = true; let mut a = fixture.sorting_data(PLUGIN_A); - a.masterlist_load_after = vec![PLUGIN_B.into()]; + a.masterlist_load_after = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; @@ -3590,7 +3590,7 @@ mod tests { b.is_blueprint_plugin = true; let mut a = fixture.sorting_data(PLUGIN_A); - a.masterlist_load_after = vec![PLUGIN_B.into()]; + a.masterlist_load_after = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; @@ -3621,7 +3621,7 @@ mod tests { b.is_blueprint_plugin = true; let mut a = fixture.sorting_data(PLUGIN_A); - a.user_load_after = vec![PLUGIN_B.into()]; + a.user_load_after = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; @@ -3650,7 +3650,7 @@ mod tests { b.is_blueprint_plugin = true; let mut a = fixture.sorting_data(PLUGIN_A); - a.user_load_after = vec![PLUGIN_B.into()]; + a.user_load_after = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; @@ -3681,7 +3681,7 @@ mod tests { b.is_blueprint_plugin = true; let mut a = fixture.sorting_data(PLUGIN_A); - a.masterlist_req = vec![PLUGIN_B.into()]; + a.masterlist_req = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; @@ -3711,7 +3711,7 @@ mod tests { b.is_blueprint_plugin = true; let mut a = fixture.sorting_data(PLUGIN_A); - a.masterlist_req = vec![PLUGIN_B.into()]; + a.masterlist_req = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; @@ -3742,7 +3742,7 @@ mod tests { b.is_blueprint_plugin = true; let mut a = fixture.sorting_data(PLUGIN_A); - a.user_req = vec![PLUGIN_B.into()]; + a.user_req = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; @@ -3771,7 +3771,7 @@ mod tests { b.is_blueprint_plugin = true; let mut a = fixture.sorting_data(PLUGIN_A); - a.user_req = vec![PLUGIN_B.into()]; + a.user_req = Box::new([PLUGIN_B.into()]); let data = vec![a, fixture.sorting_data(PLUGIN_B)]; diff --git a/src/sorting/validate.rs b/src/sorting/validate.rs index e2b193f1..f8caa7d6 100644 --- a/src/sorting/validate.rs +++ b/src/sorting/validate.rs @@ -16,14 +16,14 @@ pub fn validate_plugin_groups( plugins_sorting_data: &[PluginSortingData<'_, T>], groups_graph: &GroupsGraph, ) -> Result<(), UndefinedGroupError> { - let group_names: HashSet<&String> = groups_graph + let group_names: HashSet<&str> = groups_graph .node_indices() - .map(|i| &groups_graph[i]) + .map(|i| groups_graph[i].as_ref()) .collect(); for plugin in plugins_sorting_data { - if !group_names.contains(&plugin.group) { - return Err(UndefinedGroupError::new(plugin.group.clone())); + if !group_names.contains(plugin.group.as_ref()) { + return Err(UndefinedGroupError::new(plugin.group.clone().into_string())); } } From 501efda9e308021a0d7eaf6f8ec9cb7473956b28 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 19 Apr 2025 19:34:23 +0100 Subject: [PATCH 078/206] Update saphyr to v0.0.4 --- Cargo.lock | 73 +++++++++++----------------- Cargo.toml | 4 +- src/metadata/error.rs | 56 +++++++++++---------- src/metadata/file.rs | 8 +-- src/metadata/group.rs | 10 ++-- src/metadata/location.rs | 8 +-- src/metadata/message.rs | 24 ++++----- src/metadata/metadata_document.rs | 4 +- src/metadata/mod.rs | 2 + src/metadata/plugin_cleaning_data.rs | 16 +++--- src/metadata/plugin_metadata.rs | 28 +++++------ src/metadata/tag.rs | 6 +-- src/metadata/yaml/merge.rs | 56 ++++++++++----------- src/metadata/yaml/mod.rs | 4 +- src/metadata/yaml/parse.rs | 63 ++++++++++++------------ 15 files changed, 176 insertions(+), 186 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0aa6c2e4..0d604acd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ahash" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy 0.7.35", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -498,10 +486,6 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -516,11 +500,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.8.4" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.15.2", ] [[package]] @@ -724,6 +708,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -736,6 +729,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2c1f9f56e534ac6a9b8a4600bdf0f530fb393b5f393e7b4d03489c3cf0c3f01" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -846,7 +848,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.24", + "zerocopy", ] [[package]] @@ -1141,21 +1143,22 @@ checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" [[package]] name = "saphyr" -version = "0.0.3" +version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31513a748cdf8fb8d1b64dcc14fdd997fa02f2431671c7ce34efbea5e7eeea" +checksum = "9235237b78e93229dfceefd307ca041d6e1752ac7eaaf392531c3778fa4810a1" dependencies = [ "arraydeque", "encoding_rs", "hashlink", + "ordered-float", "saphyr-parser", ] [[package]] name = "saphyr-parser" -version = "0.0.3" +version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "123f0a6da68f3072c7c761450276d1d444cb391c8be182a757cd26cf684cb77f" +checksum = "2f5c8d67f5937b6b8a42c0d6038d90571ca3d8a99cc4adae69e490ba0acc6bf1" dependencies = [ "arraydeque", "hashlink", @@ -1616,33 +1619,13 @@ dependencies = [ "bitflags", ] -[[package]] -name = "zerocopy" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" -dependencies = [ - "zerocopy-derive 0.7.35", -] - [[package]] name = "zerocopy" version = "0.8.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" dependencies = [ - "zerocopy-derive 0.8.24", -] - -[[package]] -name = "zerocopy-derive" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "zerocopy-derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7a754321..99d5de0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,8 @@ log = { version = "0.4.26", features = ["std"] } petgraph = "0.8.1" rayon = "1.10.0" rustc-hash = "2.1.1" -saphyr = "0.0.3" -saphyr-parser = "0.0.3" +saphyr = "0.0.4" +saphyr-parser = "0.0.4" unicase = "2.8.1" esplugin.workspace = true diff --git a/src/metadata/error.rs b/src/metadata/error.rs index d74cbfe8..e79a0f77 100644 --- a/src/metadata/error.rs +++ b/src/metadata/error.rs @@ -6,7 +6,7 @@ use saphyr::Marker; use crate::metadata::MessageContent; -use super::yaml::{YamlObjectType, to_yaml}; +use super::yaml::{YamlObjectType, to_unmarked_yaml}; /// Represents an error that occurred when validating a collection of /// [MessageContent] objects. @@ -328,43 +328,47 @@ impl From for MetadataDocumentParsingError { } } -#[derive(Clone, Debug, Eq, PartialEq, Hash)] +#[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct YamlMergeKeyError { - value: Box, + start: Marker, + yaml: String, } impl YamlMergeKeyError { pub(super) fn new(value: saphyr::MarkedYaml) -> Self { + let mut yaml = String::new(); + + let unmarked_yaml = to_unmarked_yaml(&value); + + if saphyr::YamlEmitter::new(&mut yaml) + .dump(&unmarked_yaml) + .is_ok() + { + // The emitter starts the dumped YAML with ---\n, so strip that. + let index = 4; + if yaml.is_char_boundary(index) { + yaml = yaml.split_off(index); + } + } else { + yaml = format!("{:?}", yaml); + } + YamlMergeKeyError { - value: Box::new(value), + start: value.span.start, + yaml, } } } impl std::fmt::Display for YamlMergeKeyError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut output = String::new(); - - let yaml = to_yaml(&self.value); - - if saphyr::YamlEmitter::new(&mut output).dump(&yaml).is_ok() { - // The emitter starts the dumped YAML with ---\n, so strip that. - write!( - f, - "invalid YAML merge key value at line {} column {}: {}", - self.value.span.start.line(), - self.value.span.start.col(), - output.get(4..).unwrap_or_default() - ) - } else { - write!( - f, - "invalid YAML merge key value at line {} column {}: {:?}", - self.value.span.start.line(), - self.value.span.start.col(), - self.value - ) - } + write!( + f, + "invalid YAML merge key value at line {} column {}: {}", + self.start.line(), + self.start.col(), + self.yaml + ) } } diff --git a/src/metadata/file.rs b/src/metadata/file.rs index 676b3701..06aa7137 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -1,4 +1,4 @@ -use saphyr::{MarkedYaml, YamlData}; +use saphyr::{MarkedYaml, Scalar, YamlData}; use unicase::UniCase; use super::{ @@ -167,13 +167,13 @@ impl std::fmt::Display for Filename { impl TryFromYaml for File { fn try_from_yaml(value: &MarkedYaml) -> Result { match &value.data { - YamlData::String(s) => Ok(File { - name: Filename::new(s.clone()), + YamlData::Value(Scalar::String(s)) => Ok(File { + name: Filename::new(s.to_string()), display_name: None, detail: Box::default(), condition: None, }), - YamlData::Hash(h) => { + YamlData::Mapping(h) => { let name = get_required_string_value(value.span.start, h, "name", YamlObjectType::File)?; diff --git a/src/metadata/group.rs b/src/metadata/group.rs index cbbb235c..89483b3f 100644 --- a/src/metadata/group.rs +++ b/src/metadata/group.rs @@ -3,7 +3,7 @@ use saphyr::MarkedYaml; use super::{ error::ParseMetadataError, yaml::{ - EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, get_as_hash, get_required_string_value, + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, as_mapping, get_required_string_value, get_string_value, get_strings_vec_value, }, }; @@ -86,14 +86,14 @@ impl std::default::Default for Group { impl TryFromYaml for Group { fn try_from_yaml(value: &MarkedYaml) -> Result { - let hash = get_as_hash(value, YamlObjectType::Group)?; + let mapping = as_mapping(value, YamlObjectType::Group)?; let name = - get_required_string_value(value.span.start, hash, "name", YamlObjectType::Group)?; + get_required_string_value(value.span.start, mapping, "name", YamlObjectType::Group)?; - let description = get_string_value(hash, "description", YamlObjectType::Group)?; + let description = get_string_value(mapping, "description", YamlObjectType::Group)?; - let after = get_strings_vec_value(hash, "after", YamlObjectType::Group)?; + let after = get_strings_vec_value(mapping, "after", YamlObjectType::Group)?; Ok(Group { name: name.into(), diff --git a/src/metadata/location.rs b/src/metadata/location.rs index 67f0faf2..606f2eda 100644 --- a/src/metadata/location.rs +++ b/src/metadata/location.rs @@ -1,4 +1,4 @@ -use saphyr::{MarkedYaml, YamlData}; +use saphyr::{MarkedYaml, Scalar, YamlData}; use super::{ error::{ExpectedType, ParseMetadataError}, @@ -49,11 +49,11 @@ impl Location { impl TryFromYaml for Location { fn try_from_yaml(value: &MarkedYaml) -> Result { match &value.data { - YamlData::String(s) => Ok(Location { - url: s.clone().into_boxed_str(), + YamlData::Value(Scalar::String(s)) => Ok(Location { + url: s.to_string().into_boxed_str(), name: None, }), - YamlData::Hash(h) => { + YamlData::Mapping(h) => { let link = get_required_string_value( value.span.start, h, diff --git a/src/metadata/message.rs b/src/metadata/message.rs index fd4de675..8ed1d615 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -1,7 +1,7 @@ use std::{borrow::Cow, sync::LazyLock}; use fancy_regex::{Captures, Regex}; -use saphyr::{MarkedYaml, YamlData}; +use saphyr::{MarkedYaml, Scalar, YamlData}; use crate::logging; @@ -11,7 +11,7 @@ use super::{ ParseMetadataError, }, yaml::{ - EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, as_string_node, get_as_hash, + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, as_mapping, as_string_node, get_required_string_value, get_strings_vec_value, parse_condition, }, }; @@ -240,13 +240,13 @@ pub(crate) fn validate_message_contents( impl TryFromYaml for MessageContent { fn try_from_yaml(value: &MarkedYaml) -> Result { - let hash = get_as_hash(value, YamlObjectType::MessageContent)?; + let mapping = as_mapping(value, YamlObjectType::MessageContent)?; let text = - get_required_string_value(value.span.start, hash, "text", YamlObjectType::Message)?; + get_required_string_value(value.span.start, mapping, "text", YamlObjectType::Message)?; let language = - get_required_string_value(value.span.start, hash, "lang", YamlObjectType::Message)?; + get_required_string_value(value.span.start, mapping, "lang", YamlObjectType::Message)?; Ok(MessageContent { text: text.into(), @@ -261,8 +261,8 @@ pub(crate) fn parse_message_contents_yaml( parent_yaml_type: YamlObjectType, ) -> Result, ParseMetadataError> { let contents = match &value.data { - YamlData::String(s) => Box::new([MessageContent::new(s.clone())]), - YamlData::Array(a) => a + YamlData::Value(Scalar::String(s)) => Box::new([MessageContent::new(s.to_string())]), + YamlData::Sequence(a) => a .iter() .map(MessageContent::try_from_yaml) .collect::, _>>()?, @@ -288,17 +288,17 @@ pub(crate) fn parse_message_contents_yaml( impl TryFromYaml for Message { fn try_from_yaml(value: &MarkedYaml) -> Result { - let hash = get_as_hash(value, YamlObjectType::Message)?; + let mapping = as_mapping(value, YamlObjectType::Message)?; let message_type = - get_required_string_value(value.span.start, hash, "type", YamlObjectType::Message)?; + get_required_string_value(value.span.start, mapping, "type", YamlObjectType::Message)?; let message_type = match message_type { "warn" => MessageType::Warn, "error" => MessageType::Error, _ => MessageType::Say, }; - let mut content = match hash.get(&as_string_node("content")) { + let mut content = match mapping.get(&as_string_node("content")) { Some(n) => parse_message_contents_yaml(n, "content", YamlObjectType::Message)?, None => { return Err(ParseMetadataError::missing_key( @@ -309,7 +309,7 @@ impl TryFromYaml for Message { } }; - let subs = get_strings_vec_value(hash, "subs", YamlObjectType::Message)?; + let subs = get_strings_vec_value(mapping, "subs", YamlObjectType::Message)?; if !subs.is_empty() { static FMT_REGEX: LazyLock = LazyLock::new(|| { @@ -364,7 +364,7 @@ impl TryFromYaml for Message { } } - let condition = parse_condition(hash, YamlObjectType::Message)?; + let condition = parse_condition(mapping, YamlObjectType::Message)?; Ok(Message { message_type, diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index d0aff4b9..d2c6b451 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -3,7 +3,7 @@ use std::{ path::Path, }; -use saphyr::{MarkedYaml, YamlData}; +use saphyr::{LoadableYamlNode, MarkedYaml, YamlData}; use crate::logging; @@ -105,7 +105,7 @@ impl MetadataDocument { let doc = process_merge_keys(doc)?; let doc = match doc.data { - YamlData::Hash(h) => h, + YamlData::Mapping(h) => h, _ => { return Err(ParseMetadataError::unexpected_type( doc.span.start, diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index 0bf39d25..4446cd66 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -28,6 +28,8 @@ fn emit(metadata: &T) -> String { #[cfg(test)] fn parse(yaml: &str) -> saphyr::MarkedYaml { + use saphyr::LoadableYamlNode; + saphyr::MarkedYaml::load_from_str(yaml) .unwrap() .pop() diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index 24392e4a..6c090189 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -7,7 +7,7 @@ use super::{ validate_message_contents, }, yaml::{ - EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, as_string_node, get_as_hash, + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, as_mapping, as_string_node, get_required_string_value, get_u32_value, }, }; @@ -138,9 +138,9 @@ impl PluginCleaningData { impl TryFromYaml for PluginCleaningData { fn try_from_yaml(value: &MarkedYaml) -> Result { - let hash = get_as_hash(value, YamlObjectType::PluginCleaningData)?; + let mapping = as_mapping(value, YamlObjectType::PluginCleaningData)?; - let crc = match get_u32_value(hash, "crc", YamlObjectType::PluginCleaningData)? { + let crc = match get_u32_value(mapping, "crc", YamlObjectType::PluginCleaningData)? { Some(n) => n, None => { return Err(ParseMetadataError::missing_key( @@ -153,16 +153,16 @@ impl TryFromYaml for PluginCleaningData { let util = get_required_string_value( value.span.start, - hash, + mapping, "util", YamlObjectType::PluginCleaningData, )?; - let itm = get_u32_value(hash, "itm", YamlObjectType::PluginCleaningData)?.unwrap_or(0); - let udr = get_u32_value(hash, "udr", YamlObjectType::PluginCleaningData)?.unwrap_or(0); - let nav = get_u32_value(hash, "nav", YamlObjectType::PluginCleaningData)?.unwrap_or(0); + let itm = get_u32_value(mapping, "itm", YamlObjectType::PluginCleaningData)?.unwrap_or(0); + let udr = get_u32_value(mapping, "udr", YamlObjectType::PluginCleaningData)?.unwrap_or(0); + let nav = get_u32_value(mapping, "nav", YamlObjectType::PluginCleaningData)?.unwrap_or(0); - let detail = match hash.get(&as_string_node("detail")) { + let detail = match mapping.get(&as_string_node("detail")) { Some(n) => { parse_message_contents_yaml(n, "detail", YamlObjectType::PluginCleaningData)? } diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 1368d583..7a991eb3 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -13,7 +13,7 @@ use super::{ plugin_cleaning_data::PluginCleaningData, tag::Tag, yaml::{ - EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, get_as_hash, get_as_slice, + EmitYaml, TryFromYaml, YamlEmitter, YamlObjectType, as_mapping, get_as_slice, get_required_string_value, get_string_value, }, }; @@ -364,11 +364,11 @@ fn replace_capturing_groups(regex_string: &str) -> Cow<'_, str> { impl TryFromYaml for PluginMetadata { fn try_from_yaml(value: &MarkedYaml) -> Result { - let hash = get_as_hash(value, YamlObjectType::PluginMetadata)?; + let mapping = as_mapping(value, YamlObjectType::PluginMetadata)?; let name = get_required_string_value( value.span.start, - hash, + mapping, "name", YamlObjectType::PluginMetadata, )?; @@ -382,16 +382,16 @@ impl TryFromYaml for PluginMetadata { } }; - let group = get_string_value(hash, "group", YamlObjectType::PluginMetadata)?; + let group = get_string_value(mapping, "group", YamlObjectType::PluginMetadata)?; - let load_after = get_boxed_slice(hash, "after")?; - let requirements = get_boxed_slice(hash, "req")?; - let incompatibilities = get_boxed_slice(hash, "inc")?; - let messages = get_boxed_slice(hash, "msg")?; - let tags = get_boxed_slice(hash, "tag")?; - let dirty_info = get_boxed_slice(hash, "dirty")?; - let clean_info = get_boxed_slice(hash, "clean")?; - let locations = get_boxed_slice(hash, "url")?; + let load_after = get_boxed_slice(mapping, "after")?; + let requirements = get_boxed_slice(mapping, "req")?; + let incompatibilities = get_boxed_slice(mapping, "inc")?; + let messages = get_boxed_slice(mapping, "msg")?; + let tags = get_boxed_slice(mapping, "tag")?; + let dirty_info = get_boxed_slice(mapping, "dirty")?; + let clean_info = get_boxed_slice(mapping, "clean")?; + let locations = get_boxed_slice(mapping, "url")?; Ok(PluginMetadata { name, @@ -409,10 +409,10 @@ impl TryFromYaml for PluginMetadata { } fn get_boxed_slice( - hash: &saphyr::AnnotatedHash, + mapping: &saphyr::AnnotatedMapping, key: &'static str, ) -> Result, ParseMetadataError> { - get_as_slice(hash, key, YamlObjectType::PluginMetadata)? + get_as_slice(mapping, key, YamlObjectType::PluginMetadata)? .iter() .map(|e| T::try_from_yaml(e)) .collect() diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index b3db1d13..92c8965b 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -1,4 +1,4 @@ -use saphyr::{MarkedYaml, YamlData}; +use saphyr::{MarkedYaml, Scalar, YamlData}; use super::{ error::{ExpectedType, ParseMetadataError}, @@ -67,7 +67,7 @@ impl Tag { impl TryFromYaml for Tag { fn try_from_yaml(value: &MarkedYaml) -> Result { match &value.data { - YamlData::String(s) => { + YamlData::Value(Scalar::String(s)) => { let (name, suggestion) = name_and_suggestion(s); Ok(Tag { name, @@ -75,7 +75,7 @@ impl TryFromYaml for Tag { condition: None, }) } - YamlData::Hash(h) => { + YamlData::Mapping(h) => { let name = get_required_string_value(value.span.start, h, "name", YamlObjectType::Tag)?; diff --git a/src/metadata/yaml/merge.rs b/src/metadata/yaml/merge.rs index 24b4dd02..cbfdca4e 100644 --- a/src/metadata/yaml/merge.rs +++ b/src/metadata/yaml/merge.rs @@ -10,12 +10,12 @@ static MERGE_KEY: LazyLock = LazyLock::new(|| as_string_node("<<")); pub fn process_merge_keys(mut yaml: MarkedYaml) -> Result { match yaml.data { - YamlData::Array(a) => { - yaml.data = merge_array_elements(a).map(YamlData::Array)?; + YamlData::Sequence(a) => { + yaml.data = merge_array_elements(a).map(YamlData::Sequence)?; Ok(yaml) } - YamlData::Hash(h) => { - yaml.data = merge_hash_keys(h).map(YamlData::Hash)?; + YamlData::Mapping(h) => { + yaml.data = merge_mapping_keys(h).map(YamlData::Mapping)?; Ok(yaml) } _ => Ok(yaml), @@ -23,15 +23,15 @@ pub fn process_merge_keys(mut yaml: MarkedYaml) -> Result, -) -> Result, YamlMergeKeyError> { + array: saphyr::AnnotatedSequence, +) -> Result, YamlMergeKeyError> { array.into_iter().map(process_merge_keys).collect() } -fn merge_hash_keys( - hash: saphyr::AnnotatedHash, -) -> Result, YamlMergeKeyError> { - let mut hash: saphyr::AnnotatedHash = hash +fn merge_mapping_keys<'a, 'b>( + mapping: saphyr::AnnotatedMapping<'a, MarkedYaml<'b>>, +) -> Result>, YamlMergeKeyError> { + let mut mapping: saphyr::AnnotatedMapping = mapping .into_iter() .map(|(key, value)| { process_merge_keys(key) @@ -39,38 +39,38 @@ fn merge_hash_keys( }) .collect::>()?; - if let Some(value) = hash.remove(&MERGE_KEY) { - merge_into_hash(hash, value) + if let Some(value) = mapping.remove(&MERGE_KEY) { + merge_into_mapping(mapping, value) } else { - Ok(hash) + Ok(mapping) } } -fn merge_into_hash( - hash: saphyr::AnnotatedHash, - value: MarkedYaml, -) -> Result, YamlMergeKeyError> { +fn merge_into_mapping<'a, 'b>( + mapping: saphyr::AnnotatedMapping<'a, MarkedYaml<'b>>, + value: MarkedYaml<'b>, +) -> Result>, YamlMergeKeyError> { match value.data { - YamlData::Array(a) => a.into_iter().try_fold(hash, |acc, e| { - if let YamlData::Hash(h) = e.data { - Ok(merge_hashes(acc, h)) + YamlData::Sequence(a) => a.into_iter().try_fold(mapping, |acc, e| { + if let YamlData::Mapping(h) = e.data { + Ok(merge_mappings(acc, h)) } else { Err(YamlMergeKeyError::new(e)) } }), - YamlData::Hash(h) => Ok(merge_hashes(hash, h)), + YamlData::Mapping(h) => Ok(merge_mappings(mapping, h)), _ => Err(YamlMergeKeyError::new(value)), } } -fn merge_hashes( - mut hash1: saphyr::AnnotatedHash, - hash2: saphyr::AnnotatedHash, -) -> saphyr::AnnotatedHash { - for (key, value) in hash2 { - hash1.entry(key).or_insert(value); +fn merge_mappings<'a, 'b>( + mut mapping1: saphyr::AnnotatedMapping<'a, MarkedYaml<'b>>, + mapping2: saphyr::AnnotatedMapping>, +) -> saphyr::AnnotatedMapping<'a, MarkedYaml<'b>> { + for (key, value) in mapping2 { + mapping1.entry(key).or_insert(value); } - hash1 + mapping1 } #[cfg(test)] diff --git a/src/metadata/yaml/mod.rs b/src/metadata/yaml/mod.rs index 90d1040e..14fc1db5 100644 --- a/src/metadata/yaml/mod.rs +++ b/src/metadata/yaml/mod.rs @@ -5,7 +5,7 @@ mod parse; pub use emit::{EmitYaml, YamlEmitter}; pub use merge::process_merge_keys; pub use parse::{ - TryFromYaml, YamlObjectType, as_string_node, get_as_hash, get_as_slice, + TryFromYaml, YamlObjectType, as_mapping, as_string_node, get_as_slice, get_required_string_value, get_string_value, get_strings_vec_value, get_u32_value, - parse_condition, to_yaml, + parse_condition, to_unmarked_yaml, }; diff --git a/src/metadata/yaml/parse.rs b/src/metadata/yaml/parse.rs index 9318cd15..e8e5b564 100644 --- a/src/metadata/yaml/parse.rs +++ b/src/metadata/yaml/parse.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use loot_condition_interpreter::Expression; -use saphyr::{AnnotatedHash, MarkedYaml, Marker, Yaml, YamlData}; +use saphyr::{AnnotatedMapping, MarkedYaml, Marker, Scalar, Yaml, YamlData}; use super::super::error::{ExpectedType, MetadataParsingErrorReason, ParseMetadataError}; @@ -36,37 +36,38 @@ impl std::fmt::Display for YamlObjectType { } } -pub fn to_yaml(yaml: &MarkedYaml) -> Yaml { +pub fn to_unmarked_yaml<'a>(yaml: &MarkedYaml<'a>) -> Yaml<'a> { match &yaml.data { - saphyr::YamlData::Real(v) => Yaml::Real(v.clone()), - saphyr::YamlData::Integer(v) => Yaml::Integer(*v), - saphyr::YamlData::String(v) => Yaml::String(v.clone()), - saphyr::YamlData::Boolean(v) => Yaml::Boolean(*v), - saphyr::YamlData::Array(v) => Yaml::Array(v.iter().map(to_yaml).collect()), - saphyr::YamlData::Hash(v) => Yaml::Hash( + YamlData::Value(Scalar::FloatingPoint(v)) => Yaml::Value(Scalar::FloatingPoint(*v)), + YamlData::Value(Scalar::Integer(v)) => Yaml::Value(Scalar::Integer(*v)), + YamlData::Value(Scalar::String(v)) => Yaml::Value(Scalar::String(v.clone())), + YamlData::Value(Scalar::Boolean(v)) => Yaml::Value(Scalar::Boolean(*v)), + YamlData::Value(Scalar::Null) => Yaml::Value(Scalar::Null), + YamlData::Sequence(v) => Yaml::Sequence(v.iter().map(to_unmarked_yaml).collect()), + YamlData::Mapping(v) => Yaml::Mapping( v.iter() - .map(|(key, value)| (to_yaml(key), to_yaml(value))) + .map(|(key, value)| (to_unmarked_yaml(key), to_unmarked_yaml(value))) .collect(), ), - saphyr::YamlData::Alias(v) => Yaml::Alias(*v), - saphyr::YamlData::Null => Yaml::Null, - saphyr::YamlData::BadValue => Yaml::BadValue, + YamlData::Alias(v) => Yaml::Alias(*v), + YamlData::BadValue => Yaml::BadValue, + YamlData::Representation(v, s, t) => Yaml::Representation(v.clone(), *s, t.clone()), } } pub fn as_string_node(value: &str) -> MarkedYaml { MarkedYaml { span: saphyr_parser::Span::default(), - data: YamlData::String(value.into()), + data: YamlData::Value(Scalar::String(value.into())), } } pub fn get_string_value<'a>( - hash: &'a AnnotatedHash, + mapping: &'a AnnotatedMapping, key: &'static str, yaml_type: YamlObjectType, ) -> Result, ParseMetadataError> { - match hash.get(&as_string_node(key)) { + match mapping.get(&as_string_node(key)) { Some(n) => match n.data.as_str() { Some(s) => Ok(Some((n.span.start, s))), None => Err(ParseMetadataError::unexpected_value_type( @@ -82,22 +83,22 @@ pub fn get_string_value<'a>( pub fn get_required_string_value<'a>( marker: Marker, - hash: &'a AnnotatedHash, + mapping: &'a AnnotatedMapping, key: &'static str, yaml_type: YamlObjectType, ) -> Result<&'a str, ParseMetadataError> { - match get_string_value(hash, key, yaml_type)? { + match get_string_value(mapping, key, yaml_type)? { Some(n) => Ok(n.1), None => Err(ParseMetadataError::missing_key(marker, key, yaml_type)), } } pub fn get_strings_vec_value<'a>( - hash: &'a AnnotatedHash, + mapping: &'a AnnotatedMapping, key: &'static str, yaml_type: YamlObjectType, ) -> Result, ParseMetadataError> { - match hash.get(&as_string_node(key)) { + match mapping.get(&as_string_node(key)) { Some(n) => match n.data.as_vec() { Some(n) => n .iter() @@ -122,11 +123,11 @@ pub fn get_strings_vec_value<'a>( } } -pub fn get_as_hash( - value: &MarkedYaml, +pub fn as_mapping<'a, 'b>( + value: &'a MarkedYaml<'b>, yaml_type: YamlObjectType, -) -> Result<&AnnotatedHash, ParseMetadataError> { - match value.data.as_hash() { +) -> Result<&'a AnnotatedMapping<'a, MarkedYaml<'b>>, ParseMetadataError> { + match value.data.as_mapping() { Some(h) => Ok(h), None => Err(ParseMetadataError::unexpected_type( value.span.start, @@ -137,12 +138,12 @@ pub fn get_as_hash( } pub fn get_u32_value( - hash: &AnnotatedHash, + mapping: &AnnotatedMapping, key: &'static str, yaml_type: YamlObjectType, ) -> Result, ParseMetadataError> { - match hash.get(&as_string_node(key)) { - Some(n) => match n.data.as_i64() { + match mapping.get(&as_string_node(key)) { + Some(n) => match n.data.as_integer() { Some(i) => i.try_into().map(Some).map_err(|_| { ParseMetadataError::new(n.span.start, MetadataParsingErrorReason::NonU32Number(i)) }), @@ -158,11 +159,11 @@ pub fn get_u32_value( } pub fn get_as_slice<'a>( - hash: &'a saphyr::AnnotatedHash, + mapping: &'a saphyr::AnnotatedMapping, key: &'static str, yaml_type: YamlObjectType, -) -> Result<&'a [MarkedYaml], ParseMetadataError> { - if let Some(value) = hash.get(&as_string_node(key)) { +) -> Result<&'a [MarkedYaml<'a>], ParseMetadataError> { + if let Some(value) = mapping.get(&as_string_node(key)) { match value.data.as_vec() { Some(n) => Ok(n.as_slice()), None => Err(ParseMetadataError::unexpected_value_type( @@ -178,10 +179,10 @@ pub fn get_as_slice<'a>( } pub fn parse_condition( - hash: &saphyr::AnnotatedHash, + mapping: &saphyr::AnnotatedMapping, yaml_type: YamlObjectType, ) -> Result>, ParseMetadataError> { - match get_string_value(hash, "condition", yaml_type)? { + match get_string_value(mapping, "condition", yaml_type)? { Some((marker, s)) => { let s = s.to_string(); if let Err(e) = Expression::from_str(&s) { From fc4b976bd646560952375cc70cdffb8763676b8d Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 20 Apr 2025 09:48:35 +0100 Subject: [PATCH 079/206] Update esplugin to v6.1.2 --- Cargo.lock | 24 ++++-------------------- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d604acd..8b6fe925 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,13 +360,13 @@ dependencies = [ [[package]] name = "esplugin" -version = "6.1.1" +version = "6.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5be744c54f80f535dc03a47c6a12c5116525edfa8a1365f13e91c42a0ad4d55" +checksum = "cd8b3f4507e489fce3df78273537e93e939916c83d992fbfb712db9b1f5eee62" dependencies = [ "encoding_rs", "memchr", - "nom 7.1.3", + "nom", "unicase", ] @@ -656,7 +656,7 @@ checksum = "94d58882c85aba2f4fca0951301b58e03428a8a02e51ccb0cf5f5b28d7869281" dependencies = [ "crc32fast", "esplugin", - "nom 8.0.0", + "nom", "pelite", "regex", "unicase", @@ -677,28 +677,12 @@ dependencies = [ "autocfg", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "no-std-compat" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" -[[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" version = "8.0.0" diff --git a/Cargo.toml b/Cargo.toml index 99d5de0c..a35ade9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ tempfile = "3.17.1" members = ["cxx", "ffi-errors", "pyo3"] [workspace.dependencies] -esplugin = "6.1.1" +esplugin = "6.1.2" libloadorder = "18.3.0" loot-condition-interpreter = "5.3.1" From fb1e1ad3609f7d44fb847b5d0ba1e8f0cad3991c Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 20 Apr 2025 12:59:19 +0100 Subject: [PATCH 080/206] Sync with libloot v0.26.0 --- Cargo.lock | 8 +- Cargo.toml | 2 +- README.md | 2 +- cxx/CMakeLists.txt | 3 +- cxx/Cargo.toml | 2 +- cxx/README.md | 4 +- cxx/build.rs | 2 + cxx/cmake/tests.cmake | 5 +- cxx/include/loot/api.h | 5 +- cxx/include/loot/database_interface.h | 52 +++-- .../loot/exception/undefined_group_error.h | 3 +- cxx/include/loot/game_interface.h | 7 +- cxx/include/loot/loot_version.h | 4 +- .../loot/metadata/conditional_metadata.h | 3 +- cxx/include/loot/metadata/file.h | 16 +- cxx/include/loot/metadata/filename.h | 3 +- cxx/include/loot/metadata/group.h | 7 +- cxx/include/loot/metadata/location.h | 5 +- cxx/include/loot/metadata/message.h | 7 +- cxx/include/loot/metadata/message_content.h | 9 +- .../loot/metadata/plugin_cleaning_data.h | 5 +- cxx/include/loot/metadata/plugin_metadata.h | 7 +- cxx/include/loot/metadata/tag.h | 5 +- cxx/include/loot/plugin_interface.h | 3 +- cxx/include/loot/vertex.h | 5 +- cxx/src/api/api.cpp | 22 +- cxx/src/api/convert.cpp | 47 +++-- cxx/src/api/convert.h | 4 + cxx/src/api/database.cpp | 61 +++--- cxx/src/api/database.h | 21 +- cxx/src/api/exception.cpp | 32 +-- cxx/src/api/game.cpp | 43 +--- cxx/src/api/game.h | 8 +- cxx/src/api/metadata/conditional_metadata.cpp | 2 +- cxx/src/api/metadata/file.cpp | 23 ++- cxx/src/api/metadata/filename.cpp | 3 +- cxx/src/api/metadata/group.cpp | 4 +- cxx/src/api/metadata/location.cpp | 2 +- cxx/src/api/metadata/message.cpp | 6 +- cxx/src/api/metadata/message_content.cpp | 6 +- cxx/src/api/metadata/plugin_cleaning_data.cpp | 5 +- cxx/src/api/metadata/plugin_metadata.cpp | 21 +- cxx/src/api/metadata/tag.cpp | 4 +- cxx/src/api/plugin.cpp | 13 +- cxx/src/api/plugin.h | 2 +- cxx/src/api/resource.rc | 4 +- cxx/src/api/sorting/undefined_group_error.cpp | 5 +- cxx/src/api/vertex.cpp | 4 +- cxx/src/database.rs | 8 + cxx/src/lib.rs | 6 + cxx/src/metadata.rs | 8 + .../api/interface/database_interface_test.h | 193 ++++++++++-------- .../tests/api/interface/game_interface_test.h | 2 +- cxx/src/tests/api/interface/main.cpp | 48 ++++- cxx/src/tests/api/internals/main.cpp | 2 +- cxx/src/tests/common_game_test_fixture.h | 17 +- ffi-errors/Cargo.toml | 2 +- pyo3/Cargo.toml | 2 +- pyo3/src/database.rs | 8 + pyo3/src/metadata.rs | 22 +- src/database/conditions.rs | 13 +- src/database/mod.rs | 27 ++- src/error.rs | 8 + src/game.rs | 96 +++++++-- src/metadata/file.rs | 65 +++++- src/metadata/message.rs | 2 +- src/metadata/plugin_metadata.rs | 28 ++- src/metadata/tag.rs | 2 +- src/metadata/yaml/parse.rs | 3 +- src/sorting/plugins.rs | 8 +- 70 files changed, 737 insertions(+), 349 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b6fe925..dca8714f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -565,7 +565,7 @@ dependencies = [ [[package]] name = "libloot" -version = "0.25.5" +version = "0.26.0" dependencies = [ "crc32fast", "esplugin", @@ -587,7 +587,7 @@ dependencies = [ [[package]] name = "libloot-cxx" -version = "0.25.5" +version = "0.26.0" dependencies = [ "cxx", "cxx-build", @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "libloot-ffi-errors" -version = "0.25.5" +version = "0.26.0" dependencies = [ "esplugin", "libloadorder", @@ -609,7 +609,7 @@ dependencies = [ [[package]] name = "libloot_pyo3" -version = "0.25.5" +version = "0.26.0" dependencies = [ "libloot", "libloot-ffi-errors", diff --git a/Cargo.toml b/Cargo.toml index a35ade9b..9f0142dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot" -version = "0.25.5" +version = "0.26.0" edition = "2024" license = "GPL-3.0" diff --git a/README.md b/README.md index 4a572279..0e94e94d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # libloot-rs -This is an **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++, that should match libloot v0.25.5. +This is an **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++, that should match libloot v0.26.0. See the `cxx` and `pyo3` subdirectories for C++ and Python wrappers respectively. diff --git a/cxx/CMakeLists.txt b/cxx/CMakeLists.txt index 11cfda75..2a55eb68 100644 --- a/cxx/CMakeLists.txt +++ b/cxx/CMakeLists.txt @@ -154,6 +154,7 @@ target_include_directories(loot SYSTEM PRIVATE if(CMAKE_SYSTEM_NAME STREQUAL "Windows") target_compile_definitions(loot PRIVATE UNICODE _UNICODE LOOT_EXPORT) + target_compile_options(loot PRIVATE "/Zc:__cplusplus") set(LOOT_LIBS ntdll ws2_32 bcrypt) @@ -224,7 +225,7 @@ endif() # Install ######################################## -set(LIBLOOT_VERSION "0.25.4") +set(LIBLOOT_VERSION "0.26.0") set_property(TARGET loot PROPERTY VERSION ${LIBLOOT_VERSION}) set_property(TARGET loot PROPERTY SOVERSION 0) diff --git a/cxx/Cargo.toml b/cxx/Cargo.toml index 04d3b425..ae45aad3 100644 --- a/cxx/Cargo.toml +++ b/cxx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot-cxx" -version = "0.25.5" +version = "0.26.0" edition = "2024" license = "GPL-3.0" diff --git a/cxx/README.md b/cxx/README.md index 1c929778..a02d9f1d 100644 --- a/cxx/README.md +++ b/cxx/README.md @@ -1,6 +1,6 @@ # libloot-rs C++ wrapper -This is an **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface that's ABI-compatible with libloot v0.25.5. +This is an **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface that's ABI-compatible with libloot v0.26.0. The wrapper has two layers: @@ -47,7 +47,7 @@ cmake --build build --parallel ### Tests & Packaging -The build process also builds a copy of the public API tests from C++ libloot v0.25.5 by default. To skip building the tests, pass `-DLIBLOOT_BUILD_TESTS=OFF` when first running CMake. +The build process also builds a copy of the public API tests from C++ libloot v0.26.0 by default. To skip building the tests, pass `-DLIBLOOT_BUILD_TESTS=OFF` when first running CMake. If built, the tests can be run using: diff --git a/cxx/build.rs b/cxx/build.rs index 833a7145..d805cf29 100644 --- a/cxx/build.rs +++ b/cxx/build.rs @@ -1,6 +1,8 @@ fn main() { cxx_build::bridge("src/lib.rs") .std("c++17") + .flag_if_supported("/Zc:__cplusplus") + .flag_if_supported("/permissive-") .compile("libloot-cxx"); // From diff --git a/cxx/cmake/tests.cmake b/cxx/cmake/tests.cmake index f415887e..4ff94610 100644 --- a/cxx/cmake/tests.cmake +++ b/cxx/cmake/tests.cmake @@ -133,13 +133,14 @@ endif() if(MSVC) # Turn off permissive mode to be more standards-compliant and avoid compiler errors. - target_compile_options(libloot_tests PRIVATE "/permissive-" "/W4") + target_compile_options(libloot_tests PRIVATE "/permissive-" "/W4" "/Zc:__cplusplus") # Set /bigobj to allow building Debug and RelWithDebInfo tests target_compile_options(libloot_internals_tests PRIVATE "/permissive-" "/W4" - "$<$,$>:/bigobj>") + "$<$,$>:/bigobj>" + "/Zc:__cplusplus") endif() diff --git a/cxx/include/loot/api.h b/cxx/include/loot/api.h index 80c7095b..b8d551e1 100644 --- a/cxx/include/loot/api.h +++ b/cxx/include/loot/api.h @@ -29,6 +29,7 @@ #include #include #include +#include #include "loot/api_decorator.h" #include "loot/enum/game_type.h" @@ -50,14 +51,12 @@ namespace loot { /** * @brief Set the callback function that is called when logging. - * @details If this function is not called, the default behaviour is to - * print messages to the console. * @param callback * The function called when logging. The first parameter is the * level of the message being logged, and the second is the message. */ LOOT_API void SetLoggingCallback( - std::function callback); + std::function callback); /** * @brief Set the log severity level. diff --git a/cxx/include/loot/database_interface.h b/cxx/include/loot/database_interface.h index 3ce1263e..136b8627 100644 --- a/cxx/include/loot/database_interface.h +++ b/cxx/include/loot/database_interface.h @@ -27,6 +27,7 @@ #include #include #include +#include #include #include "loot/exception/cyclic_interaction_error.h" @@ -46,26 +47,41 @@ public: */ /** - * @brief Loads the masterlist, userlist and masterlist prelude from the - * paths specified. + * @brief Loads the masterlist from the path specified. * @details Can be called multiple times, each time replacing the * previously-loaded data. * @param masterlist_path * The relative or absolute path to the masterlist file that should be * loaded. - * @param userlist_path - * The relative or absolute path to the userlist file that should be - * loaded, or an empty path. If an empty path, no userlist will be + */ + virtual void LoadMasterlist( + const std::filesystem::path& masterlistPath) = 0; + + /** + * @brief Loads the masterlist and masterlist prelude from the paths + specified. + * @details Can be called multiple times, each time replacing the + * previously-loaded data. + * @param masterlist_path + * The relative or absolute path to the masterlist file that should be * loaded. * @param masterlist_prelude_path * The relative or absolute path to the masterlist prelude file that - * should be loaded. If an empty path, no masterlist prelude will be + * should be loaded. + */ + virtual void LoadMasterlistWithPrelude( + const std::filesystem::path& masterlistPath, + const std::filesystem::path& masterlistPreludePath) = 0; + + /** + * @brief Loads the userlist from the path specified. + * @details Can be called multiple times, each time replacing the + * previously-loaded data. + * @param userlist_path + * The relative or absolute path to the userlist file that should be * loaded. */ - virtual void LoadLists( - const std::filesystem::path& masterlist_path, - const std::filesystem::path& userlist_path = "", - const std::filesystem::path& masterlist_prelude_path = "") = 0; + virtual void LoadUserlist(const std::filesystem::path& userlistPath) = 0; /** * Writes a metadata file containing all loaded user-added metadata. @@ -91,6 +107,12 @@ public: virtual void WriteMinimalList(const std::filesystem::path& outputFile, const bool overwrite) const = 0; + /** + * @brief Evaluate the given condition string. + * @param condition A condition string. + */ + virtual bool Evaluate(const std::string& condition) const = 0; + /** * @} * @name Non-plugin Data Access @@ -160,8 +182,8 @@ public: * exists. */ virtual std::vector GetGroupsPath( - const std::string& fromGroupName, - const std::string& toGroupName) const = 0; + std::string_view fromGroupName, + std::string_view toGroupName) const = 0; /** * @} @@ -185,7 +207,7 @@ public: * otherwise an optional containing no value. */ virtual std::optional GetPluginMetadata( - const std::string& plugin, + std::string_view plugin, bool includeUserMetadata = true, bool evaluateConditions = false) const = 0; @@ -201,7 +223,7 @@ public: * that metadata, otherwise an optional containing no value. */ virtual std::optional GetPluginUserMetadata( - const std::string& plugin, + std::string_view plugin, bool evaluateConditions = false) const = 0; /** @@ -220,7 +242,7 @@ public: * The filename of the plugin for which all user-added metadata * should be deleted. */ - virtual void DiscardPluginUserMetadata(const std::string& plugin) = 0; + virtual void DiscardPluginUserMetadata(std::string_view plugin) = 0; /** * @brief Discards all loaded user metadata for all plugins, and any diff --git a/cxx/include/loot/exception/undefined_group_error.h b/cxx/include/loot/exception/undefined_group_error.h index 008b3ae0..27dfea1e 100644 --- a/cxx/include/loot/exception/undefined_group_error.h +++ b/cxx/include/loot/exception/undefined_group_error.h @@ -26,6 +26,7 @@ #define LOOT_EXCEPTION_UNDEFINED_GROUP_ERROR #include +#include #include "loot/api_decorator.h" @@ -39,7 +40,7 @@ public: * @brief Construct an exception for an undefined group. * @param groupName The name of the group that is undefined. */ - LOOT_API UndefinedGroupError(const std::string& groupName); + LOOT_API UndefinedGroupError(std::string_view groupName); /** * Get the name of the undefined group. diff --git a/cxx/include/loot/game_interface.h b/cxx/include/loot/game_interface.h index 9e41c83a..3ebcf1f2 100644 --- a/cxx/include/loot/game_interface.h +++ b/cxx/include/loot/game_interface.h @@ -141,8 +141,8 @@ public: * is called, this GameInterface is destroyed, or until a plugin with * a case-insensitively equal filename is loaded. */ - virtual const PluginInterface* GetPlugin( - const std::string& pluginName) const = 0; + virtual std::shared_ptr GetPlugin( + std::string_view pluginName) const = 0; /** * @brief Get a set of const references to all loaded plugins' PluginInterface @@ -152,7 +152,8 @@ public: * this GameInterface is destroyed, or until a plugin with a * case-insensitively equal filename is loaded. */ - virtual std::vector GetLoadedPlugins() const = 0; + virtual std::vector> GetLoadedPlugins() + const = 0; /** * @} diff --git a/cxx/include/loot/loot_version.h b/cxx/include/loot/loot_version.h index f39f504b..961407b0 100644 --- a/cxx/include/loot/loot_version.h +++ b/cxx/include/loot/loot_version.h @@ -34,10 +34,10 @@ namespace loot { inline constexpr unsigned int LIBLOOT_VERSION_MAJOR = 0; /** @brief libloot's minor version number. */ -inline constexpr unsigned int LIBLOOT_VERSION_MINOR = 25; +inline constexpr unsigned int LIBLOOT_VERSION_MINOR = 26; /** @brief libloot's patch version number. */ -inline constexpr unsigned int LIBLOOT_VERSION_PATCH = 5; +inline constexpr unsigned int LIBLOOT_VERSION_PATCH = 0; /** * @brief Get the library version. diff --git a/cxx/include/loot/metadata/conditional_metadata.h b/cxx/include/loot/metadata/conditional_metadata.h index cd47658b..e175670c 100644 --- a/cxx/include/loot/metadata/conditional_metadata.h +++ b/cxx/include/loot/metadata/conditional_metadata.h @@ -25,6 +25,7 @@ #define LOOT_METADATA_CONDITIONAL_METADATA #include +#include #include "loot/api_decorator.h" @@ -48,7 +49,7 @@ public: * documentation. * @return A ConditionalMetadata object. */ - LOOT_API explicit ConditionalMetadata(const std::string& condition); + LOOT_API explicit ConditionalMetadata(std::string_view condition); /** * Check if the condition string is non-empty. diff --git a/cxx/include/loot/metadata/file.h b/cxx/include/loot/metadata/file.h index e7d1934d..c9ea1335 100644 --- a/cxx/include/loot/metadata/file.h +++ b/cxx/include/loot/metadata/file.h @@ -25,6 +25,7 @@ #define LOOT_METADATA_FILE #include +#include #include "loot/api_decorator.h" #include "loot/metadata/conditional_metadata.h" @@ -56,12 +57,16 @@ public: * The detail message content, which may be appended to any messages * generated for this file. If multilingual, one language must be * English. + * @param constraint + * A condition string that must evaluate to true for the file's existence + * to be recognised. * @return A File object. */ - LOOT_API explicit File(const std::string& name, - const std::string& display = "", - const std::string& condition = "", - const std::vector& detail = {}); + LOOT_API explicit File(std::string_view name, + std::string_view display = "", + std::string_view condition = "", + const std::vector& detail = {}, + std::string_view constraint = ""); /** * Get the filename of the file. @@ -84,10 +89,13 @@ public: */ LOOT_API std::vector GetDetail() const; + LOOT_API std::string GetConstraint() const; + private: Filename name_; std::string display_; std::vector detail_; + std::string constraint_; }; /** diff --git a/cxx/include/loot/metadata/filename.h b/cxx/include/loot/metadata/filename.h index b09fa2be..3c7fc9c9 100644 --- a/cxx/include/loot/metadata/filename.h +++ b/cxx/include/loot/metadata/filename.h @@ -25,6 +25,7 @@ #define LOOT_METADATA_FILENAME #include +#include #include "loot/api_decorator.h" @@ -44,7 +45,7 @@ public: * Construct a Filename using the given string. * @return A Filename object. */ - LOOT_API explicit Filename(const std::string& filename); + LOOT_API explicit Filename(std::string_view filename); /** * Get this Filename as a string. diff --git a/cxx/include/loot/metadata/group.h b/cxx/include/loot/metadata/group.h index adf78ce3..ba5a0071 100644 --- a/cxx/include/loot/metadata/group.h +++ b/cxx/include/loot/metadata/group.h @@ -25,6 +25,7 @@ #define LOOT_METADATA_GROUP #include +#include #include #include "loot/api_decorator.h" @@ -38,7 +39,7 @@ public: /** * The name of the group to which all plugins belong by default. */ - static constexpr const char* DEFAULT_NAME = "default"; + static constexpr std::string_view DEFAULT_NAME = "default"; /** * Construct a Group with the name "default" and an empty set of groups to @@ -58,9 +59,9 @@ public: * A description of the group. * @return A Group object. */ - LOOT_API explicit Group(const std::string& name, + LOOT_API explicit Group(std::string_view name, const std::vector& afterGroups = {}, - const std::string& description = ""); + std::string_view description = ""); /** * Get the name of the group. diff --git a/cxx/include/loot/metadata/location.h b/cxx/include/loot/metadata/location.h index 66b319f7..7f32eca4 100644 --- a/cxx/include/loot/metadata/location.h +++ b/cxx/include/loot/metadata/location.h @@ -25,6 +25,7 @@ #define LOOT_METADATA_LOCATION #include +#include #include #include "loot/api_decorator.h" @@ -49,8 +50,8 @@ public: * A name for the URL, eg. the page or site name. * @return A Location object. */ - LOOT_API explicit Location(const std::string& url, - const std::string& name = ""); + LOOT_API explicit Location(std::string_view url, + std::string_view name = ""); /** * Get the object's URL. diff --git a/cxx/include/loot/metadata/message.h b/cxx/include/loot/metadata/message.h index 735d59b1..1509a640 100644 --- a/cxx/include/loot/metadata/message.h +++ b/cxx/include/loot/metadata/message.h @@ -25,6 +25,7 @@ #define LOOT_METADATA_MESSAGE #include +#include #include #include "loot/api_decorator.h" @@ -57,8 +58,8 @@ public: * @return A Message object. */ LOOT_API explicit Message(const MessageType type, - const std::string& content, - const std::string& condition = ""); + std::string_view content, + std::string_view condition = ""); /** * Construct a Message object with the given type, content and condition @@ -73,7 +74,7 @@ public: */ LOOT_API explicit Message(const MessageType type, const std::vector& content, - const std::string& condition = ""); + std::string_view condition = ""); /** * Get the message type. diff --git a/cxx/include/loot/metadata/message_content.h b/cxx/include/loot/metadata/message_content.h index 9a3b4c07..9f23335f 100644 --- a/cxx/include/loot/metadata/message_content.h +++ b/cxx/include/loot/metadata/message_content.h @@ -26,6 +26,7 @@ #include #include +#include #include #include "loot/api_decorator.h" @@ -40,7 +41,7 @@ public: * The code for the default language assumed for message content, which is * "en" (English). */ - static constexpr const char* DEFAULT_LANGUAGE = "en"; + static constexpr std::string_view DEFAULT_LANGUAGE = "en"; /** * Construct a MessageContent object with an empty English message string. @@ -57,8 +58,8 @@ public: * @return A MessageContent object. */ LOOT_API explicit MessageContent( - const std::string& text, - const std::string& language = DEFAULT_LANGUAGE); + std::string_view text, + std::string_view language = DEFAULT_LANGUAGE); /** * Get the message text. @@ -144,7 +145,7 @@ LOOT_API bool operator>=(const MessageContent& lhs, const MessageContent& rhs); */ LOOT_API std::optional SelectMessageContent( const std::vector content, - const std::string& language); + std::string_view language); } #endif diff --git a/cxx/include/loot/metadata/plugin_cleaning_data.h b/cxx/include/loot/metadata/plugin_cleaning_data.h index 8ed59662..bb429927 100644 --- a/cxx/include/loot/metadata/plugin_cleaning_data.h +++ b/cxx/include/loot/metadata/plugin_cleaning_data.h @@ -27,6 +27,7 @@ #include #include +#include #include "loot/api_decorator.h" #include "loot/metadata/message.h" @@ -57,7 +58,7 @@ public: * @return A PluginCleaningData object. */ LOOT_API explicit PluginCleaningData(uint32_t crc, - const std::string& utility); + std::string_view utility); /** * Construct a PluginCleaningData object with the given values. @@ -78,7 +79,7 @@ public: */ LOOT_API explicit PluginCleaningData( uint32_t crc, - const std::string& utility, + std::string_view utility, const std::vector& detail, unsigned int itm, unsigned int ref, diff --git a/cxx/include/loot/metadata/plugin_metadata.h b/cxx/include/loot/metadata/plugin_metadata.h index da821a90..59619544 100644 --- a/cxx/include/loot/metadata/plugin_metadata.h +++ b/cxx/include/loot/metadata/plugin_metadata.h @@ -30,6 +30,7 @@ #include #include #include +#include #include #include "loot/api_decorator.h" @@ -58,7 +59,7 @@ public: * The filename of the plugin that the object is constructed for. * @return A PluginMetadata object. */ - LOOT_API explicit PluginMetadata(const std::string& name); + LOOT_API explicit PluginMetadata(std::string_view name); /** * Merge metadata from the given PluginMetadata object into this object. @@ -138,7 +139,7 @@ public: * @param group * The name of the group this plugin belongs to. */ - LOOT_API void SetGroup(const std::string& group); + LOOT_API void SetGroup(std::string_view group); /** * Unsets the plugin's group. @@ -227,7 +228,7 @@ public: * @returns True if the given plugin name matches this metadata's plugin * name, false otherwise. */ - LOOT_API bool NameMatches(const std::string& pluginName) const; + LOOT_API bool NameMatches(std::string_view pluginName) const; /** * @brief Serialises the plugin metadata as YAML. diff --git a/cxx/include/loot/metadata/tag.h b/cxx/include/loot/metadata/tag.h index 1138b3cc..09f9249e 100644 --- a/cxx/include/loot/metadata/tag.h +++ b/cxx/include/loot/metadata/tag.h @@ -25,6 +25,7 @@ #define LOOT_METADATA_TAG #include +#include #include "loot/api_decorator.h" #include "loot/metadata/conditional_metadata.h" @@ -53,9 +54,9 @@ public: * A condition string. * @return A Tag object. */ - LOOT_API explicit Tag(const std::string& tag, + LOOT_API explicit Tag(std::string_view tag, const bool isAddition = true, - const std::string& condition = ""); + std::string_view condition = ""); /** * Check if the tag should be added. diff --git a/cxx/include/loot/plugin_interface.h b/cxx/include/loot/plugin_interface.h index 4bec165d..dc152a01 100644 --- a/cxx/include/loot/plugin_interface.h +++ b/cxx/include/loot/plugin_interface.h @@ -28,6 +28,7 @@ #include #include #include +#include #include #include "loot/metadata/message.h" @@ -79,7 +80,7 @@ public: * @return A set of Bash Tags. The order of elements in the set holds no * semantics. */ - virtual std::vector GetBashTags() const = 0; + virtual std::vector GetBashTags() const = 0; /** * Get the plugin's CRC-32 checksum. diff --git a/cxx/include/loot/vertex.h b/cxx/include/loot/vertex.h index 861d652c..f18de6b3 100644 --- a/cxx/include/loot/vertex.h +++ b/cxx/include/loot/vertex.h @@ -27,6 +27,7 @@ along with LOOT. If not, see #include #include +#include #include "loot/api_decorator.h" #include "loot/enum/edge_type.h" @@ -42,14 +43,14 @@ public: * @brief Construct a Vertex with the given name and no out edge. * @param name The name of the plugin or group that this vertex represents. */ - LOOT_API explicit Vertex(std::string name); + LOOT_API explicit Vertex(std::string_view name); /** * @brief Construct a Vertex with the given name and out edge type. * @param name The name of the plugin or group that this vertex represents. * @param outEdgeType The type of the edge going out from this vertex. */ - LOOT_API explicit Vertex(std::string name, EdgeType outEdgeType); + LOOT_API explicit Vertex(std::string_view name, EdgeType outEdgeType); /** * @brief Get the name of the plugin or group. diff --git a/cxx/src/api/api.cpp b/cxx/src/api/api.cpp index cc7e32bd..3878917c 100644 --- a/cxx/src/api/api.cpp +++ b/cxx/src/api/api.cpp @@ -32,7 +32,7 @@ void libloot_set_logging_callback(void (*callback)(uint8_t, const char*, void*), namespace { using loot::LogLevel; -static std::function STORED_CALLBACK; +static std::function STORED_CALLBACK; LogLevel convert(uint8_t level) { if (level == LIBLOOT_LOG_LEVEL_TRACE) { @@ -53,25 +53,25 @@ LogLevel convert(uint8_t level) { loot::rust::LogLevel convert(LogLevel level) { switch (level) { case LogLevel::trace: - return loot::rust::LogLevel::Trace; + return loot::rust::LogLevel::Trace; case LogLevel::debug: - return loot::rust::LogLevel::Debug; + return loot::rust::LogLevel::Debug; case LogLevel::info: - return loot::rust::LogLevel::Info; + return loot::rust::LogLevel::Info; case LogLevel::warning: - return loot::rust::LogLevel::Warning; + return loot::rust::LogLevel::Warning; case LogLevel::error: - return loot::rust::LogLevel::Error; + return loot::rust::LogLevel::Error; case LogLevel::fatal: - return loot::rust::LogLevel::Fatal; - default: - return loot::rust::LogLevel::Trace; + return loot::rust::LogLevel::Fatal; + default: + return loot::rust::LogLevel::Trace; } } void logging_callback(uint8_t level, const char* message, void* context) { auto callbackPtr = - static_cast*>(context); + static_cast*>(context); (*callbackPtr)(convert(level), message); } @@ -79,7 +79,7 @@ void logging_callback(uint8_t level, const char* message, void* context) { namespace loot { LOOT_API void SetLoggingCallback( - std::function callback) { + std::function callback) { STORED_CALLBACK = callback; libloot_set_logging_callback(logging_callback, &STORED_CALLBACK); } diff --git a/cxx/src/api/convert.cpp b/cxx/src/api/convert.cpp index 08405f27..b9546d35 100644 --- a/cxx/src/api/convert.cpp +++ b/cxx/src/api/convert.cpp @@ -10,17 +10,25 @@ std::string convert(const ::rust::String& string) { return std::string(string); } +// Although there's an explicit conversion operator declared, it seems that +// building the CXX wrapper with MSVC doesn't set __cplusplus correctly as using +// the operator causes a linker error, so this just reimpls it as a function. +std::string_view convert(::rust::Str str) { + return std::string_view(str.data(), str.length()); +} + loot::Group convert(const loot::rust::Group& group) { - return loot::Group(std::string(group.name()), + return loot::Group(convert(group.name()), convert(group.after_groups()), - std::string(group.description())); + convert(group.description())); } loot::File convert(const loot::rust::File& file) { - return loot::File(std::string(file.filename().as_str()), - std::string(file.display_name()), - std::string(file.condition()), - convert(file.detail())); + return loot::File(convert(file.filename().as_str()), + convert(file.display_name()), + convert(file.condition()), + convert(file.detail()), + convert(file.constraint())); } loot::MessageType convert(loot::rust::MessageType messageType) { @@ -37,24 +45,24 @@ loot::MessageType convert(loot::rust::MessageType messageType) { } loot::MessageContent convert(const loot::rust::MessageContent& content) { - return loot::MessageContent(std::string(content.text()), - std::string(content.language())); + return loot::MessageContent(convert(content.text()), + convert(content.language())); } loot::Message convert(const loot::rust::Message& message) { return loot::Message(convert(message.message_type()), convert(message.content()), - std::string(message.condition())); + convert(message.condition())); } loot::Tag convert(const loot::rust::Tag& tag) { return loot::Tag( - std::string(tag.name()), tag.is_addition(), std::string(tag.condition())); + convert(tag.name()), tag.is_addition(), convert(tag.condition())); } loot::PluginCleaningData convert(const loot::rust::PluginCleaningData& data) { return loot::PluginCleaningData(data.crc(), - std::string(data.cleaning_utility()), + convert(data.cleaning_utility()), convert(data.detail()), data.itm_count(), data.deleted_reference_count(), @@ -62,15 +70,14 @@ loot::PluginCleaningData convert(const loot::rust::PluginCleaningData& data) { } loot::Location convert(const loot::rust::Location& location) { - return loot::Location(std::string(location.url()), - std::string(location.name())); + return loot::Location(convert(location.url()), convert(location.name())); } loot::PluginMetadata convert(const loot::rust::PluginMetadata& metadata) { - auto output = loot::PluginMetadata(std::string(metadata.name())); + auto output = loot::PluginMetadata(convert(metadata.name())); if (!metadata.group().empty()) { - output.SetGroup(std::string(metadata.group())); + output.SetGroup(convert(metadata.group())); } output.SetLoadAfterFiles(convert(metadata.load_after_files())); @@ -123,9 +130,9 @@ loot::Vertex convert(const loot::rust::Vertex& vertex) { try { const auto outEdgeType = convert(vertex.out_edge_type()); if (outEdgeType.has_value()) { - return loot::Vertex(std::string(vertex.name()), outEdgeType.value()); + return loot::Vertex(convert(vertex.name()), outEdgeType.value()); } else { - return loot::Vertex(std::string(vertex.name())); + return loot::Vertex(convert(vertex.name())); } } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); @@ -135,6 +142,10 @@ loot::Vertex convert(const loot::rust::Vertex& vertex) { // From public types /////////////////////// +::rust::Str convert(std::string_view view) { + return ::rust::Str(view.data(), view.length()); +} + ::rust::Box convert(const loot::Group& group) { auto output = loot::rust::new_group(group.GetName()); output->set_after_groups(convert(group.GetAfterGroups())); @@ -156,6 +167,8 @@ loot::Vertex convert(const loot::rust::Vertex& vertex) { output->set_condition(file.GetCondition()); + output->set_constraint(file.GetConstraint()); + return output; } diff --git a/cxx/src/api/convert.h b/cxx/src/api/convert.h index 7b2c9034..998c3d72 100644 --- a/cxx/src/api/convert.h +++ b/cxx/src/api/convert.h @@ -12,6 +12,8 @@ namespace loot { std::string convert(const ::rust::String& string); +std::string_view convert(::rust::Str string); + loot::Group convert(const loot::rust::Group& group); loot::File convert(const loot::rust::File& file); @@ -37,6 +39,8 @@ loot::Vertex convert(const loot::rust::Vertex& vertex); // From public types /////////////////////// +::rust::Str convert(std::string_view view); + ::rust::Box convert(const loot::Group& group); ::rust::Box convert(const loot::File& file); diff --git a/cxx/src/api/database.cpp b/cxx/src/api/database.cpp index 0bdb68e0..d5620ae9 100644 --- a/cxx/src/api/database.cpp +++ b/cxx/src/api/database.cpp @@ -8,22 +8,28 @@ namespace loot { Database::Database(::rust::Box&& database) : database_(std::move(database)) {} -void Database::LoadLists(const std::filesystem::path& masterlistPath, - const std::filesystem::path& userlistPath, - const std::filesystem::path& masterlistPreludePath) { +void Database::LoadMasterlist(const std::filesystem::path& masterlistPath) { try { - if (!masterlistPath.empty()) { - if (!masterlistPreludePath.empty()) { - database_->load_masterlist_with_prelude( - masterlistPath.u8string(), masterlistPreludePath.u8string()); - } else { - database_->load_masterlist(masterlistPath.u8string()); - } - } + database_->load_masterlist(masterlistPath.u8string()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} - if (!userlistPath.empty()) { - database_->load_userlist(userlistPath.u8string()); - } +void Database::LoadMasterlistWithPrelude( + const std::filesystem::path& masterlistPath, + const std::filesystem::path& masterlistPreludePath) { + try { + database_->load_masterlist_with_prelude(masterlistPath.u8string(), + masterlistPreludePath.u8string()); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + +void Database::LoadUserlist(const std::filesystem::path& userlistPath) { + try { + database_->load_userlist(userlistPath.u8string()); } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); } @@ -38,6 +44,14 @@ void Database::WriteUserMetadata(const std::filesystem::path& outputFile, } } +bool Database::Evaluate(const std::string& condition) const { + try { + return database_->evaluate(condition); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); + } +} + std::vector Database::GetKnownBashTags() const { try { return convert(database_->known_bash_tags()); @@ -81,22 +95,23 @@ void Database::SetUserGroups(const std::vector& groups) { } std::vector Database::GetGroupsPath( - const std::string& fromGroupName, - const std::string& toGroupName) const { + std::string_view fromGroupName, + std::string_view toGroupName) const { try { - return convert(database_->groups_path(fromGroupName, toGroupName)); + return convert( + database_->groups_path(convert(fromGroupName), convert(toGroupName))); } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); } } std::optional Database::GetPluginMetadata( - const std::string& plugin, + std::string_view plugin, bool includeUserMetadata, bool evaluateConditions) const { try { const auto metadata = database_->plugin_metadata( - plugin, includeUserMetadata, evaluateConditions); + convert(plugin), includeUserMetadata, evaluateConditions); if (metadata->is_some()) { return convert(metadata->as_ref()); } else { @@ -108,11 +123,11 @@ std::optional Database::GetPluginMetadata( } std::optional Database::GetPluginUserMetadata( - const std::string& plugin, + std::string_view plugin, bool evaluateConditions) const { try { const auto metadata = - database_->plugin_user_metadata(plugin, evaluateConditions); + database_->plugin_user_metadata(convert(plugin), evaluateConditions); if (metadata->is_some()) { return convert(metadata->as_ref()); } else { @@ -131,9 +146,9 @@ void Database::SetPluginUserMetadata(const PluginMetadata& pluginMetadata) { } } -void Database::DiscardPluginUserMetadata(const std::string& plugin) { +void Database::DiscardPluginUserMetadata(std::string_view plugin) { try { - database_->discard_plugin_user_metadata(plugin); + database_->discard_plugin_user_metadata(convert(plugin)); } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); } diff --git a/cxx/src/api/database.h b/cxx/src/api/database.h index 84c0f2d4..393625e9 100644 --- a/cxx/src/api/database.h +++ b/cxx/src/api/database.h @@ -10,10 +10,13 @@ class Database final : public DatabaseInterface { public: explicit Database(::rust::Box&& database); - void LoadLists( + void LoadMasterlist(const std::filesystem::path& masterlist_path) override; + + void LoadMasterlistWithPrelude( const std::filesystem::path& masterlist_path, - const std::filesystem::path& userlist_path = "", - const std::filesystem::path& masterlist_prelude_path = "") override; + const std::filesystem::path& masterlist_prelude_path) override; + + void LoadUserlist(const std::filesystem::path& userlist_path) override; void WriteUserMetadata(const std::filesystem::path& outputFile, const bool overwrite) const override; @@ -21,6 +24,8 @@ public: void WriteMinimalList(const std::filesystem::path& outputFile, const bool overwrite) const override; + bool Evaluate(const std::string& condition) const override; + std::vector GetKnownBashTags() const override; std::vector GetGeneralMessages( @@ -30,21 +35,21 @@ public: std::vector GetUserGroups() const override; void SetUserGroups(const std::vector& groups) override; std::vector GetGroupsPath( - const std::string& fromGroupName, - const std::string& toGroupName) const override; + std::string_view fromGroupName, + std::string_view toGroupName) const override; std::optional GetPluginMetadata( - const std::string& plugin, + std::string_view plugin, bool includeUserMetadata = true, bool evaluateConditions = false) const override; std::optional GetPluginUserMetadata( - const std::string& plugin, + std::string_view plugin, bool evaluateConditions = false) const override; void SetPluginUserMetadata(const PluginMetadata& pluginMetadata) override; - void DiscardPluginUserMetadata(const std::string& plugin) override; + void DiscardPluginUserMetadata(std::string_view plugin) override; void DiscardAllUserMetadata() override; diff --git a/cxx/src/api/exception.cpp b/cxx/src/api/exception.cpp index acec793c..775ea61b 100644 --- a/cxx/src/api/exception.cpp +++ b/cxx/src/api/exception.cpp @@ -10,19 +10,20 @@ #include "loot/vertex.h" namespace { +using std::string_view_literals::operator""sv; using loot::EdgeType; using loot::Vertex; -constexpr std::string_view CYCLIC_ERROR_PREFIX = "CyclicInteractionError: "; +constexpr std::string_view CYCLIC_ERROR_PREFIX = "CyclicInteractionError: "sv; constexpr std::string_view UNDEFINED_GROUP_ERROR_PREFIX = - "UndefinedGroupError: "; -constexpr std::string_view ESPLUGIN_ERROR_PREFIX = "EspluginError: "; -constexpr std::string_view LIBLOADORDER_ERROR_PREFIX = "LibloadorderError: "; -constexpr std::string_view LCI_ERROR_PREFIX = "LciError: "; -constexpr std::string_view FILE_ACCESS_ERROR_PREFIX = "FileAccessError: "; -constexpr std::string_view INVALID_ARGUMENT_PREFIX = "InvalidArgument: "; + "UndefinedGroupError: "sv; +constexpr std::string_view ESPLUGIN_ERROR_PREFIX = "EspluginError: "sv; +constexpr std::string_view LIBLOADORDER_ERROR_PREFIX = "LibloadorderError: "sv; +constexpr std::string_view LCI_ERROR_PREFIX = "LciError: "sv; +constexpr std::string_view FILE_ACCESS_ERROR_PREFIX = "FileAccessError: "sv; +constexpr std::string_view INVALID_ARGUMENT_PREFIX = "InvalidArgument: "sv; -bool startsWith(const std::string_view& str, const std::string_view& prefix) { +bool startsWith(std::string_view str, std::string_view prefix) { if (str.size() < prefix.size()) { return false; } @@ -30,9 +31,9 @@ bool startsWith(const std::string_view& str, const std::string_view& prefix) { return str.substr(0, prefix.size()) == prefix; } -std::string replace(const std::string_view& str, - const std::string_view& from, - const std::string_view& to) { +std::string replace(std::string_view str, + std::string_view from, + std::string_view to) { std::string out; out.reserve(str.size()); @@ -50,7 +51,7 @@ std::string replace(const std::string_view& str, return out; } -EdgeType toEdgeType(const std::string_view& edgeTypeDisplay) { +EdgeType toEdgeType(std::string_view edgeTypeDisplay) { if (edgeTypeDisplay == "Hardcoded") { return EdgeType::hardcoded; } else if (edgeTypeDisplay == "Master Flag") { @@ -84,7 +85,7 @@ EdgeType toEdgeType(const std::string_view& edgeTypeDisplay) { } } -std::vector parseCyclicError(const std::string_view& what) { +std::vector parseCyclicError(std::string_view what) { const auto suffix = what.substr(0, CYCLIC_ERROR_PREFIX.size()); std::vector vertices; @@ -111,14 +112,13 @@ std::vector parseCyclicError(const std::string_view& what) { return vertices; } -std::string getErrorSuffix(const std::string_view& what) { +std::string getErrorSuffix(std::string_view what) { const auto sepPos = what.find(": "); return std::string(what.substr(sepPos + 2)); } -std::pair parseSystemError( - const std::string_view& whatSuffix) { +std::pair parseSystemError(std::string_view whatSuffix) { const auto sepPos = whatSuffix.find(": "); int code; const auto result = diff --git a/cxx/src/api/game.cpp b/cxx/src/api/game.cpp index 324149eb..e8d17c4e 100644 --- a/cxx/src/api/game.cpp +++ b/cxx/src/api/game.cpp @@ -160,52 +160,31 @@ void Game::LoadPlugins(const std::vector& pluginPaths, } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); } - - for (const auto& path : pluginPaths) { - auto key = Filename(path.filename().u8string()); - auto it = plugins_.find(key); - if (it != plugins_.end()) { - plugins_.erase(it); - } - } } -void Game::ClearLoadedPlugins() { - game_->clear_loaded_plugins(); - plugins_.clear(); -} +void Game::ClearLoadedPlugins() { game_->clear_loaded_plugins(); } -const PluginInterface* Game::GetPlugin(const std::string& pluginName) const { - const auto pluginOpt = game_->plugin(pluginName); +std::shared_ptr Game::GetPlugin( + std::string_view pluginName) const { + const auto pluginOpt = game_->plugin(convert(pluginName)); if (!pluginOpt->is_some()) { return nullptr; } - auto key = Filename(pluginName); - const auto it = plugins_.find(key); - if (it != plugins_.end()) { - return it->second.get(); - } - try { - auto plugin = std::make_shared(std::move(pluginOpt->as_ref().boxed_clone())); - const auto result = plugins_.emplace(key, plugin); - - return result.first->second.get(); + return std::make_shared( + std::move(pluginOpt->as_ref().boxed_clone())); } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); } } -std::vector Game::GetLoadedPlugins() const { - std::vector plugins; +std::vector> Game::GetLoadedPlugins() + const { + std::vector> plugins; for (const auto& pluginRef : game_->loaded_plugins()) { - const auto plugin = - std::make_shared(std::move(pluginRef.boxed_clone())); - - const auto result = - plugins_.insert_or_assign(Filename(plugin->GetName()), plugin); - plugins.push_back(result.first->second.get()); + plugins.push_back( + std::make_shared(std::move(pluginRef.boxed_clone()))); } return plugins; diff --git a/cxx/src/api/game.h b/cxx/src/api/game.h index 819712ba..85fb4886 100644 --- a/cxx/src/api/game.h +++ b/cxx/src/api/game.h @@ -37,10 +37,11 @@ public: void ClearLoadedPlugins() override; - const PluginInterface* GetPlugin( - const std::string& pluginName) const override; + std::shared_ptr GetPlugin( + std::string_view pluginName) const override; - std::vector GetLoadedPlugins() const override; + std::vector> GetLoadedPlugins() + const override; std::vector SortPlugins( const std::vector& pluginFilenames) override; @@ -60,7 +61,6 @@ public: private: ::rust::Box game_; Database database_; - mutable std::map> plugins_; }; } diff --git a/cxx/src/api/metadata/conditional_metadata.cpp b/cxx/src/api/metadata/conditional_metadata.cpp index f778f686..c660ed80 100644 --- a/cxx/src/api/metadata/conditional_metadata.cpp +++ b/cxx/src/api/metadata/conditional_metadata.cpp @@ -25,7 +25,7 @@ #include "loot/metadata/conditional_metadata.h" namespace loot { -ConditionalMetadata::ConditionalMetadata(const std::string& condition) : +ConditionalMetadata::ConditionalMetadata(std::string_view condition) : condition_(condition) {} bool ConditionalMetadata::IsConditional() const { return !condition_.empty(); } diff --git a/cxx/src/api/metadata/file.cpp b/cxx/src/api/metadata/file.cpp index 132ec0fa..c199ac3c 100644 --- a/cxx/src/api/metadata/file.cpp +++ b/cxx/src/api/metadata/file.cpp @@ -25,14 +25,16 @@ #include "loot/metadata/file.h" namespace loot { -File::File(const std::string& name, - const std::string& display, - const std::string& condition, - const std::vector& detail) : +File::File(std::string_view name, + std::string_view display, + std::string_view condition, + const std::vector& detail, + std::string_view constraint) : ConditionalMetadata(condition), name_(Filename(name)), display_(display), - detail_(detail) {} + detail_(detail), + constraint_(constraint) {} Filename File::GetName() const { return name_; } @@ -40,9 +42,12 @@ std::string File::GetDisplayName() const { return display_; } std::vector File::GetDetail() const { return detail_; } +std::string File::GetConstraint() const { return constraint_; } + bool operator==(const File& lhs, const File& rhs) { return lhs.GetDisplayName() == rhs.GetDisplayName() && lhs.GetCondition() == rhs.GetCondition() && + lhs.GetConstraint() == rhs.GetConstraint() && lhs.GetName() == rhs.GetName() && lhs.GetDetail() == rhs.GetDetail(); } @@ -65,6 +70,14 @@ bool operator<(const File& lhs, const File& rhs) { return false; } + if (lhs.GetConstraint() < rhs.GetConstraint()) { + return true; + } + + if (rhs.GetConstraint() < lhs.GetConstraint()) { + return false; + } + if (lhs.GetName() < rhs.GetName()) { return true; } diff --git a/cxx/src/api/metadata/filename.cpp b/cxx/src/api/metadata/filename.cpp index 34e9178e..54a34964 100644 --- a/cxx/src/api/metadata/filename.cpp +++ b/cxx/src/api/metadata/filename.cpp @@ -30,8 +30,7 @@ #include "libloot-cxx/src/lib.rs.h" namespace loot { -Filename::Filename(const std::string& filename) : - filename_(filename) {} +Filename::Filename(std::string_view filename) : filename_(filename) {} Filename::operator std::string() const { return filename_; } diff --git a/cxx/src/api/metadata/group.cpp b/cxx/src/api/metadata/group.cpp index a3117f16..4205d17f 100644 --- a/cxx/src/api/metadata/group.cpp +++ b/cxx/src/api/metadata/group.cpp @@ -25,9 +25,9 @@ #include "loot/metadata/group.h" namespace loot { -Group::Group(const std::string& name, +Group::Group(std::string_view name, const std::vector& afterGroups, - const std::string& description) : + std::string_view description) : name_(name), description_(description), afterGroups_(afterGroups) {} std::string Group::GetName() const { return name_; } diff --git a/cxx/src/api/metadata/location.cpp b/cxx/src/api/metadata/location.cpp index ac669ee6..7b540212 100644 --- a/cxx/src/api/metadata/location.cpp +++ b/cxx/src/api/metadata/location.cpp @@ -25,7 +25,7 @@ #include "loot/metadata/location.h" namespace loot { -Location::Location(const std::string& url, const std::string& name) : +Location::Location(std::string_view url, std::string_view name) : url_(url), name_(name) {} std::string Location::GetURL() const { return url_; } diff --git a/cxx/src/api/metadata/message.cpp b/cxx/src/api/metadata/message.cpp index ca152ab2..c3dec404 100644 --- a/cxx/src/api/metadata/message.cpp +++ b/cxx/src/api/metadata/message.cpp @@ -28,15 +28,15 @@ namespace loot { Message::Message(const MessageType type, - const std::string& content, - const std::string& condition) : + std::string_view content, + std::string_view condition) : ConditionalMetadata(condition), type_(type), content_({MessageContent(content)}) {} Message::Message(const MessageType type, const std::vector& content, - const std::string& condition) : + std::string_view condition) : ConditionalMetadata(condition), type_(type), content_(content) { if (content.size() > 1) { bool englishStringExists = false; diff --git a/cxx/src/api/metadata/message_content.cpp b/cxx/src/api/metadata/message_content.cpp index 4458551a..c184af48 100644 --- a/cxx/src/api/metadata/message_content.cpp +++ b/cxx/src/api/metadata/message_content.cpp @@ -25,8 +25,8 @@ #include "loot/metadata/message_content.h" namespace loot { -MessageContent::MessageContent(const std::string& text, - const std::string& language) : +MessageContent::MessageContent(std::string_view text, + std::string_view language) : text_(text), language_(language) {} std::string MessageContent::GetText() const { return text_; } @@ -68,7 +68,7 @@ bool operator>=(const MessageContent& lhs, const MessageContent& rhs) { std::optional SelectMessageContent( const std::vector content, - const std::string& language) { + std::string_view language) { if (content.empty()) return std::nullopt; else if (content.size() == 1) diff --git a/cxx/src/api/metadata/plugin_cleaning_data.cpp b/cxx/src/api/metadata/plugin_cleaning_data.cpp index 8dc5d9fc..3664d327 100644 --- a/cxx/src/api/metadata/plugin_cleaning_data.cpp +++ b/cxx/src/api/metadata/plugin_cleaning_data.cpp @@ -25,13 +25,12 @@ #include "loot/metadata/plugin_cleaning_data.h" namespace loot { -PluginCleaningData::PluginCleaningData(uint32_t crc, - const std::string& utility) : +PluginCleaningData::PluginCleaningData(uint32_t crc, std::string_view utility) : crc_(crc), utility_(utility) {} PluginCleaningData::PluginCleaningData( uint32_t crc, - const std::string& utility, + std::string_view utility, const std::vector& detail, unsigned int itm, unsigned int ref, diff --git a/cxx/src/api/metadata/plugin_metadata.cpp b/cxx/src/api/metadata/plugin_metadata.cpp index 2297c2a2..7430b95b 100644 --- a/cxx/src/api/metadata/plugin_metadata.cpp +++ b/cxx/src/api/metadata/plugin_metadata.cpp @@ -50,9 +50,10 @@ std::vector mergeVectors(std::vector first, return first; } -std::string TrimDotGhostExtension(const std::string& filename) { +std::string TrimDotGhostExtension(std::string&& filename) { + using std::string_view_literals::operator""sv; // If the name passed ends in '.ghost', that should be trimmed. - constexpr std::string_view GHOST_FILE_EXTENSION = ".ghost"; + constexpr std::string_view GHOST_FILE_EXTENSION = ".ghost"sv; if (filename.length() < GHOST_FILE_EXTENSION.length()) { return filename; @@ -77,10 +78,9 @@ std::string TrimDotGhostExtension(const std::string& filename) { } namespace loot { -PluginMetadata::PluginMetadata(const std::string& n) : name_(n) { - // If the name passed ends in '.ghost', that should be trimmed. - name_ = TrimDotGhostExtension(n); - +// If the name passed ends in '.ghost', that should be trimmed. +PluginMetadata::PluginMetadata(std::string_view n) : + name_(TrimDotGhostExtension(std::string(n))) { if (IsRegexPlugin()) { nameRegex_ = std::regex(name_, std::regex::ECMAScript | std::regex::icase); } @@ -144,7 +144,7 @@ std::vector PluginMetadata::GetLocations() const { return locations_; } -void PluginMetadata::SetGroup(const std::string& group) { group_ = group; } +void PluginMetadata::SetGroup(std::string_view group) { group_ = group; } void PluginMetadata::UnsetGroup() { group_ = std::nullopt; } @@ -192,16 +192,17 @@ bool PluginMetadata::IsRegexPlugin() const { return strpbrk(name_.c_str(), ":\\*?|") != nullptr; } -bool PluginMetadata::NameMatches(const std::string& pluginName) const { +bool PluginMetadata::NameMatches(std::string_view pluginName) const { if (IsRegexPlugin()) { if (!nameRegex_.has_value()) { throw std::runtime_error("Regex plugin does not have regex object"); } - return std::regex_match(pluginName, nameRegex_.value()); + return std::regex_match( + pluginName.begin(), pluginName.end(), nameRegex_.value()); } - return loot::rust::compare_filenames(name_, pluginName) == 0; + return loot::rust::compare_filenames(name_, convert(pluginName)) == 0; } std::string PluginMetadata::AsYaml() const { diff --git a/cxx/src/api/metadata/tag.cpp b/cxx/src/api/metadata/tag.cpp index 1a551ad5..80640d0b 100644 --- a/cxx/src/api/metadata/tag.cpp +++ b/cxx/src/api/metadata/tag.cpp @@ -25,9 +25,9 @@ #include "loot/metadata/tag.h" namespace loot { -Tag::Tag(const std::string& tag, +Tag::Tag(std::string_view tag, const bool isAddition, - const std::string& condition) : + std::string_view condition) : ConditionalMetadata(condition), name_(tag), addTag_(isAddition) {} bool Tag::IsAddition() const { return addTag_; } diff --git a/cxx/src/api/plugin.cpp b/cxx/src/api/plugin.cpp index 592a1a53..3446e8d1 100644 --- a/cxx/src/api/plugin.cpp +++ b/cxx/src/api/plugin.cpp @@ -38,13 +38,8 @@ std::vector Plugin::GetMasters() const { } } -std::vector Plugin::GetBashTags() const { - std::vector tags; - for (const auto& tag : plugin_->bash_tags()) { - tags.push_back(Tag(std::string(tag))); - } - - return tags; +std::vector Plugin::GetBashTags() const { + return convert(plugin_->bash_tags()); } std::optional Plugin::GetCRC() const { @@ -102,7 +97,9 @@ bool Plugin::DoRecordsOverlap(const PluginInterface& plugin) const { return plugin_->do_records_overlap(*otherPlugin.plugin_); } catch (std::bad_cast&) { - throw std::invalid_argument("Tried to check if records overlapped with a different concrete type implementing PluginInterface"); + throw std::invalid_argument( + "Tried to check if records overlapped with a different concrete type " + "implementing PluginInterface"); } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); } diff --git a/cxx/src/api/plugin.h b/cxx/src/api/plugin.h index 79eb6d40..32107e02 100644 --- a/cxx/src/api/plugin.h +++ b/cxx/src/api/plugin.h @@ -22,7 +22,7 @@ public: std::optional GetHeaderVersion() const override; std::optional GetVersion() const override; std::vector GetMasters() const override; - std::vector GetBashTags() const override; + std::vector GetBashTags() const override; std::optional GetCRC() const override; bool IsMaster() const override; diff --git a/cxx/src/api/resource.rc b/cxx/src/api/resource.rc index 80d6b3bd..a119a2db 100644 --- a/cxx/src/api/resource.rc +++ b/cxx/src/api/resource.rc @@ -13,12 +13,12 @@ BLOCK "040904b0" BEGIN VALUE "CompanyName", "LOOT" VALUE "FileDescription", "Library providing LOOT's core functionality" -VALUE "FileVersion", "0.25.5" +VALUE "FileVersion", "0.26.0" VALUE "InternalName", "loot" VALUE "LegalCopyright", "Copyright (C) 2013-2022 Oliver Hamlet" VALUE "OriginalFilename", "loot.dll" VALUE "ProductName", "LOOT" -VALUE "ProductVersion", "0.25.5" +VALUE "ProductVersion", "0.26.0" END END BLOCK "VarFileInfo" diff --git a/cxx/src/api/sorting/undefined_group_error.cpp b/cxx/src/api/sorting/undefined_group_error.cpp index 50fa72da..c62aadac 100644 --- a/cxx/src/api/sorting/undefined_group_error.cpp +++ b/cxx/src/api/sorting/undefined_group_error.cpp @@ -24,8 +24,9 @@ #include "loot/exception/undefined_group_error.h" namespace loot { -UndefinedGroupError::UndefinedGroupError(const std::string& groupName) : - std::runtime_error("The group \"" + groupName + "\" does not exist"), +UndefinedGroupError::UndefinedGroupError(std::string_view groupName) : + std::runtime_error("The group \"" + std::string(groupName) + + "\" does not exist"), groupName_(groupName) {} std::string UndefinedGroupError::GetGroupName() const { return groupName_; } diff --git a/cxx/src/api/vertex.cpp b/cxx/src/api/vertex.cpp index fb6a345b..ee1bf9af 100644 --- a/cxx/src/api/vertex.cpp +++ b/cxx/src/api/vertex.cpp @@ -24,9 +24,9 @@ #include "loot/vertex.h" namespace loot { -Vertex::Vertex(std::string name) : name_(name) {} +Vertex::Vertex(std::string_view name) : name_(name) {} -Vertex::Vertex(std::string name, EdgeType outEdgeType) : +Vertex::Vertex(std::string_view name, EdgeType outEdgeType) : name_(name), outEdgeType_(outEdgeType) {} std::string Vertex::GetName() const { return name_; } diff --git a/cxx/src/database.rs b/cxx/src/database.rs index 1b6fea2a..660c75ec 100644 --- a/cxx/src/database.rs +++ b/cxx/src/database.rs @@ -85,6 +85,14 @@ impl Database { .map_err(Into::into) } + pub fn evaluate(&self, condition: &str) -> Result { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .evaluate(condition) + .map_err(Into::into) + } + pub fn known_bash_tags(&self) -> Result, VerboseError> { Ok(self .0 diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index 4e7e80b3..66c965e5 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -232,6 +232,8 @@ mod ffi { pub fn write_minimal_list(&self, output_path: &str, overwrite: bool) -> Result<()>; + pub fn evaluate(&self, condition: &str) -> Result; + pub fn known_bash_tags(&self) -> Result>; pub fn general_messages(&self, evaluate_conditions: bool) -> Result>; @@ -489,6 +491,10 @@ mod ffi { pub fn set_condition(&mut self, condition: String); + pub fn constraint(&self) -> &str; + + pub fn set_constraint(&mut self, constraint: String); + pub fn boxed_clone(&self) -> Box; } diff --git a/cxx/src/metadata.rs b/cxx/src/metadata.rs index 13e22e50..7eff0ebb 100644 --- a/cxx/src/metadata.rs +++ b/cxx/src/metadata.rs @@ -408,6 +408,14 @@ impl File { self.0.set_condition(condition); } + pub fn constraint(&self) -> &str { + self.0.constraint().unwrap_or("") + } + + pub fn set_constraint(&mut self, constraint: String) { + self.0.set_constraint(constraint); + } + pub fn boxed_clone(&self) -> Box { Box::new(Self(self.0.clone())) } diff --git a/cxx/src/tests/api/interface/database_interface_test.h b/cxx/src/tests/api/interface/database_interface_test.h index 2a4511b2..2ee00b95 100644 --- a/cxx/src/tests/api/interface/database_interface_test.h +++ b/cxx/src/tests/api/interface/database_interface_test.h @@ -43,6 +43,25 @@ protected: ASSERT_FALSE(std::filesystem::exists(minimalOutputPath_)); } + std::string GetExpectedMinimalContent() const { + using std::endl; + + std::stringstream expectedContent; + expectedContent << "plugins:" << endl + << " - name: '" << blankDifferentEsm << "'" << endl + << " dirty:" << endl + << " - crc: 0x7D22F9DF" << endl + << " util: 'TES4Edit'" << endl + << " udr: 4" << endl + << " - name: '" << blankEsm << "'" << endl + << " tag:" << endl + << " - Actors.ACBS" << endl + << " - Actors.AIData" << endl + << " - -C.Water"; + + return expectedContent.str(); + } + std::string GetFileContent(const std::filesystem::path& file) { std::ifstream stream(file); std::stringstream content; @@ -102,58 +121,42 @@ INSTANTIATE_TEST_SUITE_P(, GameType::tes5se)); TEST_P(DatabaseInterfaceTest, - loadListsShouldSucceedEvenIfGameHandleIsDiscarded) { + loadMasterlistShouldSucceedEvenIfGameHandleIsDiscarded) { handle_ = CreateGameHandle(GetParam(), gamePath, localPath); ASSERT_NO_THROW(GenerateMasterlist()); - EXPECT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); -} - -TEST_P(DatabaseInterfaceTest, loadListsShouldThrowIfNoMasterlistIsPresent) { - EXPECT_THROW(handle_->GetDatabase().LoadLists(masterlistPath, ""), - FileAccessError); -} - -TEST_P( - DatabaseInterfaceTest, - loadListsShouldThrowIfAMasterlistIsPresentButAUserlistDoesNotExistAtTheGivenPath) { - ASSERT_NO_THROW(GenerateMasterlist()); - EXPECT_THROW(handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_), - FileAccessError); -} - -TEST_P( - DatabaseInterfaceTest, - loadListsShouldSucceedIfTheMasterlistIsPresentAndTheUserlistPathIsAnEmptyString) { - ASSERT_NO_THROW(GenerateMasterlist()); - - EXPECT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); + EXPECT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); } TEST_P(DatabaseInterfaceTest, - loadListsShouldSucceedIfTheMasterlistAndUserlistAreBothPresent) { - ASSERT_NO_THROW(GenerateMasterlist()); - ASSERT_NO_THROW(std::filesystem::copy(masterlistPath, userlistPath_)); + loadMasterlistShouldThrowIfNoMasterlistIsPresent) { + EXPECT_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath), + FileAccessError); +} - EXPECT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); +TEST_P(DatabaseInterfaceTest, + loadMasterlistShouldSucceedIfTheMasterlistIsPresent) { + ASSERT_NO_THROW(GenerateMasterlist()); + + EXPECT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); } TEST_P( DatabaseInterfaceTest, - loadListsShouldThrowIfAMasterlistIsPresentButAPreludeDoesNotExistAtTheGivenPath) { + loadMasterlistWithPreludeShouldThrowIfAMasterlistIsPresentButAPreludeDoesNotExistAtTheGivenPath) { ASSERT_NO_THROW(GenerateMasterlist()); auto preludePath = localPath / "prelude.yaml"; - EXPECT_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, "", preludePath), - FileAccessError); + EXPECT_THROW(handle_->GetDatabase().LoadMasterlistWithPrelude(masterlistPath, + preludePath), + FileAccessError); } -TEST_P(DatabaseInterfaceTest, - loadListsShouldSucceedIfTheMasterlistAndPreludeAreBothPresent) { +TEST_P( + DatabaseInterfaceTest, + loadMasterlistWithPreludeShouldSucceedIfTheMasterlistAndPreludeAreBothPresent) { using std::endl; std::ofstream out(masterlistPath); @@ -173,8 +176,8 @@ TEST_P(DatabaseInterfaceTest, << " type: say" << endl << " content: Loaded from prelude" << endl; - EXPECT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, "", preludePath)); + EXPECT_NO_THROW(handle_->GetDatabase().LoadMasterlistWithPrelude( + masterlistPath, preludePath)); auto messages = handle_->GetDatabase().GetGeneralMessages(); ASSERT_EQ(1, messages.size()); @@ -183,6 +186,20 @@ TEST_P(DatabaseInterfaceTest, EXPECT_EQ("Loaded from prelude", messages[0].GetContent()[0].GetText()); } +TEST_P(DatabaseInterfaceTest, + loadUserlistShouldThrowIfAUserlistDoesNotExistAtTheGivenPath) { + ASSERT_NO_THROW(GenerateMasterlist()); + EXPECT_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_), + FileAccessError); +} + +TEST_P(DatabaseInterfaceTest, loadUserlistShouldSucceedIfTheUserlistIsPresent) { + ASSERT_NO_THROW(GenerateMasterlist()); + ASSERT_NO_THROW(std::filesystem::copy(masterlistPath, userlistPath_)); + + EXPECT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); +} + TEST_P( DatabaseInterfaceTest, writeUserMetadataShouldThrowIfTheFileAlreadyExistsAndTheOverwriteArgumentIsFalse) { @@ -232,7 +249,7 @@ TEST_P(DatabaseInterfaceTest, TEST_P(DatabaseInterfaceTest, writeUserMetadataShouldShouldNotWriteMasterlistMetadata) { ASSERT_NO_THROW(GenerateMasterlist()); - ASSERT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); EXPECT_NO_THROW( handle_->GetDatabase().WriteUserMetadata(minimalOutputPath_, true)); @@ -248,8 +265,8 @@ TEST_P(DatabaseInterfaceTest, writeUserMetadataShouldShouldWriteUserMetadata) { masterlist << "bash_tags:\n []\nglobals:\n []\nplugins:\n []"; masterlist.close(); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); EXPECT_NO_THROW( handle_->GetDatabase().WriteUserMetadata(minimalOutputPath_, true)); @@ -257,13 +274,21 @@ TEST_P(DatabaseInterfaceTest, writeUserMetadataShouldShouldWriteUserMetadata) { EXPECT_FALSE(GetFileContent(minimalOutputPath_).empty()); } +TEST_P(DatabaseInterfaceTest, evaluateShouldReturnTrueIfTheConditionIsTrue) { + EXPECT_TRUE(handle_->GetDatabase().Evaluate("file(\"Blank.esp\")")); +} + +TEST_P(DatabaseInterfaceTest, evaluateShouldReturnFalseIfTheConditionIsFalse) { + EXPECT_FALSE(handle_->GetDatabase().Evaluate("file(\"missing.esp\")")); +} + TEST_P(DatabaseInterfaceTest, getGroupsShouldReturnAllGroupsListedInTheLoadedMetadata) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); auto groups = handle_->GetDatabase().GetGroups(); @@ -298,8 +323,8 @@ TEST_P(DatabaseInterfaceTest, ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); auto groups = handle_->GetDatabase().GetGroups(false); @@ -331,8 +356,8 @@ TEST_P(DatabaseInterfaceTest, ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); auto groups = handle_->GetDatabase().GetUserGroups(); @@ -354,8 +379,8 @@ TEST_P( ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); handle_->GetDatabase().SetUserGroups(std::vector({ Group("group4"), @@ -377,8 +402,8 @@ TEST_P(DatabaseInterfaceTest, ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); auto path = handle_->GetDatabase().GetGroupsPath("group1", "group3"); @@ -394,8 +419,8 @@ TEST_P(DatabaseInterfaceTest, ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); auto tags = handle_->GetDatabase().GetKnownBashTags(); @@ -412,8 +437,8 @@ TEST_P(DatabaseInterfaceTest, getGeneralMessagesShouldGetGeneralMessagesFromTheMasterlistAndUserlist) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); auto messages = handle_->GetDatabase().GetGeneralMessages(); @@ -430,7 +455,7 @@ TEST_P( DatabaseInterfaceTest, getGeneralMessagesShouldReturnOnlyValidMessagesIfConditionsAreEvaluated) { ASSERT_NO_THROW(GenerateMasterlist()); - ASSERT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); auto messages = handle_->GetDatabase().GetGeneralMessages(true); @@ -447,8 +472,8 @@ TEST_P( getPluginMetadataShouldReturnMergedMasterAndUserMetadataForTheGivenPluginIfIncludeUserMetadataIsTrue) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); auto metadata = handle_->GetDatabase().GetPluginMetadata(blankEsm, true).value(); @@ -464,8 +489,8 @@ TEST_P(DatabaseInterfaceTest, getPluginMetadataShouldPreferUserMetadataWhenMergingMetadata) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); auto metadata = handle_->GetDatabase().GetPluginMetadata(blankEsm, true).value(); @@ -486,8 +511,8 @@ TEST_P( getPluginMetadataShouldReturnOnlyMasterlistMetadataForTheGivenPluginIfIncludeUserMetadataIsFalse) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); auto metadata = handle_->GetDatabase().GetPluginMetadata(blankEsm, false).value(); @@ -502,7 +527,7 @@ TEST_P( DatabaseInterfaceTest, getPluginMetadataShouldReturnOnlyValidMetadataForTheGivenPluginIfConditionsAreEvaluated) { ASSERT_NO_THROW(GenerateMasterlist()); - ASSERT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); auto metadata = handle_->GetDatabase().GetPluginMetadata(blankEsm, false, true).value(); @@ -515,8 +540,8 @@ TEST_P( getPluginUserMetadataShouldReturnAnEmptyPluginMetadataObjectIfThePluginHasNoUserMetadata) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); EXPECT_FALSE(handle_->GetDatabase().GetPluginUserMetadata(blankDifferentEsm)); } @@ -525,8 +550,8 @@ TEST_P(DatabaseInterfaceTest, getPluginUserMetadataShouldReturnOnlyUserMetadataForTheGivenPlugin) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); auto metadata = handle_->GetDatabase().GetPluginUserMetadata(blankEsm).value(); @@ -542,8 +567,8 @@ TEST_P( getPluginUserMetadataShouldReturnOnlyValidMetadataForTheGivenPluginIfConditionsAreEvaluated) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); auto metadata = handle_->GetDatabase().GetPluginMetadata(blankEsm, false, true).value(); @@ -556,8 +581,8 @@ TEST_P( setPluginUserMetadataShouldReplaceExistingUserMetadataWithTheGivenMetadata) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); PluginMetadata newMetadata(blankDifferentEsp); newMetadata.SetRequirements(std::vector({File(masterFile)})); @@ -578,8 +603,8 @@ TEST_P(DatabaseInterfaceTest, setPluginUserMetadataShouldNotAffectExistingMasterlistMetadata) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); PluginMetadata newMetadata(blankEsm); newMetadata.SetRequirements(std::vector({File(masterFile)})); @@ -598,8 +623,8 @@ TEST_P(DatabaseInterfaceTest, discardPluginUserMetadataShouldDiscardAllUserMetadataForTheGivenPlugin) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); handle_->GetDatabase().DiscardPluginUserMetadata(blankEsm); @@ -611,8 +636,8 @@ TEST_P( discardPluginUserMetadataShouldNotDiscardMasterlistMetadataForTheGivenPlugin) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); handle_->GetDatabase().DiscardPluginUserMetadata(blankEsm); @@ -628,8 +653,8 @@ TEST_P(DatabaseInterfaceTest, discardPluginUserMetadataShouldNotDiscardUserMetadataForOtherPlugins) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); handle_->GetDatabase().DiscardPluginUserMetadata(blankEsm); @@ -643,8 +668,8 @@ TEST_P(DatabaseInterfaceTest, discardPluginUserMetadataShouldNotDiscardGeneralMessages) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); handle_->GetDatabase().DiscardPluginUserMetadata(blankEsm); @@ -663,8 +688,8 @@ TEST_P(DatabaseInterfaceTest, discardPluginUserMetadataShouldNotDiscardKnownBashTags) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); handle_->GetDatabase().DiscardPluginUserMetadata(blankEsm); @@ -684,8 +709,8 @@ TEST_P( discardAllUserMetadataShouldDiscardAllUserMetadataAndNoMasterlistMetadata) { ASSERT_NO_THROW(GenerateMasterlist()); ASSERT_NO_THROW(GenerateUserlist()); - ASSERT_NO_THROW( - handle_->GetDatabase().LoadLists(masterlistPath, userlistPath_)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); + ASSERT_NO_THROW(handle_->GetDatabase().LoadUserlist(userlistPath_)); handle_->GetDatabase().DiscardAllUserMetadata(); @@ -775,7 +800,7 @@ TEST_P(DatabaseInterfaceTest, using std::endl; ASSERT_NO_THROW(GenerateMasterlist()); - ASSERT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); EXPECT_NO_THROW( handle_->GetDatabase().WriteMinimalList(minimalOutputPath_, true)); diff --git a/cxx/src/tests/api/interface/game_interface_test.h b/cxx/src/tests/api/interface/game_interface_test.h index 4545d3dd..f5c699bf 100644 --- a/cxx/src/tests/api/interface/game_interface_test.h +++ b/cxx/src/tests/api/interface/game_interface_test.h @@ -235,7 +235,7 @@ TEST_P(GameInterfaceTest, sortPluginsShouldSucceedIfPassedValidArguments) { } ASSERT_NO_THROW(GenerateMasterlist()); - ASSERT_NO_THROW(handle_->GetDatabase().LoadLists(masterlistPath, "")); + ASSERT_NO_THROW(handle_->GetDatabase().LoadMasterlist(masterlistPath)); if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { pluginsToLoad.push_back(blankEsl); diff --git a/cxx/src/tests/api/interface/main.cpp b/cxx/src/tests/api/interface/main.cpp index a501bd5e..29027fef 100644 --- a/cxx/src/tests/api/interface/main.cpp +++ b/cxx/src/tests/api/interface/main.cpp @@ -37,12 +37,12 @@ int main(int argc, char **argv) { namespace loot { namespace test { -void testLoggingCallback(LogLevel, const char *) { +void testLoggingCallback(LogLevel, std::string_view) { // Do nothing. } struct TestLogger { - void callback(LogLevel, const char *message) { + void callback(LogLevel, std::string_view message) { loggedMessages += std::string(message); } @@ -56,7 +56,7 @@ TEST(SetLoggingCallback, shouldAcceptAFreeFunction) { CreateGameHandle(GameType::tes4, "dummy"); FAIL(); } catch (...) { - SetLoggingCallback([](LogLevel, const char *) {}); + SetLoggingCallback([](LogLevel, std::string_view) {}); } } @@ -77,13 +77,13 @@ TEST(SetLoggingCallback, shouldAcceptAMemberFunction) { "IV: Oblivion\" with game path \"dummy\"", testLogger.loggedMessages); - SetLoggingCallback([](LogLevel, const char *) {}); + SetLoggingCallback([](LogLevel, std::string_view) {}); } } TEST(SetLoggingCallback, shouldAcceptALambdaFunction) { std::string loggedMessages; - auto callback = [&](LogLevel, const char *string) { + auto callback = [&](LogLevel, std::string_view string) { loggedMessages += std::string(string); }; SetLoggingCallback(callback); @@ -97,7 +97,7 @@ TEST(SetLoggingCallback, shouldAcceptALambdaFunction) { "IV: Oblivion\" with game path \"dummy\"", loggedMessages); - SetLoggingCallback([](LogLevel, const char *) {}); + SetLoggingCallback([](LogLevel, std::string_view) {}); } } @@ -105,7 +105,7 @@ TEST(SetLoggingCallback, shouldNotBreakLoggingIfPassedLambdaFunctionGoesOutOfScope) { std::string loggedMessages; { - SetLoggingCallback([&](LogLevel, const char *string) { + SetLoggingCallback([&](LogLevel, std::string_view string) { loggedMessages += std::string(string); }); } @@ -119,7 +119,39 @@ TEST(SetLoggingCallback, "IV: Oblivion\" with game path \"dummy\"", loggedMessages); - SetLoggingCallback([](LogLevel, const char *) {}); + SetLoggingCallback([](LogLevel, std::string_view) {}); + } +} + +TEST(SetLogLevel, shouldOnlyRunTheCallbackForMessagesAtOrAboveTheGivenLevel) { + std::vector> loggedMessages; + auto callback = [&](LogLevel level, std::string_view string) { + loggedMessages.push_back(std::make_pair(level, std::string(string))); + }; + SetLoggingCallback(callback); + SetLogLevel(LogLevel::fatal); + + try { + CreateGameHandle(GameType::tes4, "dummy"); + FAIL(); + } catch (...) { + EXPECT_TRUE(loggedMessages.empty()); + } + + SetLogLevel(LogLevel::info); + + try { + CreateGameHandle(GameType::tes4, "dummy"); + FAIL(); + } catch (...) { + ASSERT_EQ(1, loggedMessages.size()); + EXPECT_EQ(LogLevel::info, loggedMessages[0].first); + EXPECT_EQ( + "Attempting to create a game handle for game type \"The Elder Scrolls " + "IV: Oblivion\" with game path \"dummy\"", + loggedMessages[0].second); + + SetLoggingCallback([](LogLevel, std::string_view) {}); } } } diff --git a/cxx/src/tests/api/internals/main.cpp b/cxx/src/tests/api/internals/main.cpp index e05ed9be..a2ed24fa 100644 --- a/cxx/src/tests/api/internals/main.cpp +++ b/cxx/src/tests/api/internals/main.cpp @@ -19,7 +19,7 @@ namespace loot::rust { TEST(libloot_version, shouldReturnExpectedValue) { auto version = libloot_version(); - EXPECT_EQ(version, "0.25.5"); + EXPECT_EQ(version, "0.26.0"); } TEST(libloot_revision, shouldReturnExpectedValue) { diff --git a/cxx/src/tests/common_game_test_fixture.h b/cxx/src/tests/common_game_test_fixture.h index 5288e62a..316a5068 100644 --- a/cxx/src/tests/common_game_test_fixture.h +++ b/cxx/src/tests/common_game_test_fixture.h @@ -185,7 +185,7 @@ protected: } void copyPlugin(const std::filesystem::path& sourceParentPath, - const std::string& filename) { + std::string_view filename) { std::filesystem::copy_file(sourceParentPath / filename, dataPath / filename); ASSERT_TRUE(std::filesystem::exists(dataPath / filename)); @@ -342,6 +342,21 @@ protected: blankDifferentEsp, blankMasterDependentEsp, }; + } else if (supportsLightPlugins(gameType_)) { + return { + masterFile, + blankEsm, + blankDifferentEsm, + blankMasterDependentEsm, + blankDifferentMasterDependentEsm, + blankEsl, + blankEsp, + blankDifferentEsp, + blankMasterDependentEsp, + blankDifferentMasterDependentEsp, + blankPluginDependentEsp, + blankDifferentPluginDependentEsp, + }; } else { return { masterFile, diff --git a/ffi-errors/Cargo.toml b/ffi-errors/Cargo.toml index 1cc1b72f..600bcc53 100644 --- a/ffi-errors/Cargo.toml +++ b/ffi-errors/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot-ffi-errors" -version = "0.25.5" +version = "0.26.0" edition = "2024" [dependencies] diff --git a/pyo3/Cargo.toml b/pyo3/Cargo.toml index fde8fe24..6579b46c 100644 --- a/pyo3/Cargo.toml +++ b/pyo3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot_pyo3" -version = "0.25.5" +version = "0.26.0" edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/pyo3/src/database.rs b/pyo3/src/database.rs index c1b30d16..e94bc6b9 100644 --- a/pyo3/src/database.rs +++ b/pyo3/src/database.rs @@ -85,6 +85,14 @@ impl Database { .map_err(Into::into) } + pub fn evaluate(&self, condition: &str) -> Result { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .evaluate(condition) + .map_err(Into::into) + } + pub fn known_bash_tags(&self) -> Result, VerboseError> { Ok(self .0 diff --git a/pyo3/src/metadata.rs b/pyo3/src/metadata.rs index 9c82e88b..826d0227 100644 --- a/pyo3/src/metadata.rs +++ b/pyo3/src/metadata.rs @@ -305,12 +305,13 @@ pub struct File(libloot::metadata::File); #[pymethods] impl File { #[new] - #[pyo3(signature = (name, display_name = None, detail = None, condition = None))] + #[pyo3(signature = (name, display_name = None, detail = None, condition = None, constraint = None))] fn new( name: String, display_name: Option, detail: Option>, condition: Option, + constraint: Option, ) -> Result { let mut file = libloot::metadata::File::new(name); @@ -327,6 +328,10 @@ impl File { file.set_condition(condition); } + if let Some(constraint) = constraint { + file.set_constraint(constraint); + } + Ok(Self(file)) } @@ -367,16 +372,27 @@ impl File { self.0.set_condition(condition); } + #[getter] + fn constraint(&self) -> Option<&str> { + self.0.constraint() + } + + #[setter] + fn set_constraint(&mut self, constraint: String) { + self.0.set_constraint(constraint); + } + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { let class_name = slf.get_type().qualname()?; let inner = &slf.borrow().0; Ok(format!( - "{}({}, {}, {}, {})", + "{}({}, {}, {}, {}, {})", class_name, inner.name(), inner.display_name().unwrap_or(NONE_REPR), repr_message_contents(inner.detail()), - inner.condition().unwrap_or(NONE_REPR) + inner.condition().unwrap_or(NONE_REPR), + inner.constraint().unwrap_or(NONE_REPR) )) } diff --git a/src/database/conditions.rs b/src/database/conditions.rs index 9ce0a71a..d30a3e5a 100644 --- a/src/database/conditions.rs +++ b/src/database/conditions.rs @@ -57,12 +57,19 @@ pub fn evaluate_all_conditions( } } -fn evaluate_condition( +pub fn evaluate_condition( + condition: &str, + state: &loot_condition_interpreter::State, +) -> Result { + Expression::from_str(condition).and_then(|e| e.eval(state)) +} + +fn evaluate_condition_option( condition: Option<&str>, state: &loot_condition_interpreter::State, ) -> Result { if let Some(condition) = condition { - Expression::from_str(condition).and_then(|e| e.eval(state)) + evaluate_condition(condition, state) } else { Ok(true) } @@ -73,7 +80,7 @@ pub fn filter_map_on_condition( condition: Option<&str>, state: &loot_condition_interpreter::State, ) -> Option> { - evaluate_condition(condition, state) + evaluate_condition_option(condition, state) .map(|r| r.then(|| item.clone())) .transpose() } diff --git a/src/database/mod.rs b/src/database/mod.rs index c57d546a..f08f78f2 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -3,7 +3,7 @@ mod error; use std::{collections::HashMap, path::Path}; -use conditions::{evaluate_all_conditions, filter_map_on_condition}; +use conditions::{evaluate_all_conditions, evaluate_condition, filter_map_on_condition}; use crate::{ logging, @@ -127,6 +127,11 @@ impl Database { doc.save(output_path) } + /// Evaluate the given condition string. + pub fn evaluate(&self, condition: &str) -> Result { + evaluate_condition(condition, &self.condition_evaluator_state).map_err(Into::into) + } + /// Gets the Bash Tags that are listed in the loaded metadata lists. /// /// Bash Tag suggestions can include Bash Tags not in this list. @@ -803,6 +808,26 @@ plugins: } } + mod evaluate { + use super::*; + + #[test] + fn should_return_true_if_the_condition_is_true() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + + assert!(database.evaluate("file(\"Blank.esp\")").unwrap()); + } + + #[test] + fn should_return_false_if_the_condition_is_false() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + + assert!(!database.evaluate("file(\"missing.esp\")").unwrap()); + } + } + mod groups { use super::*; diff --git a/src/error.rs b/src/error.rs index 00d998dd..fc9a4a71 100644 --- a/src/error.rs +++ b/src/error.rs @@ -273,3 +273,11 @@ impl From for SortPluginsError { SortPluginsError::MetadataRetrievalError(value) } } + +impl From for SortPluginsError { + fn from(value: ConditionEvaluationError) -> Self { + SortPluginsError::MetadataRetrievalError(MetadataRetrievalError::ConditionEvaluationError( + value, + )) + } +} diff --git a/src/game.rs b/src/game.rs index dff8dd91..25c2333e 100644 --- a/src/game.rs +++ b/src/game.rs @@ -455,17 +455,7 @@ impl Game { let plugins_sorting_data = plugins .into_iter() .enumerate() - .map(|(i, p)| { - let masterlist_metadata = database.plugin_metadata(p.name(), false, true)?; - let user_metadata = database.plugin_user_metadata(p.name(), true)?; - let plugin = PluginSortingData::new( - p.as_ref(), - masterlist_metadata.as_ref(), - user_metadata.as_ref(), - i, - )?; - Ok::<_, SortPluginsError>(plugin) - }) + .map(|(i, p)| to_plugin_sorting_data(&database, p, i)) .collect::, _>>()?; if is_log_enabled(LogLevel::Debug) { @@ -747,6 +737,30 @@ fn update_loaded_plugin_state<'a>( } } +fn to_plugin_sorting_data<'a>( + database: &Database, + plugin: &'a Arc, + load_order_index: usize, +) -> Result, SortPluginsError> { + let masterlist_metadata = database + .plugin_metadata(plugin.name(), false, true)? + .map(|m| m.filter_by_constraints(database)) + .transpose()?; + + let user_metadata = database + .plugin_user_metadata(plugin.name(), true)? + .map(|m| m.filter_by_constraints(database)) + .transpose()?; + + PluginSortingData::new( + plugin.as_ref(), + masterlist_metadata.as_ref(), + user_metadata.as_ref(), + load_order_index, + ) + .map_err(Into::into) +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct GameCache { plugins: HashMap>, @@ -793,7 +807,13 @@ mod tests { use rstest_reuse::apply; - use crate::tests::{Fixture, all_game_types}; + use crate::{ + metadata::{File, PluginMetadata}, + tests::{ + BLANK_DIFFERENT_ESM, BLANK_DIFFERENT_ESP, BLANK_ESM, BLANK_ESP, + BLANK_MASTER_DEPENDENT_ESM, Fixture, all_game_types, + }, + }; mod game { use std::path::Component; @@ -2027,6 +2047,58 @@ mod tests { } } + #[test] + fn to_plugin_sorting_data_should_filter_out_files_with_false_constraints() { + let game_type = GameType::TES4; + let true_constraint = "file(\"Blank.esm\")"; + let false_constraint = "file(\"missing.esm\")"; + + let fixture = Fixture::new(game_type); + + let plugin = Arc::new( + Plugin::new( + game_type, + &GameCache::default(), + &fixture.data_path().join(BLANK_ESP), + LoadScope::HeaderOnly, + ) + .unwrap(), + ); + + let mut database = Database::new(loot_condition_interpreter::State::new( + game_type.into(), + fixture.data_path(), + )); + + let masterlist_path = fixture.local_path.join("masterlist.yaml"); + let masterlist = format!( + "{{plugins: [{{name: Blank.esp, after: [{{name: A.esp, constraint: '{true_constraint}'}}, {{name: B.esp, constraint: '{false_constraint}'}}], req: [{{name: C.esp, constraint: '{true_constraint}'}}, {{name: D.esp, constraint: '{false_constraint}'}}]}}]}}" + ); + std::fs::write(&masterlist_path, masterlist).unwrap(); + + database.load_masterlist(&masterlist_path).unwrap(); + + let mut user_metadata = PluginMetadata::new(BLANK_ESP).unwrap(); + user_metadata.set_load_after_files(vec![ + File::new(BLANK_ESM.to_owned()).with_constraint(true_constraint.to_owned()), + File::new(BLANK_DIFFERENT_ESM.to_owned()).with_constraint(false_constraint.to_owned()), + ]); + user_metadata.set_requirements(vec![ + File::new(BLANK_DIFFERENT_ESP.to_owned()).with_constraint(true_constraint.to_owned()), + File::new(BLANK_MASTER_DEPENDENT_ESM.to_owned()) + .with_constraint(false_constraint.to_owned()), + ]); + + database.set_plugin_user_metadata(user_metadata); + + let data = to_plugin_sorting_data(&database, &plugin, 0).unwrap(); + + assert_eq!(["A.esp".to_owned()], *data.masterlist_load_after); + assert_eq!(["C.esp".to_owned()], *data.masterlist_req); + assert_eq!([BLANK_ESM.to_owned()], *data.user_load_after); + assert_eq!([BLANK_DIFFERENT_ESP.to_owned()], *data.user_req); + } + mod game_cache { use super::*; diff --git a/src/metadata/file.rs b/src/metadata/file.rs index 06aa7137..0a9d1f75 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -21,6 +21,7 @@ pub struct File { display_name: Option>, detail: Box<[MessageContent]>, condition: Option>, + constraint: Option>, } impl File { @@ -59,6 +60,13 @@ impl File { Ok(self) } + /// Set the constraint string. + #[must_use] + pub fn with_constraint(mut self, constraint: String) -> Self { + self.set_constraint(constraint); + self + } + /// Gets the name of the file (which may actually be a path). pub fn name(&self) -> &Filename { &self.name @@ -107,6 +115,17 @@ impl File { self.condition = Some(condition.into_boxed_str()); self } + + /// Get the constraint string. + pub fn constraint(&self) -> Option<&str> { + self.constraint.as_deref() + } + + /// Set the constraint string. + pub fn set_constraint(&mut self, constraint: String) -> &mut Self { + self.constraint = Some(constraint.into_boxed_str()); + self + } } /// Represents a case-insensitive filename. @@ -172,6 +191,7 @@ impl TryFromYaml for File { display_name: None, detail: Box::default(), condition: None, + constraint: None, }), YamlData::Mapping(h) => { let name = @@ -188,13 +208,16 @@ impl TryFromYaml for File { None => Box::default(), }; - let condition = parse_condition(h, YamlObjectType::File)?; + let condition = parse_condition(h, "condition", YamlObjectType::File)?; + + let constraint = parse_condition(h, "constraint", YamlObjectType::File)?; Ok(File { name: Filename::new(name.to_string()), display_name: display_name.map(|(_, s)| s.into()), detail, condition, + constraint, }) } _ => Err(ParseMetadataError::unexpected_type( @@ -208,7 +231,10 @@ impl TryFromYaml for File { impl EmitYaml for File { fn is_scalar(&self) -> bool { - self.condition.is_none() && self.detail.is_empty() && self.display_name.is_none() + self.condition.is_none() + && self.constraint.is_none() + && self.detail.is_empty() + && self.display_name.is_none() } fn emit_yaml(&self, emitter: &mut YamlEmitter) { @@ -230,6 +256,11 @@ impl EmitYaml for File { emitter.single_quoted_str(condition); } + if let Some(constraint) = &self.constraint { + emitter.map_key("constraint"); + emitter.single_quoted_str(constraint); + } + emit_message_contents(&self.detail, emitter, "detail"); emitter.end_map(); @@ -277,6 +308,7 @@ mod tests { assert_eq!("name1", file.name().as_str()); assert!(file.display_name().is_none()); assert!(file.condition().is_none()); + assert!(file.constraint().is_none()); assert!(file.detail().is_empty()); } @@ -301,10 +333,17 @@ mod tests { assert!(File::try_from_yaml(&yaml).is_err()); } + #[test] + fn should_error_if_given_an_invalid_constraint() { + let yaml = parse("{name: name1, constraint: invalid}"); + + assert!(File::try_from_yaml(&yaml).is_err()); + } + #[test] fn should_set_all_given_fields() { let yaml = parse( - "{name: name1, display: display1, condition: 'file(\"Foo.esp\")', detail: 'details'}", + "{name: name1, display: display1, condition: 'file(\"Foo.esp\")', constraint: 'file(\"Bar.esp\")', detail: 'details'}", ); let file = File::try_from_yaml(&yaml).unwrap(); @@ -312,6 +351,7 @@ mod tests { assert_eq!("name1", file.name().as_str()); assert_eq!("display1", file.display_name().unwrap()); assert_eq!("file(\"Foo.esp\")", file.condition().unwrap()); + assert_eq!("file(\"Bar.esp\")", file.constraint().unwrap()); assert_eq!(&[MessageContent::new("details".into())], file.detail()); } @@ -324,6 +364,7 @@ mod tests { assert_eq!("name1", file.name().as_str()); assert!(file.display_name().is_none()); assert!(file.condition().is_none()); + assert!(file.constraint().is_none()); assert!(file.detail().is_empty()); } @@ -409,6 +450,21 @@ mod tests { ); } + #[test] + fn should_emit_map_with_constraint_if_it_is_not_empty() { + let file = File::new("filename".into()).with_constraint("constraint1".into()); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}'\nconstraint: '{}'", + file.name.as_str(), + file.constraint.unwrap() + ), + yaml + ); + } + #[test] fn should_emit_map_with_a_detail_string_if_detail_is_monolingual() { let file = File::new("filename".into()) @@ -459,6 +515,7 @@ detail: let file = File::new("filename".into()) .with_display_name("display1".into()) .with_condition("condition1".into()) + .with_constraint("constraint1".into()) .with_detail(vec![ MessageContent::new("english".into()).with_language("en".into()), MessageContent::new("french".into()).with_language("fr".into()), @@ -471,6 +528,7 @@ detail: "name: '{}' display: '{}' condition: '{}' +constraint: '{}' detail: - lang: {} text: '{}' @@ -479,6 +537,7 @@ detail: file.name.as_str(), file.display_name.unwrap(), file.condition.unwrap(), + file.constraint.unwrap(), file.detail[0].language(), file.detail[0].text(), file.detail[1].language(), diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 8ed1d615..cf5070e7 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -364,7 +364,7 @@ impl TryFromYaml for Message { } } - let condition = parse_condition(mapping, YamlObjectType::Message)?; + let condition = parse_condition(mapping, "condition", YamlObjectType::Message)?; Ok(Message { message_type, diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 7a991eb3..1f2bfd9d 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -3,7 +3,7 @@ use std::borrow::Cow; use fancy_regex::{Error as RegexImplError, Regex}; use saphyr::MarkedYaml; -use crate::{case_insensitive_regex, logging}; +use crate::{Database, case_insensitive_regex, error::ConditionEvaluationError, logging}; use super::{ error::{MetadataParsingErrorReason, ParseMetadataError, RegexError}, @@ -217,6 +217,32 @@ impl PluginMetadata { self.emit_yaml(&mut emitter); emitter.into_string() } + + pub(crate) fn filter_by_constraints( + mut self, + database: &Database, + ) -> Result { + self.load_after = filter_files_by_constraint(self.load_after, database)?; + self.requirements = filter_files_by_constraint(self.requirements, database)?; + + Ok(self) + } +} + +fn filter_files_by_constraint( + files: Box<[File]>, + database: &Database, +) -> Result, ConditionEvaluationError> { + files + .into_iter() + .filter_map(|f| { + if let Some(c) = f.constraint() { + database.evaluate(c).map(|r| r.then_some(f)).transpose() + } else { + Some(Ok(f)) + } + }) + .collect() } #[derive(Clone, Debug, Default)] diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index 92c8965b..be2b7e72 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -79,7 +79,7 @@ impl TryFromYaml for Tag { let name = get_required_string_value(value.span.start, h, "name", YamlObjectType::Tag)?; - let condition = parse_condition(h, YamlObjectType::Tag)?; + let condition = parse_condition(h, "condition", YamlObjectType::Tag)?; let (name, suggestion) = name_and_suggestion(name); Ok(Tag { diff --git a/src/metadata/yaml/parse.rs b/src/metadata/yaml/parse.rs index e8e5b564..1605549c 100644 --- a/src/metadata/yaml/parse.rs +++ b/src/metadata/yaml/parse.rs @@ -180,9 +180,10 @@ pub fn get_as_slice<'a>( pub fn parse_condition( mapping: &saphyr::AnnotatedMapping, + key: &'static str, yaml_type: YamlObjectType, ) -> Result>, ParseMetadataError> { - match get_string_value(mapping, "condition", yaml_type)? { + match get_string_value(mapping, key, yaml_type)? { Some((marker, s)) => { let s = s.to_string(); if let Err(e) = Expression::from_str(&s) { diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 57c393d7..2798d776 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -34,10 +34,10 @@ pub struct PluginSortingData<'a, T: SortingPlugin> { pub(super) group: Box, group_is_user_metadata: bool, - pub(super) masterlist_load_after: Box<[String]>, - pub(super) user_load_after: Box<[String]>, - pub(super) masterlist_req: Box<[String]>, - pub(super) user_req: Box<[String]>, + pub(crate) masterlist_load_after: Box<[String]>, + pub(crate) user_load_after: Box<[String]>, + pub(crate) masterlist_req: Box<[String]>, + pub(crate) user_req: Box<[String]>, } impl<'a, T: SortingPlugin> PluginSortingData<'a, T> { From 2ce3a6db3adbad9c02cdd9cd7e93bcf62ce87e68 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 20 Apr 2025 15:59:08 +0100 Subject: [PATCH 081/206] Fix conversion to Rust structs The optional fields were being set even if they were empty on the C++ side. --- cxx/src/api/convert.cpp | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/cxx/src/api/convert.cpp b/cxx/src/api/convert.cpp index b9546d35..2cd36fe9 100644 --- a/cxx/src/api/convert.cpp +++ b/cxx/src/api/convert.cpp @@ -156,7 +156,10 @@ loot::Vertex convert(const loot::rust::Vertex& vertex) { ::rust::Box convert(const loot::File& file) { auto output = loot::rust::new_file(std::string(file.GetName())); - output->set_display_name(file.GetDisplayName()); + + if (!file.GetDisplayName().empty()) { + output->set_display_name(file.GetDisplayName()); + } try { output->set_detail( @@ -165,9 +168,13 @@ loot::Vertex convert(const loot::rust::Vertex& vertex) { std::rethrow_exception(mapError(e)); } - output->set_condition(file.GetCondition()); + if (file.IsConditional()) { + output->set_condition(file.GetCondition()); + } - output->set_constraint(file.GetConstraint()); + if (!file.GetConstraint().empty()) { + output->set_constraint(file.GetConstraint()); + } return output; } @@ -199,7 +206,10 @@ loot::rust::MessageType convert(loot::MessageType messageType) { convert(message.GetType()), ::rust::Slice( convert(message.GetContent()))); - output->set_condition(message.GetCondition()); + + if (message.IsConditional()) { + output->set_condition(message.GetCondition()); + } return output; } catch (const ::rust::Error& e) { @@ -213,7 +223,10 @@ loot::rust::MessageType convert(loot::MessageType messageType) { ? loot::rust::TagSuggestion::Addition : loot::rust::TagSuggestion::Removal; auto output = loot::rust::new_tag(tag.GetName(), suggestion); - output->set_condition(tag.GetCondition()); + + if (tag.IsConditional()) { + output->set_condition(tag.GetCondition()); + } return output; } catch (const ::rust::Error& e) { @@ -241,7 +254,10 @@ loot::rust::MessageType convert(loot::MessageType messageType) { ::rust::Box convert(const loot::Location& location) { auto output = loot::rust::new_location(location.GetURL()); - output->set_name(location.GetName()); + + if (!location.GetName().empty()) { + output->set_name(location.GetName()); + } return output; } From ed77fa9fbfd6b3f5ed4ecb7079f6e0859812a873 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 20 Apr 2025 16:00:22 +0100 Subject: [PATCH 082/206] Add C++ tests for the C++ API metadata types Taken from the same files in libloot v0.26.0, with the YAML tests removed. --- cxx/cmake/tests.cmake | 19 +- cxx/src/tests/api/internals/main.cpp | 10 + .../metadata/condition_evaluator_test.h | 267 ++++++++++++ .../metadata/conditional_metadata_test.h | 77 ++++ .../tests/api/internals/metadata/file_test.h | 386 +++++++++++++++++ .../tests/api/internals/metadata/group_test.h | 355 ++++++++++++++++ .../api/internals/metadata/location_test.h | 239 +++++++++++ .../internals/metadata/message_content_test.h | 335 +++++++++++++++ .../api/internals/metadata/message_test.h | 347 +++++++++++++++ .../metadata/plugin_cleaning_data_test.h | 315 ++++++++++++++ .../internals/metadata/plugin_metadata_test.h | 397 ++++++++++++++++++ .../tests/api/internals/metadata/tag_test.h | 289 +++++++++++++ 12 files changed, 3031 insertions(+), 5 deletions(-) create mode 100644 cxx/src/tests/api/internals/metadata/condition_evaluator_test.h create mode 100644 cxx/src/tests/api/internals/metadata/conditional_metadata_test.h create mode 100644 cxx/src/tests/api/internals/metadata/file_test.h create mode 100644 cxx/src/tests/api/internals/metadata/group_test.h create mode 100644 cxx/src/tests/api/internals/metadata/location_test.h create mode 100644 cxx/src/tests/api/internals/metadata/message_content_test.h create mode 100644 cxx/src/tests/api/internals/metadata/message_test.h create mode 100644 cxx/src/tests/api/internals/metadata/plugin_cleaning_data_test.h create mode 100644 cxx/src/tests/api/internals/metadata/plugin_metadata_test.h create mode 100644 cxx/src/tests/api/internals/metadata/tag_test.h diff --git a/cxx/cmake/tests.cmake b/cxx/cmake/tests.cmake index 4ff94610..8eed90d0 100644 --- a/cxx/cmake/tests.cmake +++ b/cxx/cmake/tests.cmake @@ -36,7 +36,16 @@ set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_INITIAL}) set(LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES "${CMAKE_SOURCE_DIR}/src/tests/api/internals/main.cpp") -# set(LIBLOOT_SRC_TESTS_INTERNALS_H_FILES ) +set(LIBLOOT_SRC_TESTS_INTERNALS_H_FILES + "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/conditional_metadata_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/file_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/group_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/location_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/message_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/message_content_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/plugin_cleaning_data_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/plugin_metadata_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/tag_test.h") set(LIBLOOT_SRC_TESTS_INTERFACE_CPP_FILES "${CMAKE_SOURCE_DIR}/src/tests/api/interface/main.cpp") @@ -52,9 +61,9 @@ source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" PREFIX "Source Files" FILES ${LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES}) -# source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" -# PREFIX "Header Files" -# FILES ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES}) +source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" + PREFIX "Header Files" + FILES ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES}) source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/interface" PREFIX "Source Files" @@ -68,7 +77,7 @@ source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/interface" set(LIBLOOT_INTERNALS_TESTS_ALL_SOURCES ${LIBLOOT_ALL_SOURCES} ${LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES} - # ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES} + ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES} "${CMAKE_SOURCE_DIR}/src/tests/common_game_test_fixture.h" "${CMAKE_SOURCE_DIR}/src/tests/test_helpers.h" "${CMAKE_SOURCE_DIR}/src/tests/printers.h") diff --git a/cxx/src/tests/api/internals/main.cpp b/cxx/src/tests/api/internals/main.cpp index a2ed24fa..9ecbe6a8 100644 --- a/cxx/src/tests/api/internals/main.cpp +++ b/cxx/src/tests/api/internals/main.cpp @@ -5,6 +5,16 @@ #include "libloot-cxx/src/lib.rs.h" #include "rust/cxx.h" +#include "tests/api/internals/metadata/conditional_metadata_test.h" +#include "tests/api/internals/metadata/file_test.h" +#include "tests/api/internals/metadata/group_test.h" +#include "tests/api/internals/metadata/location_test.h" +#include "tests/api/internals/metadata/message_content_test.h" +#include "tests/api/internals/metadata/message_test.h" +#include "tests/api/internals/metadata/plugin_cleaning_data_test.h" +#include "tests/api/internals/metadata/plugin_metadata_test.h" +#include "tests/api/internals/metadata/tag_test.h" + namespace rust { template bool operator==(const Vec& lhs, const Vec& rhs) { diff --git a/cxx/src/tests/api/internals/metadata/condition_evaluator_test.h b/cxx/src/tests/api/internals/metadata/condition_evaluator_test.h new file mode 100644 index 00000000..deab40c3 --- /dev/null +++ b/cxx/src/tests/api/internals/metadata/condition_evaluator_test.h @@ -0,0 +1,267 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERNALS_METADATA_CONDITION_EVALUATOR_TEST +#define LOOT_TESTS_API_INTERNALS_METADATA_CONDITION_EVALUATOR_TEST + +#include "api/metadata/condition_evaluator.h" +#include "loot/exception/condition_syntax_error.h" +#include "tests/common_game_test_fixture.h" + +namespace loot { +namespace test { +class ConditionEvaluatorTest : public CommonGameTestFixture, + public testing::WithParamInterface { +protected: + ConditionEvaluatorTest() : + CommonGameTestFixture(GetParam()), + info_(std::vector({ + MessageContent("info"), + })), + nonAsciiEsm(u8"non\u00C1scii.esm"), + nonAsciiNestedFile(u8"non\u00C1scii/test.txt"), + game_(GetParam(), gamePath, localPath), + evaluator_(game_.GetType(), game_.DataPath()) { + // Make sure the plugin with a non-ASCII filename exists. + std::filesystem::copy_file(dataPath / blankEsm, + dataPath / std::filesystem::u8path(nonAsciiEsm)); + + touch(dataPath / std::filesystem::u8path(nonAsciiNestedFile)); + + loadInstalledPlugins(); + evaluator_.RefreshLoadedPluginsState(game_.GetLoadedPlugins()); + evaluator_.RefreshActivePluginsState( + game_.GetLoadOrderHandler().GetActivePlugins()); + } + + std::string IntToHexString(const uint32_t value) { + std::stringstream stream; + stream << std::hex << value; + return stream.str(); + } + + void loadInstalledPlugins() { + auto plugins = GetInstalledPlugins(); + + plugins.push_back(std::filesystem::u8path(nonAsciiEsm)); + + game_.LoadCurrentLoadOrderState(); + game_.LoadPlugins(plugins, true); + } + + const std::vector info_; + const std::string nonAsciiEsm; + const std::string nonAsciiNestedFile; + + Game game_; + ConditionEvaluator evaluator_; +}; + +// Pass an empty first argument, as it's a prefix for the test instantation, +// but we only have the one so no prefix is necessary. +INSTANTIATE_TEST_SUITE_P(, + ConditionEvaluatorTest, + ::testing::Values(GameType::tes3, + GameType::tes4, + GameType::tes5, + GameType::fo3, + GameType::fonv, + GameType::fo4, + GameType::tes5se, + GameType::openmw)); + +TEST_P(ConditionEvaluatorTest, + evaluateShouldReturnTrueForAnEmptyConditionString) { + EXPECT_TRUE(evaluator_.Evaluate("")); +} + +TEST_P(ConditionEvaluatorTest, evaluateShouldThrowForAnInvalidConditionString) { + EXPECT_THROW(evaluator_.Evaluate("condition"), ConditionSyntaxError); +} + +TEST_P(ConditionEvaluatorTest, + evaluateShouldReturnTrueForAConditionThatIsTrue) { + EXPECT_TRUE(evaluator_.Evaluate("file(\"" + blankEsm + "\")")); +} + +TEST_P(ConditionEvaluatorTest, evaluateShouldUseAllGivenDataPaths) { + ASSERT_FALSE( + evaluator_.Evaluate("file(\"" + localPath.filename().u8string() + "\")")); + + evaluator_.ClearConditionCache(); + evaluator_.SetAdditionalDataPaths({localPath.parent_path()}); + + EXPECT_TRUE( + evaluator_.Evaluate("file(\"" + localPath.filename().u8string() + "\")")); +} + +TEST_P(ConditionEvaluatorTest, + evaluateFileConditionShouldReturnTrueForANonAsciiFileThatExists) { + EXPECT_TRUE(evaluator_.Evaluate("file(\"" + nonAsciiEsm + "\")")); +} + +TEST_P(ConditionEvaluatorTest, + evaluateChecksumConditionShouldBeAbleToGetTheCrcOfANonAsciiFile) { + std::string condition("checksum(\"" + nonAsciiEsm + "\", " + + IntToHexString(blankEsmCrc) + ")"); + EXPECT_TRUE(evaluator_.Evaluate(condition)); +} + +TEST_P(ConditionEvaluatorTest, + evaluateVersionConditionShouldBeAbleToGetTheVersionOfANonAsciiFile) { + std::string condition("version(\"" + nonAsciiEsm + "\", \"5.0\", ==)"); + EXPECT_TRUE(evaluator_.Evaluate(condition)); +} + +TEST_P(ConditionEvaluatorTest, + evaluateActiveConditionShouldReturnTrueForAnActivePlugin) { + std::string condition("active(\"" + blankEsm + "\")"); + EXPECT_TRUE(evaluator_.Evaluate(condition)); +} + +TEST_P(ConditionEvaluatorTest, + evaluateRegexFileConditionShouldReturnTrueForANonAsciiFileThatExists) { + std::string condition(u8"file(\"non\u00C1scii.*\\.esm\")"); + EXPECT_TRUE(evaluator_.Evaluate(condition)); +} + +TEST_P( + ConditionEvaluatorTest, + evaluateRegexFileConditionShouldReturnTrueForANonAsciiNestedFileThatExists) { + std::string condition(u8"file(\"non\u00C1scii/.+\\.txt\")"); + EXPECT_TRUE(evaluator_.Evaluate(condition)); +} + +TEST_P(ConditionEvaluatorTest, + evaluateShouldReturnFalseForAConditionThatIsFalse) { + EXPECT_FALSE(evaluator_.Evaluate("file(\"" + missingEsp + "\")")); +} + +TEST_P(ConditionEvaluatorTest, evaluateAllShouldEvaluateAllMetadataConditions) { + PluginMetadata plugin(nonAsciiEsm); + plugin.SetGroup("group1"); + + File file1(blankEsp); + File file2(blankDifferentEsm, "", "file(\"" + missingEsp + "\")"); + plugin.SetLoadAfterFiles({file1, file2}); + plugin.SetRequirements({file1, file2}); + plugin.SetIncompatibilities({file1, file2}); + + Message message1(MessageType::say, "content"); + Message message2(MessageType::say, "content", "file(\"" + missingEsp + "\")"); + plugin.SetMessages({message1, message2}); + + Tag tag1("Relev"); + Tag tag2("Relev", true, "file(\"" + missingEsp + "\")"); + plugin.SetTags({tag1, tag2}); + + PluginCleaningData info1(blankEsmCrc, "utility", info_, 1, 2, 3); + PluginCleaningData info2(0xDEADBEEF, "utility", info_, 1, 2, 3); + plugin.SetDirtyInfo({info1, info2}); + plugin.SetCleanInfo({info1, info2}); + + EXPECT_NO_THROW(plugin = evaluator_.EvaluateAll(plugin)); + + std::vector expectedFiles({file1}); + EXPECT_EQ("group1", plugin.GetGroup().value()); + EXPECT_EQ(expectedFiles, plugin.GetLoadAfterFiles()); + EXPECT_EQ(expectedFiles, plugin.GetRequirements()); + EXPECT_EQ(expectedFiles, plugin.GetIncompatibilities()); + EXPECT_EQ(std::vector({message1}), plugin.GetMessages()); + EXPECT_EQ(std::vector({tag1}), plugin.GetTags()); + EXPECT_EQ(std::vector({info1}), plugin.GetDirtyInfo()); + EXPECT_EQ(std::vector({info1}), plugin.GetCleanInfo()); +} + +TEST_P(ConditionEvaluatorTest, evaluateAllShouldPreserveGroupExplicitness) { + PluginMetadata plugin(blankEsm); + + EXPECT_NO_THROW(plugin = evaluator_.EvaluateAll(plugin)); + EXPECT_FALSE(plugin.GetGroup()); +} + +TEST_P(ConditionEvaluatorTest, + refreshActivePluginsStateShouldClearTheConditionCache) { + std::string condition("active(\"" + blankEsm + "\")"); + ASSERT_TRUE(evaluator_.Evaluate(condition)); + + evaluator_.RefreshActivePluginsState({blankEsp}); + + EXPECT_FALSE(evaluator_.Evaluate(condition)); +} + +TEST_P( + ConditionEvaluatorTest, + refreshActivePluginsStateShouldClearTheActivePluginsCacheIfGivenAnEmptyVector) { + std::string condition("active(\"" + blankEsm + "\")"); + ASSERT_TRUE(evaluator_.Evaluate(condition)); + + evaluator_.RefreshActivePluginsState({}); + + EXPECT_FALSE(evaluator_.Evaluate(condition)); +} + +TEST_P(ConditionEvaluatorTest, + refreshLoadedPluginsStateShouldClearTheConditionCache) { + std::string condition("version(\"" + blankEsm + "\", \"5.0\", ==)"); + + ASSERT_TRUE(evaluator_.Evaluate(condition)); + + auto plugins = game_.GetLoadedPlugins(); + auto pluginsIt = + std::find_if(plugins.cbegin(), plugins.cend(), [&](auto plugin) { + return plugin->GetName() == blankEsm; + }); + plugins.erase(pluginsIt); + evaluator_.RefreshLoadedPluginsState(plugins); + + EXPECT_FALSE(evaluator_.Evaluate(condition)); +} + +TEST_P( + ConditionEvaluatorTest, + refreshLoadedPluginsStateShouldClearTheVersionsCacheIfGivenAnEmptyVector) { + std::string condition("version(\"" + blankEsm + "\", \"5.0\", ==)"); + + ASSERT_TRUE(evaluator_.Evaluate(condition)); + + evaluator_.RefreshLoadedPluginsState({}); + + EXPECT_FALSE(evaluator_.Evaluate(condition)); +} + +TEST_P(ConditionEvaluatorTest, + setAdditionalDataPathsShouldAcceptAnEmptyVector) { + EXPECT_NO_THROW(evaluator_.SetAdditionalDataPaths({})); +} + +TEST_P(ConditionEvaluatorTest, + setAdditionalDataPathsShouldAcceptANonEmptyVector) { + EXPECT_NO_THROW(evaluator_.SetAdditionalDataPaths( + {std::filesystem::u8path("a"), std::filesystem::u8path("b")})); +} +} +} + +#endif diff --git a/cxx/src/tests/api/internals/metadata/conditional_metadata_test.h b/cxx/src/tests/api/internals/metadata/conditional_metadata_test.h new file mode 100644 index 00000000..4fdf0012 --- /dev/null +++ b/cxx/src/tests/api/internals/metadata/conditional_metadata_test.h @@ -0,0 +1,77 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERNALS_METADATA_CONDITIONAL_METADATA_TEST +#define LOOT_TESTS_API_INTERNALS_METADATA_CONDITIONAL_METADATA_TEST + +#include "loot/metadata/conditional_metadata.h" +#include "tests/common_game_test_fixture.h" + +namespace loot { +namespace test { +class ConditionalMetadataTest : public CommonGameTestFixture, + public testing::WithParamInterface { +protected: + ConditionalMetadataTest() : CommonGameTestFixture(GetParam()) {} + ConditionalMetadata conditionalMetadata_; +}; + +// Pass an empty first argument, as it's a prefix for the test instantation, +// but we only have the one so no prefix is necessary. +INSTANTIATE_TEST_SUITE_P(, + ConditionalMetadataTest, + ::testing::Values(GameType::tes4, + GameType::tes5, + GameType::fo3, + GameType::fonv, + GameType::fo4, + GameType::tes5se)); + +TEST_P(ConditionalMetadataTest, + defaultConstructorShouldSetEmptyConditionString) { + EXPECT_TRUE(conditionalMetadata_.GetCondition().empty()); +} + +TEST_P(ConditionalMetadataTest, + stringConstructorShouldSetConditionToGivenString) { + std::string condition("condition"); + conditionalMetadata_ = ConditionalMetadata(condition); + + EXPECT_EQ(condition, conditionalMetadata_.GetCondition()); +} + +TEST_P(ConditionalMetadataTest, + isConditionalShouldBeFalseForAnEmptyConditionString) { + EXPECT_FALSE(conditionalMetadata_.IsConditional()); +} + +TEST_P(ConditionalMetadataTest, + isConditionalShouldBeTrueForANonEmptyConditionString) { + conditionalMetadata_ = ConditionalMetadata("condition"); + EXPECT_TRUE(conditionalMetadata_.IsConditional()); +} +} +} + +#endif diff --git a/cxx/src/tests/api/internals/metadata/file_test.h b/cxx/src/tests/api/internals/metadata/file_test.h new file mode 100644 index 00000000..e1ae3a2d --- /dev/null +++ b/cxx/src/tests/api/internals/metadata/file_test.h @@ -0,0 +1,386 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERNALS_METADATA_FILE_TEST +#define LOOT_TESTS_API_INTERNALS_METADATA_FILE_TEST + +#include + +#include "loot/metadata/file.h" + +namespace loot { +namespace test { +TEST(File, defaultConstructorShouldInitialiseEmptyStrings) { + File file; + + EXPECT_EQ("", std::string(file.GetName())); + EXPECT_EQ("", file.GetDisplayName()); + EXPECT_EQ("", file.GetCondition()); + EXPECT_EQ("", file.GetConstraint()); +} + +TEST(File, stringsConstructorShouldStoreGivenStrings) { + std::vector detail = {MessageContent("text", "en")}; + File file("name", "display", "condition", detail, "constraint"); + + EXPECT_EQ("name", std::string(file.GetName())); + EXPECT_EQ("display", file.GetDisplayName()); + EXPECT_EQ("condition", file.GetCondition()); + EXPECT_EQ(detail, file.GetDetail()); + EXPECT_EQ("constraint", file.GetConstraint()); +} + +TEST(File, equalityShouldBeCaseInsensitiveOnName) { + File file1("name", "display", "condition"); + File file2("name", "display", "condition"); + + EXPECT_TRUE(file1 == file2); + + file1 = File("name", "display", "condition"); + file2 = File("Name", "display", "condition"); + + EXPECT_TRUE(file1 == file2); + + file1 = File("name1", "display", "condition"); + file2 = File("name2", "display", "condition"); + + EXPECT_FALSE(file1 == file2); +} + +TEST(File, equalityShouldBeCaseSensitiveOnDisplayAndConditionAndConstraint) { + File file1("name", "display", "condition"); + File file2("name", "display", "condition"); + + EXPECT_TRUE(file1 == file2); + + file1 = File("name", "display", "condition"); + file2 = File("name", "Display", "condition"); + + EXPECT_FALSE(file1 == file2); + + file1 = File("name", "display", "condition"); + file2 = File("name", "display", "Condition"); + + EXPECT_FALSE(file1 == file2); + + file1 = File("name", "display", "condition", {}, "constraint"); + file2 = File("name", "display", "condition", {}, "Constraint"); + + EXPECT_FALSE(file1 == file2); + + file1 = File("name", "display1", "condition"); + file2 = File("name", "display2", "condition"); + + EXPECT_FALSE(file1 == file2); + + file1 = File("name", "display", "condition1"); + file2 = File("name", "display", "condition2"); + + EXPECT_FALSE(file1 == file2); + + file1 = File("name", "display", "condition", {}, "constraint1"); + file2 = File("name", "display", "condition", {}, "constraint2"); + + EXPECT_FALSE(file1 == file2); +} + +TEST(File, equalityShouldCompareTheDetailVectors) { + File file1("", "", "", {MessageContent("text", "en")}); + File file2("", "", "", {MessageContent("text", "en")}); + + EXPECT_TRUE(file1 == file2); + + file1 = File("", "", "", {MessageContent("text", "en")}); + file2 = File("", "", "", {MessageContent("Text", "en")}); + + EXPECT_FALSE(file1 == file2); + + file1 = File("", "", "", {MessageContent("text", "en")}); + file2 = File("", "", "", {MessageContent("text", "En")}); + + EXPECT_FALSE(file1 == file2); + + file1 = File( + "", "", "", {MessageContent("text", "en"), MessageContent("text", "en")}); + file2 = File("", "", "", {MessageContent("text", "en")}); + + EXPECT_FALSE(file1 == file2); +} + +TEST(File, inequalityShouldBeTheInverseOfEquality) { + File file1("name", "display", "condition", {MessageContent("text", "en")}); + File file2("name", "display", "condition", {MessageContent("text", "en")}); + + EXPECT_FALSE(file1 != file2); + + file1 = File("name", "display", "condition"); + file2 = File("name", "Display", "condition"); + + EXPECT_TRUE(file1 != file2); + + file1 = File("name", "display", "condition"); + file2 = File("name", "display", "Condition"); + + EXPECT_TRUE(file1 != file2); + + file1 = File("name", "display", "condition", {}, "constraint"); + file2 = File("name", "display", "condition", {}, "Constraint"); + + EXPECT_TRUE(file1 != file2); + + file1 = File("name", "display1", "condition"); + file2 = File("name", "display2", "condition"); + + EXPECT_TRUE(file1 != file2); + + file1 = File("name", "display", "condition1"); + file2 = File("name", "display", "condition2"); + + EXPECT_TRUE(file1 != file2); + + file1 = File("", "", "", {MessageContent("text", "en")}); + file2 = File("", "", "", {MessageContent("Text", "en")}); + + EXPECT_TRUE(file1 != file2); + + file1 = File("name", "display", "condition", {}, "constraint1"); + file2 = File("name", "display", "condition", {}, "constraint2"); + + EXPECT_TRUE(file1 != file2); +} + +TEST(File, + lessThanOperatorShouldUseCaseInsensitiveLexicographicalComparisonForName) { + File file1("name", "display", "condition"); + File file2("name", "display", "condition"); + + EXPECT_FALSE(file1 < file2); + EXPECT_FALSE(file2 < file1); + + file1 = File("name", "display", "condition"); + file2 = File("Name", "display", "condition"); + + EXPECT_FALSE(file1 < file2); + EXPECT_FALSE(file2 < file1); + + file1 = File("name1"); + file2 = File("name2"); + + EXPECT_TRUE(file1 < file2); + EXPECT_FALSE(file2 < file1); +} + +TEST( + File, + lessThanOperatorShouldUseCaseSensitiveLexicographicalComparisonForDisplayAndConditionAndConstraint) { + File file1("name", "display", "condition"); + File file2("name", "display", "condition"); + + EXPECT_FALSE(file1 < file2); + EXPECT_FALSE(file2 < file1); + + file1 = File("name", "display", "condition"); + file2 = File("name", "Display", "condition"); + + EXPECT_TRUE(file2 < file1); + EXPECT_FALSE(file1 < file2); + + file1 = File("name", "display", "condition"); + file2 = File("name", "display", "Condition"); + + EXPECT_TRUE(file2 < file1); + EXPECT_FALSE(file1 < file2); + + file1 = File("name", "display", "condition", {}, "constraint"); + file2 = File("name", "display", "condition", {}, "Constraint"); + + EXPECT_TRUE(file2 < file1); + EXPECT_FALSE(file1 < file2); + + file1 = File("name", "display1"); + file2 = File("name", "display2"); + + EXPECT_TRUE(file1 < file2); + EXPECT_FALSE(file2 < file1); + + file1 = File("name", "display", "condition1"); + file2 = File("name", "display", "condition2"); + + EXPECT_TRUE(file1 < file2); + EXPECT_FALSE(file2 < file1); + + file1 = File("name", "display", "condition", {}, "constraint1"); + file2 = File("name", "display", "condition", {}, "constraint2"); + + EXPECT_TRUE(file1 < file2); + EXPECT_FALSE(file2 < file1); +} + +TEST(File, lessThanOperatorShouldCompareTheDetailVectors) { + File file1("", "", "", {MessageContent("text", "en")}); + File file2("", "", "", {MessageContent("text", "en")}); + + EXPECT_FALSE(file1 < file2); + + file1 = File("", "", "", {MessageContent("text", "en")}); + file2 = File("", "", "", {MessageContent("Text", "en")}); + + EXPECT_FALSE(file1 < file2); + + file1 = File("", "", "", {MessageContent("text", "en")}); + file2 = File("", "", "", {MessageContent("text", "En")}); + + EXPECT_FALSE(file1 < file2); + + file1 = File( + "", "", "", {MessageContent("text", "en"), MessageContent("text", "en")}); + file2 = File("", "", "", {MessageContent("text", "en")}); + + EXPECT_FALSE(file1 < file2); +} + +TEST(File, shouldAllowComparisonUsingGreaterThanOperator) { + File file1("name", "display", "condition", {MessageContent("text", "en")}); + File file2("name", "display", "condition", {MessageContent("text", "en")}); + + EXPECT_FALSE(file1 > file2); + EXPECT_FALSE(file2 > file1); + + file1 = File("name", "display", "condition"); + file2 = File("name", "Display", "condition"); + + EXPECT_FALSE(file2 > file1); + EXPECT_TRUE(file1 > file2); + + file1 = File("name", "display", "condition"); + file2 = File("name", "display", "Condition"); + + EXPECT_FALSE(file2 > file1); + EXPECT_TRUE(file1 > file2); + + file1 = File("name", "display1"); + file2 = File("name", "display2"); + + EXPECT_FALSE(file1 > file2); + EXPECT_TRUE(file2 > file1); + + file1 = File("name", "display", "condition1"); + file2 = File("name", "display", "condition2"); + + EXPECT_FALSE(file1 > file2); + EXPECT_TRUE(file2 > file1); + + file1 = File("", "", "", {MessageContent("text", "en")}); + file2 = File("", "", "", {MessageContent("Text", "en")}); + + EXPECT_TRUE(file1 > file2); +} + +TEST( + File, + lessThanOrEqualToOperatorShouldReturnTrueIfFirstFileIsNotGreaterThanSecondFile) { + File file1("name", "display", "condition", {MessageContent("text", "en")}); + File file2("name", "display", "condition", {MessageContent("text", "en")}); + + EXPECT_TRUE(file1 <= file2); + EXPECT_TRUE(file2 <= file1); + + file1 = File("name", "display", "condition"); + file2 = File("name", "Display", "condition"); + + EXPECT_TRUE(file2 <= file1); + EXPECT_FALSE(file1 <= file2); + + file1 = File("name", "display", "condition"); + file2 = File("name", "display", "Condition"); + + EXPECT_TRUE(file2 <= file1); + EXPECT_FALSE(file1 <= file2); + + file1 = File("name", "display1"); + file2 = File("name", "display2"); + + EXPECT_TRUE(file1 <= file2); + EXPECT_FALSE(file2 <= file1); + + file1 = File("name", "display", "condition1"); + file2 = File("name", "display", "condition2"); + + EXPECT_TRUE(file1 <= file2); + EXPECT_FALSE(file2 <= file1); + + file1 = File("", "", "", {MessageContent("text", "en")}); + file2 = File("", "", "", {MessageContent("Text", "en")}); + + EXPECT_FALSE(file1 <= file2); +} + +TEST( + File, + greaterThanOrEqualToOperatorShouldReturnTrueIfFirstFileIsNotLessThanSecondFile) { + File file1("name", "display", "condition", {MessageContent("text", "en")}); + File file2("name", "display", "condition", {MessageContent("text", "en")}); + + EXPECT_TRUE(file1 >= file2); + EXPECT_TRUE(file2 >= file1); + + file1 = File("name", "display", "condition"); + file2 = File("name", "Display", "condition"); + + EXPECT_FALSE(file2 >= file1); + EXPECT_TRUE(file1 >= file2); + + file1 = File("name", "display", "condition"); + file2 = File("name", "display", "Condition"); + + EXPECT_FALSE(file2 >= file1); + EXPECT_TRUE(file1 >= file2); + + file1 = File("name", "display1"); + file2 = File("name", "display2"); + + EXPECT_FALSE(file1 >= file2); + EXPECT_TRUE(file2 >= file1); + + file1 = File("name", "display", "condition1"); + file2 = File("name", "display", "condition2"); + + EXPECT_FALSE(file1 >= file2); + EXPECT_TRUE(file2 >= file1); + + file1 = File("", "", "", {MessageContent("text", "en")}); + file2 = File("", "", "", {MessageContent("Text", "en")}); + + EXPECT_TRUE(file1 >= file2); +} + +TEST(File, getDisplayNameShouldReturnDisplayString) { + File file("name", "display"); + + EXPECT_EQ("display", file.GetDisplayName()); +} +} +} + +#endif diff --git a/cxx/src/tests/api/internals/metadata/group_test.h b/cxx/src/tests/api/internals/metadata/group_test.h new file mode 100644 index 00000000..ae670152 --- /dev/null +++ b/cxx/src/tests/api/internals/metadata/group_test.h @@ -0,0 +1,355 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERNALS_METADATA_GROUP_TEST +#define LOOT_TESTS_API_INTERNALS_METADATA_GROUP_TEST + +#include + +#include "loot/metadata/group.h" + +namespace loot { +namespace test { +TEST(Group, defaultConstructorShouldCreateDefaultGroup) { + Group group; + + EXPECT_EQ("default", group.GetName()); + EXPECT_TRUE(group.GetAfterGroups().empty()); +} + +TEST(Group, + allArgsConstructorShouldSetDescriptionAndAfterGroupsDefaultsAsEmpty) { + Group group("group1"); + + EXPECT_EQ("group1", group.GetName()); + EXPECT_TRUE(group.GetDescription().empty()); + EXPECT_TRUE(group.GetAfterGroups().empty()); +} + +TEST(Group, allArgsConstructorShouldStoreGivenValues) { + Group group("group1", {"other_group"}, "test"); + + EXPECT_EQ("group1", group.GetName()); + EXPECT_EQ("test", group.GetDescription()); + EXPECT_EQ(std::vector({"other_group"}), group.GetAfterGroups()); +} + +TEST(Group, equalityShouldBeCaseSensitiveOnNameAndDescription) { + Group group1("name", {}, "description"); + Group group2("name", {}, "description"); + + EXPECT_TRUE(group1 == group2); + + group1 = Group("name"); + group2 = Group("Name"); + + EXPECT_FALSE(group1 == group2); + + group1 = Group("name", {}, "description"); + group2 = Group("name", {}, "Description"); + + EXPECT_FALSE(group1 == group2); + + group1 = Group("name1"); + group2 = Group("name2"); + + EXPECT_FALSE(group1 == group2); + + group1 = Group("name", {}, "description1"); + group2 = Group("name", {}, "description2"); + + EXPECT_FALSE(group1 == group2); +} + +TEST(Group, equalityShouldRequireEqualAfterGroups) { + Group group1("name", {}, "description"); + Group group2("name", {}, "description"); + + EXPECT_TRUE(group1 == group2); + + group1 = Group("name", {}, "description"); + group2 = Group("name", {"after1"}, "Description"); + + EXPECT_FALSE(group1 == group2); +} + +TEST(Group, inequalityShouldBeTheInverseOfEquality) { + Group group1("name", {}, "description"); + Group group2("name", {}, "description"); + + EXPECT_FALSE(group1 != group2); + + group1 = Group("name"); + group2 = Group("Name"); + + EXPECT_TRUE(group1 != group2); + + group1 = Group("name", {}, "description"); + group2 = Group("name", {}, "Description"); + + EXPECT_TRUE(group1 != group2); + + group1 = Group("name1"); + group2 = Group("name2"); + + EXPECT_TRUE(group1 != group2); + + group1 = Group("name", {}, "description1"); + group2 = Group("name", {}, "description2"); + + EXPECT_TRUE(group1 != group2); + + group1 = Group("name", {}, "description"); + group2 = Group("name", {"after1"}, "Description"); + + EXPECT_TRUE(group1 != group2); +} + +TEST(Group, + lessThanOperatorShouldUseCaseSensitiveLexicographicalComparisonForNames) { + Group group1("name", {}, "description"); + Group group2("name", {}, "description"); + + EXPECT_FALSE(group1 < group2); + EXPECT_FALSE(group2 < group1); + + group1 = Group("Name", {}, "description"); + group2 = Group("name", {}, "description"); + + EXPECT_TRUE(group1 < group2); + EXPECT_FALSE(group2 < group1); + + group1 = Group("name1", {}, "description"); + group2 = Group("name2", {}, "description"); + + EXPECT_TRUE(group1 < group2); + EXPECT_FALSE(group2 < group1); +} + +TEST( + Group, + lessThanOperatorShouldUseCaseSensitiveLexicographicalComparisonForDescriptions) { + Group group1("name", {}, "description"); + Group group2("name", {}, "description"); + + EXPECT_FALSE(group1 < group2); + EXPECT_FALSE(group2 < group1); + + group1 = Group("name", {}, "Description"); + group2 = Group("name", {}, "description"); + + EXPECT_TRUE(group1 < group2); + EXPECT_FALSE(group2 < group1); + + group1 = Group("name", {}, "description1"); + group2 = Group("name", {}, "description2"); + + EXPECT_TRUE(group1 < group2); + EXPECT_FALSE(group2 < group1); +} + +TEST(Group, lessThanOperatorShouldCompareAfterGroups) { + Group group1("name", {}, "description"); + Group group2("name", {}, "description"); + + EXPECT_FALSE(group1 < group2); + EXPECT_FALSE(group2 < group1); + + group1 = Group("name", {}, "description"); + group2 = Group("name", {"group"}, "description"); + + EXPECT_TRUE(group1 < group2); + EXPECT_FALSE(group2 < group1); + + group1 = Group("name", {"Group"}, "description"); + group2 = Group("name", {"group"}, "description"); + + EXPECT_TRUE(group1 < group2); + EXPECT_FALSE(group2 < group1); + + group1 = Group("name", {"group1"}, "description"); + group2 = Group("name", {"group2"}, "description"); + + EXPECT_TRUE(group1 < group2); + EXPECT_FALSE(group2 < group1); +} + +TEST(Group, + greaterThanOperatorShouldReturnTrueIfTheSecondGroupIsLessThanTheFirst) { + Group group1("name", {}, "description"); + Group group2("name", {}, "description"); + + EXPECT_FALSE(group1 > group2); + EXPECT_FALSE(group2 > group1); + + group1 = Group("Name", {}, "description"); + group2 = Group("name", {}, "description"); + + EXPECT_FALSE(group1 > group2); + EXPECT_TRUE(group2 > group1); + + group1 = Group("name1", {}, "description"); + group2 = Group("name2", {}, "description"); + + EXPECT_FALSE(group1 > group2); + EXPECT_TRUE(group2 > group1); + + group1 = Group("name", {}, "Description"); + group2 = Group("name", {}, "description"); + + EXPECT_FALSE(group1 > group2); + EXPECT_TRUE(group2 > group1); + + group1 = Group("name", {}, "description1"); + group2 = Group("name", {}, "description2"); + + EXPECT_FALSE(group1 > group2); + EXPECT_TRUE(group2 > group1); + + group1 = Group("name", {}, "description"); + group2 = Group("name", {"group"}, "description"); + + EXPECT_FALSE(group1 > group2); + EXPECT_TRUE(group2 > group1); + + group1 = Group("name", {"Group"}, "description"); + group2 = Group("name", {"group"}, "description"); + + EXPECT_FALSE(group1 > group2); + EXPECT_TRUE(group2 > group1); + + group1 = Group("name", {"group1"}, "description"); + group2 = Group("name", {"group2"}, "description"); + + EXPECT_FALSE(group1 > group2); + EXPECT_TRUE(group2 > group1); +} + +TEST( + Group, + lessThanOrEqualToOperatorShouldReturnTrueIfTheFirstGroupIsNotGreaterThanTheSecond) { + Group group1("name", {}, "description"); + Group group2("name", {}, "description"); + + EXPECT_TRUE(group1 <= group2); + EXPECT_TRUE(group2 <= group1); + + group1 = Group("Name", {}, "description"); + group2 = Group("name", {}, "description"); + + EXPECT_TRUE(group1 <= group2); + EXPECT_FALSE(group2 <= group1); + + group1 = Group("name1", {}, "description"); + group2 = Group("name2", {}, "description"); + + EXPECT_TRUE(group1 <= group2); + EXPECT_FALSE(group2 <= group1); + + group1 = Group("name", {}, "Description"); + group2 = Group("name", {}, "description"); + + EXPECT_TRUE(group1 <= group2); + EXPECT_FALSE(group2 <= group1); + + group1 = Group("name", {}, "description1"); + group2 = Group("name", {}, "description2"); + + EXPECT_TRUE(group1 <= group2); + EXPECT_FALSE(group2 <= group1); + + group1 = Group("name", {}, "description"); + group2 = Group("name", {"group"}, "description"); + + EXPECT_TRUE(group1 <= group2); + EXPECT_FALSE(group2 <= group1); + + group1 = Group("name", {"Group"}, "description"); + group2 = Group("name", {"group"}, "description"); + + EXPECT_TRUE(group1 <= group2); + EXPECT_FALSE(group2 <= group1); + + group1 = Group("name", {"group1"}, "description"); + group2 = Group("name", {"group2"}, "description"); + + EXPECT_TRUE(group1 <= group2); + EXPECT_FALSE(group2 <= group1); +} + +TEST( + Group, + greaterThanOrEqualToOperatorShouldReturnTrueIfTheFirstGroupIsNotLessThanTheSecond) { + Group group1("name", {}, "description"); + Group group2("name", {}, "description"); + + EXPECT_TRUE(group1 >= group2); + EXPECT_TRUE(group2 >= group1); + + group1 = Group("Name", {}, "description"); + group2 = Group("name", {}, "description"); + + EXPECT_FALSE(group1 >= group2); + EXPECT_TRUE(group2 >= group1); + + group1 = Group("name1", {}, "description"); + group2 = Group("name2", {}, "description"); + + EXPECT_FALSE(group1 >= group2); + EXPECT_TRUE(group2 >= group1); + + group1 = Group("name", {}, "Description"); + group2 = Group("name", {}, "description"); + + EXPECT_FALSE(group1 >= group2); + EXPECT_TRUE(group2 >= group1); + + group1 = Group("name", {}, "description1"); + group2 = Group("name", {}, "description2"); + + EXPECT_FALSE(group1 >= group2); + EXPECT_TRUE(group2 >= group1); + + group1 = Group("name", {}, "description"); + group2 = Group("name", {"group"}, "description"); + + EXPECT_FALSE(group1 >= group2); + EXPECT_TRUE(group2 >= group1); + + group1 = Group("name", {"Group"}, "description"); + group2 = Group("name", {"group"}, "description"); + + EXPECT_FALSE(group1 >= group2); + EXPECT_TRUE(group2 >= group1); + + group1 = Group("name", {"group1"}, "description"); + group2 = Group("name", {"group2"}, "description"); + + EXPECT_FALSE(group1 >= group2); + EXPECT_TRUE(group2 >= group1); +} +} +} + +#endif diff --git a/cxx/src/tests/api/internals/metadata/location_test.h b/cxx/src/tests/api/internals/metadata/location_test.h new file mode 100644 index 00000000..6d9d3bcd --- /dev/null +++ b/cxx/src/tests/api/internals/metadata/location_test.h @@ -0,0 +1,239 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERNALS_METADATA_LOCATION_TEST +#define LOOT_TESTS_API_INTERNALS_METADATA_LOCATION_TEST + +#include + +#include "loot/metadata/location.h" + +namespace loot { +namespace test { +TEST(Location, defaultConstructorShouldInitialiseEmptyStrings) { + Location location; + + EXPECT_EQ("", location.GetURL()); + EXPECT_EQ("", location.GetName()); +} + +TEST(Location, stringsConstructorShouldStoreGivenStrings) { + Location location("http://www.example.com", "example"); + + EXPECT_EQ("http://www.example.com", location.GetURL()); + EXPECT_EQ("example", location.GetName()); +} + +TEST(Location, equalityShouldBeCaseSensitiveOnUrlAndName) { + Location location1("http://www.example.com", "example"); + Location location2("http://www.example.com", "example"); + + EXPECT_TRUE(location1 == location2); + + location1 = Location("http://www.example.com", "example"); + location2 = Location("HTTP://WWW.EXAMPLE.COM", "example"); + + EXPECT_FALSE(location1 == location2); + + location1 = Location("http://www.example.com", "example"); + location2 = Location("http://www.example.com", "Example"); + + EXPECT_FALSE(location1 == location2); + + location1 = Location("http://www.example1.com", "example"); + location2 = Location("http://www.example2.com", "example"); + + EXPECT_FALSE(location1 == location2); + + location1 = Location("http://www.example.com", "example1"); + location2 = Location("http://www.example.com", "example2"); + + EXPECT_FALSE(location1 == location2); +} + +TEST(Location, inequalityShouldBeTheInverseOfEquality) { + Location location1("http://www.example.com", "example"); + Location location2("http://www.example.com", "example"); + + EXPECT_FALSE(location1 != location2); + + location1 = Location("http://www.example.com", "example"); + location2 = Location("HTTP://WWW.EXAMPLE.COM", "example"); + + EXPECT_TRUE(location1 != location2); + + location1 = Location("http://www.example.com", "example"); + location2 = Location("http://www.example.com", "Example"); + + EXPECT_TRUE(location1 != location2); + + location1 = Location("http://www.example1.com", "example"); + location2 = Location("http://www.example2.com", "example"); + + EXPECT_TRUE(location1 != location2); + + location1 = Location("http://www.example.com", "example1"); + location2 = Location("http://www.example.com", "example2"); + + EXPECT_TRUE(location1 != location2); +} + +TEST( + Location, + lessThanOperatorShouldUseCaseSensitiveLexicographicalComparisonForNameAndUrl) { + Location location1("http://www.example.com", "example"); + Location location2("http://www.example.com", "example"); + + EXPECT_FALSE(location1 < location2); + EXPECT_FALSE(location2 < location1); + + location1 = Location("http://www.example.com"); + location2 = Location("HTTP://WWW.EXAMPLE.COM"); + + EXPECT_FALSE(location1 < location2); + EXPECT_TRUE(location2 < location1); + + location1 = Location("http://www.example.com", "example"); + location2 = Location("http://www.example.com", "Example"); + + EXPECT_FALSE(location1 < location2); + EXPECT_TRUE(location2 < location1); + + location1 = Location("http://www.example1.com"); + location2 = Location("http://www.example2.com"); + + EXPECT_TRUE(location1 < location2); + EXPECT_FALSE(location2 < location1); + + location1 = Location("http://www.example.com", "example1"); + location2 = Location("http://www.example.com", "example2"); + + EXPECT_FALSE(location2 < location1); + EXPECT_TRUE(location1 < location2); +} + +TEST(Location, + greaterThanOperatorShouldReturnTrueIfTheSecondLocationIsLessThanTheFirst) { + Location location1("http://www.example.com", "example"); + Location location2("http://www.example.com", "example"); + + EXPECT_FALSE(location1 > location2); + EXPECT_FALSE(location2 > location1); + + location1 = Location("http://www.example.com"); + location2 = Location("HTTP://WWW.EXAMPLE.COM"); + + EXPECT_TRUE(location1 > location2); + EXPECT_FALSE(location2 > location1); + + location1 = Location("http://www.example.com", "example"); + location2 = Location("http://www.example.com", "Example"); + + EXPECT_TRUE(location1 > location2); + EXPECT_FALSE(location2 > location1); + + location1 = Location("http://www.example1.com"); + location2 = Location("http://www.example2.com"); + + EXPECT_FALSE(location1 > location2); + EXPECT_TRUE(location2 > location1); + + location1 = Location("http://www.example.com", "example1"); + location2 = Location("http://www.example.com", "example2"); + + EXPECT_TRUE(location2 > location1); + EXPECT_FALSE(location1 > location2); +} + +TEST( + Location, + lessThanOrEqualOperatorShouldReturnTrueIfTheFirstLocationIsNotGreaterThanTheSecond) { + Location location1("http://www.example.com", "example"); + Location location2("http://www.example.com", "example"); + + EXPECT_TRUE(location1 <= location2); + EXPECT_TRUE(location2 <= location1); + + location1 = Location("http://www.example.com"); + location2 = Location("HTTP://WWW.EXAMPLE.COM"); + + EXPECT_FALSE(location1 <= location2); + EXPECT_TRUE(location2 <= location1); + + location1 = Location("http://www.example.com", "example"); + location2 = Location("http://www.example.com", "Example"); + + EXPECT_FALSE(location1 <= location2); + EXPECT_TRUE(location2 <= location1); + + location1 = Location("http://www.example1.com"); + location2 = Location("http://www.example2.com"); + + EXPECT_TRUE(location1 <= location2); + EXPECT_FALSE(location2 <= location1); + + location1 = Location("http://www.example.com", "example1"); + location2 = Location("http://www.example.com", "example2"); + + EXPECT_FALSE(location2 <= location1); + EXPECT_TRUE(location1 <= location2); +} + +TEST( + Location, + greaterThanOrEqualToOperatorShouldReturnTrueIfTheFirstLocationIsNotLessThanTheSecond) { + Location location1("http://www.example.com", "example"); + Location location2("http://www.example.com", "example"); + + EXPECT_TRUE(location1 >= location2); + EXPECT_TRUE(location2 >= location1); + + location1 = Location("http://www.example.com"); + location2 = Location("HTTP://WWW.EXAMPLE.COM"); + + EXPECT_TRUE(location1 >= location2); + EXPECT_FALSE(location2 >= location1); + + location1 = Location("http://www.example.com", "example"); + location2 = Location("http://www.example.com", "Example"); + + EXPECT_TRUE(location1 >= location2); + EXPECT_FALSE(location2 >= location1); + + location1 = Location("http://www.example1.com"); + location2 = Location("http://www.example2.com"); + + EXPECT_FALSE(location1 >= location2); + EXPECT_TRUE(location2 >= location1); + + location1 = Location("http://www.example.com", "example1"); + location2 = Location("http://www.example.com", "example2"); + + EXPECT_TRUE(location2 >= location1); + EXPECT_FALSE(location1 >= location2); +} +} +} + +#endif diff --git a/cxx/src/tests/api/internals/metadata/message_content_test.h b/cxx/src/tests/api/internals/metadata/message_content_test.h new file mode 100644 index 00000000..f5f20cfb --- /dev/null +++ b/cxx/src/tests/api/internals/metadata/message_content_test.h @@ -0,0 +1,335 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERNALS_METADATA_MESSAGE_CONTENT_TEST +#define LOOT_TESTS_API_INTERNALS_METADATA_MESSAGE_CONTENT_TEST + +#include + +#include "loot/metadata/message_content.h" + +namespace loot { +namespace test { +const std::string french = "fr"; + +TEST(MessageContent, defaultConstructorShouldSetEmptyEnglishLanguageString) { + MessageContent content; + + EXPECT_TRUE(content.GetText().empty()); + EXPECT_EQ(MessageContent::DEFAULT_LANGUAGE, content.GetLanguage()); +} + +TEST(MessageContent, contentConstructorShouldStoreGivenStringAndLanguage) { + MessageContent content("content", french); + + EXPECT_EQ("content", content.GetText()); + EXPECT_EQ(french, content.GetLanguage()); +} + +TEST(MessageContent, + equalityShouldRequireCaseSensitiveEqualityOnTextAndLanguage) { + MessageContent content1("content", "fr"); + MessageContent content2("content", "fr"); + + EXPECT_TRUE(content1 == content2); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("Content", "fr"); + + EXPECT_FALSE(content1 == content2); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("content", "Fr"); + + EXPECT_FALSE(content1 == content2); + + content1 = MessageContent("content1", "fr"); + content2 = MessageContent("content2", "fr"); + + EXPECT_FALSE(content1 == content2); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("content", "de"); + + EXPECT_FALSE(content1 == content2); +} + +TEST(MessageContent, inequalityShouldBeTheInverseOfEquality) { + MessageContent content1("content", "fr"); + MessageContent content2("content", "fr"); + + EXPECT_FALSE(content1 != content2); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("Content", "fr"); + + EXPECT_TRUE(content1 != content2); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("content", "Fr"); + + EXPECT_TRUE(content1 != content2); + + content1 = MessageContent("content1", "fr"); + content2 = MessageContent("content2", "fr"); + + EXPECT_TRUE(content1 != content2); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("content", "de"); + + EXPECT_TRUE(content1 != content2); +} + +TEST( + MessageContent, + lessThanOperatorShouldUseCaseSensitiveLexicographicalComparisonForTextAndLanguage) { + MessageContent content1("content", "fr"); + MessageContent content2("content", "fr"); + + EXPECT_FALSE(content1 < content2); + EXPECT_FALSE(content2 < content1); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("Content", "fr"); + + EXPECT_FALSE(content1 < content2); + EXPECT_TRUE(content2 < content1); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("content", "Fr"); + + EXPECT_TRUE(content2 < content1); + EXPECT_FALSE(content1 < content2); + + content1 = MessageContent("content1", "fr"); + content2 = MessageContent("content2", "fr"); + + EXPECT_TRUE(content1 < content2); + EXPECT_FALSE(content2 < content1); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("content", "de"); + + EXPECT_TRUE(content2 < content1); + EXPECT_FALSE(content1 < content2); +} + +TEST( + MessageContent, + greaterThanOperatorShouldReturnTrueIfTheSecondMessageContentIsLessThanTheFirst) { + MessageContent content1("content", "fr"); + MessageContent content2("content", "fr"); + + EXPECT_FALSE(content1 > content2); + EXPECT_FALSE(content2 > content1); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("Content", "fr"); + + EXPECT_TRUE(content1 > content2); + EXPECT_FALSE(content2 > content1); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("content", "Fr"); + + EXPECT_FALSE(content2 > content1); + EXPECT_TRUE(content1 > content2); + + content1 = MessageContent("content1", "fr"); + content2 = MessageContent("content2", "fr"); + + EXPECT_FALSE(content1 > content2); + EXPECT_TRUE(content2 > content1); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("content", "de"); + + EXPECT_FALSE(content2 > content1); + EXPECT_TRUE(content1 > content2); +} + +TEST( + MessageContent, + lessThanOrEqualOperatorShouldReturnTrueIfTheFirstMessageContentIsNotGreaterThanTheSecond) { + MessageContent content1("content", "fr"); + MessageContent content2("content", "fr"); + + EXPECT_TRUE(content1 <= content2); + EXPECT_TRUE(content2 <= content1); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("Content", "fr"); + + EXPECT_FALSE(content1 <= content2); + EXPECT_TRUE(content2 <= content1); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("content", "Fr"); + + EXPECT_TRUE(content2 <= content1); + EXPECT_FALSE(content1 <= content2); + + content1 = MessageContent("content1", "fr"); + content2 = MessageContent("content2", "fr"); + + EXPECT_TRUE(content1 <= content2); + EXPECT_FALSE(content2 <= content1); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("content", "de"); + + EXPECT_TRUE(content2 <= content1); + EXPECT_FALSE(content1 <= content2); +} + +TEST( + MessageContent, + greaterThanOrEqualToOperatorShouldReturnTrueIfTheFirstMessageContentIsNotLessThanTheSecond) { + MessageContent content1("content", "fr"); + MessageContent content2("content", "fr"); + + EXPECT_TRUE(content1 >= content2); + EXPECT_TRUE(content2 >= content1); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("Content", "fr"); + + EXPECT_TRUE(content1 >= content2); + EXPECT_FALSE(content2 >= content1); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("content", "Fr"); + + EXPECT_FALSE(content2 >= content1); + EXPECT_TRUE(content1 >= content2); + + content1 = MessageContent("content1", "fr"); + content2 = MessageContent("content2", "fr"); + + EXPECT_FALSE(content1 >= content2); + EXPECT_TRUE(content2 >= content1); + + content1 = MessageContent("content", "fr"); + content2 = MessageContent("content", "de"); + + EXPECT_FALSE(content2 >= content1); + EXPECT_TRUE(content1 >= content2); +} + +TEST(SelectMessageContent, shouldReturnANulloptIfTheVectorIsEmpty) { + auto content = SelectMessageContent(std::vector(), "fr"); + + EXPECT_FALSE(content.has_value()); +} + +TEST(SelectMessageContent, shouldReturnTheOnlyElementOfASingleElementVector) { + MessageContent content("test", "de"); + auto chosen = SelectMessageContent({MessageContent("test", "de")}, "fr"); + + EXPECT_EQ(content, chosen); +} + +TEST( + SelectMessageContent, + shouldReturnAnEmptyEnglishMessageIfTheVectorHasNoEnglishOrMatchingLanguageContentWithTwoOrMoreElements) { + auto contents = {MessageContent("test1", "de"), + MessageContent("test2", "fr")}; + auto content = SelectMessageContent(contents, "pt"); + + EXPECT_FALSE(content.has_value()); +} + +TEST(SelectMessageContent, + shouldReturnElementWithExactlyMatchingLocaleCodeIfPresent) { + auto contents = {MessageContent("test1", "en"), + MessageContent("test2", "de"), + MessageContent("test3", "pt"), + MessageContent("test4", "pt_PT"), + MessageContent("test5", "pt_BR")}; + auto content = SelectMessageContent(contents, "pt_BR"); + + EXPECT_TRUE(content.has_value()); + EXPECT_EQ("pt_BR", content.value().GetLanguage()); + EXPECT_EQ("test5", content.value().GetText()); +} + +TEST( + SelectMessageContent, + shouldReturnElementWithMatchingLanguageCodeIfExactlyMatchingLocaleCodeIsNotPresent) { + auto contents = {MessageContent("test1", "en"), + MessageContent("test2", "de"), + MessageContent("test3", "pt_PT"), + MessageContent("test4", "pt")}; + auto content = SelectMessageContent(contents, "pt_BR"); + + EXPECT_TRUE(content.has_value()); + EXPECT_EQ("pt", content.value().GetLanguage()); + EXPECT_EQ("test4", content.value().GetText()); +} + +TEST(SelectMessageContent, + shouldReturnElementWithEnLanguageCodeIfNoMatchingLanguageCodeIsPresent) { + auto contents = {MessageContent("test1", "en"), + MessageContent("test2", "de"), + MessageContent("test3", "pt_PT")}; + auto content = SelectMessageContent(contents, "pt_BR"); + + EXPECT_TRUE(content.has_value()); + EXPECT_EQ("en", content.value().GetLanguage()); + EXPECT_EQ("test1", content.value().GetText()); +} + +TEST(SelectMessageContent, + shouldReturnElementWithExactlyMatchingLanguageCodeIfLanguageCodeIsGiven) { + auto contents = { + MessageContent("test1", "en"), + MessageContent("test2", "de"), + MessageContent("test3", "pt_BR"), + MessageContent("test4", "pt"), + }; + auto content = SelectMessageContent(contents, "pt"); + + EXPECT_TRUE(content.has_value()); + EXPECT_EQ("pt", content.value().GetLanguage()); + EXPECT_EQ("test4", content.value().GetText()); +} + +TEST( + SelectMessageContent, + shouldReturnFirstElementWithMatchingLanguageCodeIfLanguageCodeIsGivenAndNoExactMatchIsPresent) { + auto contents = {MessageContent("test1", "en"), + MessageContent("test2", "de"), + MessageContent("test3", "pt_PT"), + MessageContent("test4", "pt_BR")}; + auto content = SelectMessageContent(contents, "pt"); + + EXPECT_TRUE(content.has_value()); + EXPECT_EQ("pt_PT", content.value().GetLanguage()); + EXPECT_EQ("test3", content.value().GetText()); +} +} +} + +#endif diff --git a/cxx/src/tests/api/internals/metadata/message_test.h b/cxx/src/tests/api/internals/metadata/message_test.h new file mode 100644 index 00000000..8939440f --- /dev/null +++ b/cxx/src/tests/api/internals/metadata/message_test.h @@ -0,0 +1,347 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERNALS_METADATA_MESSAGE_TEST +#define LOOT_TESTS_API_INTERNALS_METADATA_MESSAGE_TEST + +#include "loot/metadata/message.h" +#include "tests/common_game_test_fixture.h" + +namespace loot { +namespace test { +class MessageTest : public CommonGameTestFixture { +protected: + MessageTest() : CommonGameTestFixture(GameType::tes4) {} + typedef std::vector MessageContents; +}; + +TEST_F(MessageTest, defaultConstructorShouldCreateNoteWithNoContent) { + Message message; + EXPECT_EQ(MessageType::say, message.GetType()); + EXPECT_EQ(MessageContents(), message.GetContent()); +} + +TEST_F(MessageTest, + scalarContentConstructorShouldCreateAMessageWithASingleContentString) { + MessageContent content = MessageContent("content1"); + Message message(MessageType::warn, content.GetText(), "condition1"); + + EXPECT_EQ(MessageType::warn, message.GetType()); + EXPECT_EQ(MessageContents({content}), message.GetContent()); + EXPECT_EQ("condition1", message.GetCondition()); +} + +TEST_F(MessageTest, + vectorContentConstructorShouldCreateAMessageWithGivenContentStrings) { + MessageContents contents({ + MessageContent("content1"), + MessageContent("content2", french), + }); + Message message(MessageType::error, contents, "condition1"); + + EXPECT_EQ(MessageType::error, message.GetType()); + EXPECT_EQ(contents, message.GetContent()); + EXPECT_EQ("condition1", message.GetCondition()); +} + +TEST_F( + MessageTest, + vectorContentConstructorShouldThrowIfMultipleContentStringsAreGivenAndNoneAreEnglish) { + MessageContents contents({ + MessageContent("content1", german), + MessageContent("content2", french), + }); + EXPECT_THROW(Message(MessageType::error, contents, "condition1"), + std::invalid_argument); +} + +TEST_F(MessageTest, equalityShouldRequireEqualMessageTypes) { + Message message1(MessageType::say, "content"); + Message message2(MessageType::say, "content"); + + EXPECT_TRUE(message1 == message2); + + message1 = Message(MessageType::say, "content"); + message2 = Message(MessageType::warn, "content"); + + EXPECT_FALSE(message1 == message2); +} + +TEST_F(MessageTest, equalityShouldRequireCaseSensitiveEqualityOnCondition) { + Message message1(MessageType::say, "content", "condition"); + Message message2(MessageType::say, "content", "condition"); + + EXPECT_TRUE(message1 == message2); + + message1 = Message(MessageType::say, "content", "condition"); + message2 = Message(MessageType::say, "content", "Condition"); + + EXPECT_FALSE(message1 == message2); + + message1 = Message(MessageType::say, "content", "condition1"); + message2 = Message(MessageType::say, "content", "condition2"); + + EXPECT_FALSE(message1 == message2); +} + +TEST_F(MessageTest, equalityShouldRequireEqualContent) { + Message message1(MessageType::say, "content"); + Message message2(MessageType::say, "content"); + + EXPECT_TRUE(message1 == message2); + + message1 = Message(MessageType::say, "content1"); + message2 = Message(MessageType::say, "content2"); + + EXPECT_FALSE(message1 == message2); +} + +TEST_F(MessageTest, inequalityShouldBeTheInverseOfEquality) { + Message message1(MessageType::say, "content"); + Message message2(MessageType::say, "content"); + + EXPECT_FALSE(message1 != message2); + + message1 = Message(MessageType::say, "content"); + message2 = Message(MessageType::warn, "content"); + + EXPECT_TRUE(message1 != message2); + + message1 = Message(MessageType::say, "content", "condition"); + message2 = Message(MessageType::say, "content", "condition"); + + EXPECT_FALSE(message1 != message2); + + message1 = Message(MessageType::say, "content", "condition"); + message2 = Message(MessageType::say, "content", "Condition"); + + EXPECT_TRUE(message1 != message2); + + message1 = Message(MessageType::say, "content", "condition1"); + message2 = Message(MessageType::say, "content", "condition2"); + + EXPECT_TRUE(message1 != message2); + + message1 = Message(MessageType::say, "content"); + message2 = Message(MessageType::say, "content"); + + EXPECT_FALSE(message1 != message2); + + message1 = Message(MessageType::say, "content1"); + message2 = Message(MessageType::say, "content2"); + + EXPECT_TRUE(message1 != message2); +} + +TEST_F(MessageTest, lessThanOperatorShouldCompareMessageTypes) { + Message message1(MessageType::say, "content"); + Message message2(MessageType::say, "content"); + + EXPECT_FALSE(message1 < message2); + EXPECT_FALSE(message2 < message1); + + message1 = Message(MessageType::say, "content"); + message2 = Message(MessageType::warn, "content"); + + EXPECT_TRUE(message1 < message2); + EXPECT_FALSE(message2 < message1); +} + +TEST_F(MessageTest, lessThanOperatorShouldCompareContent) { + Message message1(MessageType::say, "content"); + Message message2(MessageType::say, "content"); + + EXPECT_FALSE(message1 < message2); + EXPECT_FALSE(message2 < message1); + + message1 = Message(MessageType::say, "content1"); + message2 = Message(MessageType::say, "content2"); + + EXPECT_TRUE(message1 < message2); + EXPECT_FALSE(message2 < message1); +} + +TEST_F( + MessageTest, + lessThanOperatorShouldUseCaseSensitiveLexicographicalComparisonForConditions) { + Message message1(MessageType::say, "content", "condition"); + Message message2(MessageType::say, "content", "condition"); + + EXPECT_FALSE(message1 < message2); + EXPECT_FALSE(message2 < message1); + + message1 = Message(MessageType::say, "content", "condition"); + message2 = Message(MessageType::say, "content", "Condition"); + + EXPECT_TRUE(message2 < message1); + EXPECT_FALSE(message1 < message2); + + message1 = Message(MessageType::say, "content", "condition1"); + message2 = Message(MessageType::say, "content", "condition2"); + + EXPECT_TRUE(message1 < message2); + EXPECT_FALSE(message2 < message1); +} + +TEST_F( + MessageTest, + greaterThanOperatorShouldReturnTrueIfTheSecondMessageIsLessThanTheFirst) { + Message message1(MessageType::say, "content"); + Message message2(MessageType::say, "content"); + + EXPECT_FALSE(message1 > message2); + EXPECT_FALSE(message2 > message1); + + message1 = Message(MessageType::say, "content"); + message2 = Message(MessageType::warn, "content"); + + EXPECT_FALSE(message1 > message2); + EXPECT_TRUE(message2 > message1); + + message1 = Message(MessageType::say, "content"); + message2 = Message(MessageType::say, "content"); + + EXPECT_FALSE(message1 > message2); + EXPECT_FALSE(message2 > message1); + + message1 = Message(MessageType::say, "content1"); + message2 = Message(MessageType::say, "content2"); + + EXPECT_FALSE(message1 > message2); + EXPECT_TRUE(message2 > message1); + + message1 = Message(MessageType::say, "content", "condition"); + message2 = Message(MessageType::say, "content", "condition"); + + EXPECT_FALSE(message1 > message2); + EXPECT_FALSE(message2 > message1); + + message1 = Message(MessageType::say, "content", "condition"); + message2 = Message(MessageType::say, "content", "Condition"); + + EXPECT_FALSE(message2 > message1); + EXPECT_TRUE(message1 > message2); + + message1 = Message(MessageType::say, "content", "condition1"); + message2 = Message(MessageType::say, "content", "condition2"); + + EXPECT_FALSE(message1 > message2); + EXPECT_TRUE(message2 > message1); +} + +TEST_F( + MessageTest, + lessThanOrEqualOperatorShouldReturnTrueIfTheFirstMessageIsNotGreaterThanTheSecond) { + Message message1(MessageType::say, "content"); + Message message2(MessageType::say, "content"); + + EXPECT_TRUE(message1 <= message2); + EXPECT_TRUE(message2 <= message1); + + message1 = Message(MessageType::say, "content"); + message2 = Message(MessageType::warn, "content"); + + EXPECT_TRUE(message1 <= message2); + EXPECT_FALSE(message2 <= message1); + + message1 = Message(MessageType::say, "content"); + message2 = Message(MessageType::say, "content"); + + EXPECT_TRUE(message1 <= message2); + EXPECT_TRUE(message2 <= message1); + + message1 = Message(MessageType::say, "content1"); + message2 = Message(MessageType::say, "content2"); + + EXPECT_TRUE(message1 <= message2); + EXPECT_FALSE(message2 <= message1); + + message1 = Message(MessageType::say, "content", "condition"); + message2 = Message(MessageType::say, "content", "condition"); + + EXPECT_TRUE(message1 <= message2); + EXPECT_TRUE(message2 <= message1); + + message1 = Message(MessageType::say, "content", "condition"); + message2 = Message(MessageType::say, "content", "Condition"); + + EXPECT_TRUE(message2 <= message1); + EXPECT_FALSE(message1 <= message2); + + message1 = Message(MessageType::say, "content", "condition1"); + message2 = Message(MessageType::say, "content", "condition2"); + + EXPECT_TRUE(message1 <= message2); + EXPECT_FALSE(message2 <= message1); +} + +TEST_F( + MessageTest, + greaterThanOrEqualToOperatorShouldReturnTrueIfTheFirstMessageIsNotLessThanTheSecond) { + Message message1(MessageType::say, "content"); + Message message2(MessageType::say, "content"); + + EXPECT_TRUE(message1 >= message2); + EXPECT_TRUE(message2 >= message1); + + message1 = Message(MessageType::say, "content"); + message2 = Message(MessageType::warn, "content"); + + EXPECT_FALSE(message1 >= message2); + EXPECT_TRUE(message2 >= message1); + + message1 = Message(MessageType::say, "content"); + message2 = Message(MessageType::say, "content"); + + EXPECT_TRUE(message1 >= message2); + EXPECT_TRUE(message2 >= message1); + + message1 = Message(MessageType::say, "content1"); + message2 = Message(MessageType::say, "content2"); + + EXPECT_FALSE(message1 >= message2); + EXPECT_TRUE(message2 >= message1); + + message1 = Message(MessageType::say, "content", "condition"); + message2 = Message(MessageType::say, "content", "condition"); + + EXPECT_TRUE(message1 >= message2); + EXPECT_TRUE(message2 >= message1); + + message1 = Message(MessageType::say, "content", "condition"); + message2 = Message(MessageType::say, "content", "Condition"); + + EXPECT_FALSE(message2 >= message1); + EXPECT_TRUE(message1 >= message2); + + message1 = Message(MessageType::say, "content", "condition1"); + message2 = Message(MessageType::say, "content", "condition2"); + + EXPECT_FALSE(message1 >= message2); + EXPECT_TRUE(message2 >= message1); +} +} +} + +#endif diff --git a/cxx/src/tests/api/internals/metadata/plugin_cleaning_data_test.h b/cxx/src/tests/api/internals/metadata/plugin_cleaning_data_test.h new file mode 100644 index 00000000..890e5cc7 --- /dev/null +++ b/cxx/src/tests/api/internals/metadata/plugin_cleaning_data_test.h @@ -0,0 +1,315 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERNALS_METADATA_PLUGIN_CLEANING_DATA +#define LOOT_TESTS_API_INTERNALS_METADATA_PLUGIN_CLEANING_DATA + +#include "loot/metadata/plugin_cleaning_data.h" +#include "tests/common_game_test_fixture.h" + +namespace loot { +namespace test { +class PluginCleaningDataTest : public CommonGameTestFixture { +protected: + PluginCleaningDataTest() : + CommonGameTestFixture(GameType::tes4), + info_(std::vector({ + MessageContent("info"), + })) {} + + const std::vector info_; +}; + +TEST_F(PluginCleaningDataTest, + defaultConstructorShouldLeaveAllCountsAtZeroAndTheUtilityStringEmpty) { + PluginCleaningData info; + EXPECT_EQ(0u, info.GetCRC()); + EXPECT_EQ(0u, info.GetITMCount()); + EXPECT_EQ(0u, info.GetDeletedReferenceCount()); + EXPECT_EQ(0u, info.GetDeletedNavmeshCount()); + EXPECT_TRUE(info.GetCleaningUtility().empty()); + EXPECT_TRUE(info.GetDetail().empty()); +} + +TEST_F(PluginCleaningDataTest, contentConstructorShouldStoreAllGivenData) { + PluginCleaningData info(0x12345678, "cleaner", info_, 2, 10, 30); + EXPECT_EQ(0x12345678u, info.GetCRC()); + EXPECT_EQ(2u, info.GetITMCount()); + EXPECT_EQ(10u, info.GetDeletedReferenceCount()); + EXPECT_EQ(30u, info.GetDeletedNavmeshCount()); + EXPECT_EQ("cleaner", info.GetCleaningUtility()); + EXPECT_EQ(info_, info.GetDetail()); +} + +TEST_F(PluginCleaningDataTest, equalityShouldCheckEqualityOfAllFields) { + PluginCleaningData info1(0x12345678, "cleaner", info_, 2, 10, 30); + PluginCleaningData info2(0x12345678, "cleaner", info_, 2, 10, 30); + EXPECT_TRUE(info1 == info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x87654321, "cleaner", info_, 2, 10, 30); + EXPECT_FALSE(info1 == info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "Cleaner", info_, 2, 10, 30); + EXPECT_FALSE(info1 == info2); + + info1 = PluginCleaningData(0x12345678, "cleaner1", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner2", info_, 2, 10, 30); + EXPECT_FALSE(info1 == info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData( + 0x12345678, "cleaner", std::vector(), 2, 10, 30); + EXPECT_FALSE(info1 == info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 4, 10, 30); + EXPECT_FALSE(info1 == info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 20, 30); + EXPECT_FALSE(info1 == info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 60); + EXPECT_FALSE(info1 == info2); +} + +TEST_F(PluginCleaningDataTest, inequalityShouldBeTheInverseOfEquality) { + PluginCleaningData info1(0x12345678, "cleaner", info_, 2, 10, 30); + PluginCleaningData info2(0x12345678, "cleaner", info_, 2, 10, 30); + EXPECT_FALSE(info1 != info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x87654321, "cleaner", info_, 2, 10, 30); + EXPECT_TRUE(info1 != info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "Cleaner", info_, 2, 10, 30); + EXPECT_TRUE(info1 != info2); + + info1 = PluginCleaningData(0x12345678, "cleaner1", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner2", info_, 2, 10, 30); + EXPECT_TRUE(info1 != info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData( + 0x12345678, "cleaner", std::vector(), 2, 10, 30); + EXPECT_TRUE(info1 != info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 4, 10, 30); + EXPECT_TRUE(info1 != info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 20, 30); + EXPECT_TRUE(info1 != info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 60); + EXPECT_TRUE(info1 != info2); +} + +TEST_F(PluginCleaningDataTest, lessThanOperatorShouldCompareAllFields) { + PluginCleaningData info1(0x12345678, "cleaner", info_, 2, 10, 30); + PluginCleaningData info2(0x12345678, "cleaner", info_, 2, 10, 30); + EXPECT_FALSE(info1 < info2); + EXPECT_FALSE(info2 < info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x87654321, "cleaner", info_, 2, 10, 30); + EXPECT_TRUE(info1 < info2); + EXPECT_FALSE(info2 < info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "Cleaner", info_, 2, 10, 30); + EXPECT_TRUE(info2 < info1); + EXPECT_FALSE(info1 < info2); + + info1 = PluginCleaningData(0x12345678, "cleaner1", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner2", info_, 2, 10, 30); + EXPECT_TRUE(info1 < info2); + EXPECT_FALSE(info2 < info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData( + 0x12345678, "cleaner", std::vector(), 2, 10, 30); + EXPECT_TRUE(info2 < info1); + EXPECT_FALSE(info1 < info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 4, 10, 30); + EXPECT_TRUE(info1 < info2); + EXPECT_FALSE(info2 < info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 20, 30); + EXPECT_TRUE(info1 < info2); + EXPECT_FALSE(info2 < info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 60); + EXPECT_TRUE(info1 < info2); + EXPECT_FALSE(info2 < info1); +} + +TEST_F( + PluginCleaningDataTest, + greaterThanOperatorShouldReturnTrueIfTheSecondPluginCleaningDataIsLessThanTheFirst) { + PluginCleaningData info1(0x12345678, "cleaner", info_, 2, 10, 30); + PluginCleaningData info2(0x12345678, "cleaner", info_, 2, 10, 30); + EXPECT_FALSE(info1 > info2); + EXPECT_FALSE(info2 > info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x87654321, "cleaner", info_, 2, 10, 30); + EXPECT_FALSE(info1 > info2); + EXPECT_TRUE(info2 > info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "Cleaner", info_, 2, 10, 30); + EXPECT_FALSE(info2 > info1); + EXPECT_TRUE(info1 > info2); + + info1 = PluginCleaningData(0x12345678, "cleaner1", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner2", info_, 2, 10, 30); + EXPECT_FALSE(info1 > info2); + EXPECT_TRUE(info2 > info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData( + 0x12345678, "cleaner", std::vector(), 2, 10, 30); + EXPECT_FALSE(info2 > info1); + EXPECT_TRUE(info1 > info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 4, 10, 30); + EXPECT_FALSE(info1 > info2); + EXPECT_TRUE(info2 > info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 20, 30); + EXPECT_FALSE(info1 > info2); + EXPECT_TRUE(info2 > info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 60); + EXPECT_FALSE(info1 > info2); + EXPECT_TRUE(info2 > info1); +} + +TEST_F( + PluginCleaningDataTest, + lessThanOrEqualOperatorShouldReturnTrueIfTheFirstPluginCleaningDataIsNotGreaterThanTheSecond) { + PluginCleaningData info1(0x12345678, "cleaner", info_, 2, 10, 30); + PluginCleaningData info2(0x12345678, "cleaner", info_, 2, 10, 30); + EXPECT_TRUE(info1 <= info2); + EXPECT_TRUE(info2 <= info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x87654321, "cleaner", info_, 2, 10, 30); + EXPECT_TRUE(info1 < info2); + EXPECT_FALSE(info2 < info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "Cleaner", info_, 2, 10, 30); + EXPECT_TRUE(info2 < info1); + EXPECT_FALSE(info1 < info2); + + info1 = PluginCleaningData(0x12345678, "cleaner1", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner2", info_, 2, 10, 30); + EXPECT_TRUE(info1 < info2); + EXPECT_FALSE(info2 < info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData( + 0x12345678, "cleaner", std::vector(), 2, 10, 30); + EXPECT_TRUE(info2 < info1); + EXPECT_FALSE(info1 < info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 4, 10, 30); + EXPECT_TRUE(info1 < info2); + EXPECT_FALSE(info2 < info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 20, 30); + EXPECT_TRUE(info1 < info2); + EXPECT_FALSE(info2 < info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 60); + EXPECT_TRUE(info1 < info2); + EXPECT_FALSE(info2 < info1); +} + +TEST_F( + PluginCleaningDataTest, + greaterThanOrEqualToOperatorShouldReturnTrueIfTheFirstPluginCleaningDataIsNotLessThanTheSecond) { + PluginCleaningData info1(0x12345678, "cleaner", info_, 2, 10, 30); + PluginCleaningData info2(0x12345678, "cleaner", info_, 2, 10, 30); + EXPECT_TRUE(info1 >= info2); + EXPECT_TRUE(info2 >= info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x87654321, "cleaner", info_, 2, 10, 30); + EXPECT_FALSE(info1 > info2); + EXPECT_TRUE(info2 > info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "Cleaner", info_, 2, 10, 30); + EXPECT_FALSE(info2 > info1); + EXPECT_TRUE(info1 > info2); + + info1 = PluginCleaningData(0x12345678, "cleaner1", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner2", info_, 2, 10, 30); + EXPECT_FALSE(info1 > info2); + EXPECT_TRUE(info2 > info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData( + 0x12345678, "cleaner", std::vector(), 2, 10, 30); + EXPECT_FALSE(info2 > info1); + EXPECT_TRUE(info1 > info2); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 4, 10, 30); + EXPECT_FALSE(info1 > info2); + EXPECT_TRUE(info2 > info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 20, 30); + EXPECT_FALSE(info1 > info2); + EXPECT_TRUE(info2 > info1); + + info1 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 30); + info2 = PluginCleaningData(0x12345678, "cleaner", info_, 2, 10, 60); + EXPECT_FALSE(info1 > info2); + EXPECT_TRUE(info2 > info1); +} +} +} + +#endif diff --git a/cxx/src/tests/api/internals/metadata/plugin_metadata_test.h b/cxx/src/tests/api/internals/metadata/plugin_metadata_test.h new file mode 100644 index 00000000..4629c3de --- /dev/null +++ b/cxx/src/tests/api/internals/metadata/plugin_metadata_test.h @@ -0,0 +1,397 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERNALS_METADATA_PLUGIN_METADATA_TEST +#define LOOT_TESTS_API_INTERNALS_METADATA_PLUGIN_METADATA_TEST + +#include "loot/metadata/plugin_metadata.h" +#include "tests/common_game_test_fixture.h" + +namespace loot { +namespace test { +class PluginMetadataTest : public CommonGameTestFixture { +protected: + PluginMetadataTest() : + CommonGameTestFixture(GameType::tes5), + info_(std::vector({ + MessageContent("info"), + })) {} + + const std::vector info_; +}; + +TEST_F( + PluginMetadataTest, + defaultConstructorShouldLeaveNameEmptyAndEnableMetadataAndLeaveGroupUnset) { + PluginMetadata plugin; + + EXPECT_TRUE(plugin.GetName().empty()); + EXPECT_FALSE(plugin.GetGroup()); +} + +TEST_F( + PluginMetadataTest, + stringConstructorShouldSetNameToGivenStringAndEnableMetadataAndLeaveGroupUnset) { + PluginMetadata plugin(blankEsm); + + EXPECT_EQ(blankEsm, plugin.GetName()); + EXPECT_FALSE(plugin.GetGroup()); +} + +TEST_F(PluginMetadataTest, + nameMatchesShouldUseCaseInsensitiveNameComparisonForNonRegexNames) { + PluginMetadata plugin(blankEsm); + + EXPECT_TRUE(plugin.NameMatches("blank.esm")); + EXPECT_FALSE(plugin.NameMatches(blankDifferentEsm)); +} + +TEST_F(PluginMetadataTest, + nameMatchesShouldTreatGivenPluginNameStringsAsLiterals) { + PluginMetadata plugin(blankEsm); + std::string regex = "blan.\\.esm"; + + EXPECT_FALSE(plugin.NameMatches(regex)); +} + +TEST_F(PluginMetadataTest, + nameMatchesShouldUseCaseInsensitiveRegexMatchingForARegexName) { + PluginMetadata plugin("Blan.\\.esm"); + + EXPECT_TRUE(plugin.NameMatches("blank.esm")); + EXPECT_FALSE(plugin.NameMatches(blankDifferentEsm)); +} + +TEST_F(PluginMetadataTest, mergeMetadataShouldNotChangeName) { + PluginMetadata plugin1(blankEsm); + PluginMetadata plugin2(blankDifferentEsm); + + plugin1.MergeMetadata(plugin2); + + EXPECT_EQ(blankEsm, plugin1.GetName()); +} + +TEST_F(PluginMetadataTest, + mergeMetadataShouldNotUseMergedGroupIfItAndCurrentGroupAreBothExplicit) { + PluginMetadata plugin1; + PluginMetadata plugin2; + + plugin1.SetGroup("group1"); + plugin2.SetGroup("group2"); + plugin1.MergeMetadata(plugin2); + + EXPECT_EQ("group1", plugin1.GetGroup()); +} + +TEST_F(PluginMetadataTest, + mergeMetadataShouldNotUseMergedGroupIfItAndCurrentGroupAreBothImplicit) { + PluginMetadata plugin1; + PluginMetadata plugin2; + + plugin1.MergeMetadata(plugin2); + + EXPECT_FALSE(plugin1.GetGroup().has_value()); +} + +TEST_F( + PluginMetadataTest, + mergeMetadataShouldNotUseMergedGroupIfItIsImplicitAndCurrentGroupIsExplicit) { + PluginMetadata plugin1; + PluginMetadata plugin2; + + plugin1.SetGroup("group1"); + plugin1.MergeMetadata(plugin2); + + EXPECT_EQ("group1", plugin1.GetGroup()); +} + +TEST_F( + PluginMetadataTest, + mergeMetadataShouldUseMergedGroupIfItIsExplicitAndCurrentGroupIsImplicit) { + PluginMetadata plugin1; + PluginMetadata plugin2; + + plugin2.SetGroup("group2"); + plugin1.MergeMetadata(plugin2); + + EXPECT_EQ("group2", plugin1.GetGroup()); +} + +TEST_F(PluginMetadataTest, mergeMetadataShouldMergeLoadAfterData) { + PluginMetadata plugin1; + PluginMetadata plugin2; + File file1(blankEsm); + File file2(blankDifferentEsm); + + plugin1.SetLoadAfterFiles({file1}); + plugin2.SetLoadAfterFiles({file1, file2}); + plugin1.MergeMetadata(plugin2); + + EXPECT_EQ(std::vector({file1, file2}), plugin1.GetLoadAfterFiles()); +} + +TEST_F(PluginMetadataTest, mergeMetadataShouldMergeRequirementData) { + PluginMetadata plugin1; + PluginMetadata plugin2; + File file1(blankEsm); + File file2(blankDifferentEsm); + + plugin1.SetRequirements({file1}); + plugin2.SetRequirements({file1, file2}); + plugin1.MergeMetadata(plugin2); + + EXPECT_EQ(std::vector({file1, file2}), plugin1.GetRequirements()); +} + +TEST_F(PluginMetadataTest, mergeMetadataShouldMergeIncompatibilityData) { + PluginMetadata plugin1; + PluginMetadata plugin2; + File file1(blankEsm); + File file2(blankDifferentEsm); + + plugin1.SetIncompatibilities({file1}); + plugin2.SetIncompatibilities({file1, file2}); + plugin1.MergeMetadata(plugin2); + + EXPECT_EQ(std::vector({file1, file2}), plugin1.GetIncompatibilities()); +} + +TEST_F(PluginMetadataTest, mergeMetadataShouldMergeMessages) { + PluginMetadata plugin1; + PluginMetadata plugin2; + Message message(MessageType::say, "content"); + + plugin1.SetMessages({message}); + plugin2.SetMessages({message}); + plugin1.MergeMetadata(plugin2); + + EXPECT_EQ(std::vector({message, message}), plugin1.GetMessages()); +} + +TEST_F(PluginMetadataTest, mergeMetadataShouldMergeTags) { + PluginMetadata plugin1; + PluginMetadata plugin2; + Tag tag1("Relev"); + Tag tag2("Relev", false); + Tag tag3("Delev"); + + plugin1.SetTags({tag1}); + plugin2.SetTags({tag1, tag2, tag3}); + plugin1.MergeMetadata(plugin2); + + EXPECT_EQ(std::vector({tag1, tag2, tag3}), plugin1.GetTags()); +} + +TEST_F(PluginMetadataTest, mergeMetadataShouldMergeDirtyInfoData) { + PluginMetadata plugin1; + PluginMetadata plugin2; + PluginCleaningData info1(0x5, "utility", info_, 1, 2, 3); + PluginCleaningData info2(0xA, "utility", info_, 1, 2, 3); + + plugin1.SetDirtyInfo({info1}); + plugin2.SetDirtyInfo({info1, info2}); + plugin1.MergeMetadata(plugin2); + + EXPECT_EQ(std::vector({info1, info2}), + plugin1.GetDirtyInfo()); +} +TEST_F(PluginMetadataTest, mergeMetadataShouldMergeCleanInfoData) { + PluginMetadata plugin1; + PluginMetadata plugin2; + PluginCleaningData info1(0x5, "utility"); + PluginCleaningData info2(0xA, "utility"); + + plugin1.SetCleanInfo({info1}); + plugin2.SetCleanInfo({info1, info2}); + plugin1.MergeMetadata(plugin2); + + EXPECT_EQ(std::vector({info1, info2}), + plugin1.GetCleanInfo()); +} + +TEST_F(PluginMetadataTest, mergeMetadataShouldMergeLocationData) { + PluginMetadata plugin1; + PluginMetadata plugin2; + Location location1("http://www.example.com/1"); + Location location2("http://www.example.com/2"); + + plugin1.SetLocations({location1}); + plugin2.SetLocations({location1, location2}); + plugin1.MergeMetadata(plugin2); + + EXPECT_EQ(std::vector({location1, location2}), + plugin1.GetLocations()); +} + +TEST_F(PluginMetadataTest, unsetGroupShouldLeaveNoGroupValueSet) { + PluginMetadata plugin; + EXPECT_FALSE(plugin.GetGroup().has_value()); + + plugin.SetGroup("test"); + EXPECT_EQ("test", plugin.GetGroup().value()); + + plugin.UnsetGroup(); + EXPECT_FALSE(plugin.GetGroup().has_value()); +} + +TEST_F(PluginMetadataTest, + hasNameOnlyShouldBeTrueForADefaultConstructedPluginMetadataObject) { + PluginMetadata plugin; + + EXPECT_TRUE(plugin.HasNameOnly()); +} + +TEST_F(PluginMetadataTest, + hasNameOnlyShouldBeTrueForAPluginMetadataObjectConstructedWithAName) { + PluginMetadata plugin(blankEsp); + + EXPECT_TRUE(plugin.HasNameOnly()); +} + +TEST_F(PluginMetadataTest, hasNameOnlyShouldBeFalseIfTheGroupIsExplicit) { + PluginMetadata plugin; + plugin.SetGroup("group"); + + EXPECT_FALSE(plugin.HasNameOnly()); +} + +TEST_F(PluginMetadataTest, hasNameOnlyShouldBeFalseIfLoadAfterMetadataExists) { + PluginMetadata plugin(blankEsp); + plugin.SetLoadAfterFiles({File(blankEsm)}); + + EXPECT_FALSE(plugin.HasNameOnly()); +} + +TEST_F(PluginMetadataTest, + hasNameOnlyShouldBeFalseIfRequirementMetadataExists) { + PluginMetadata plugin(blankEsp); + plugin.SetRequirements({File(blankEsm)}); + + EXPECT_FALSE(plugin.HasNameOnly()); +} + +TEST_F(PluginMetadataTest, + hasNameOnlyShouldBeFalseIfIncompatibilityMetadataExists) { + PluginMetadata plugin(blankEsp); + plugin.SetIncompatibilities({File(blankEsm)}); + + EXPECT_FALSE(plugin.HasNameOnly()); +} + +TEST_F(PluginMetadataTest, hasNameOnlyShouldBeFalseIfMessagesExist) { + PluginMetadata plugin(blankEsp); + plugin.SetMessages({Message(MessageType::say, "content")}); + + EXPECT_FALSE(plugin.HasNameOnly()); +} + +TEST_F(PluginMetadataTest, hasNameOnlyShouldBeFalseIfTagsExist) { + PluginMetadata plugin(blankEsp); + plugin.SetTags({Tag("Relev")}); + + EXPECT_FALSE(plugin.HasNameOnly()); +} + +TEST_F(PluginMetadataTest, hasNameOnlyShouldBeFalseIfDirtyInfoExists) { + PluginMetadata plugin(blankEsp); + plugin.SetDirtyInfo({PluginCleaningData(5, "utility", info_, 0, 1, 2)}); + + EXPECT_FALSE(plugin.HasNameOnly()); +} + +TEST_F(PluginMetadataTest, hasNameOnlyShouldBeFalseIfCleanInfoExists) { + PluginMetadata plugin(blankEsp); + plugin.SetCleanInfo({PluginCleaningData(5, "utility")}); + + EXPECT_FALSE(plugin.HasNameOnly()); +} + +TEST_F(PluginMetadataTest, hasNameOnlyShouldBeFalseIfLocationsExist) { + PluginMetadata plugin(blankEsp); + plugin.SetLocations({Location("http://www.example.com")}); + + EXPECT_FALSE(plugin.HasNameOnly()); +} + +TEST_F(PluginMetadataTest, isRegexPluginShouldBeFalseForAnEmptyPluginName) { + PluginMetadata plugin; + + EXPECT_FALSE(plugin.IsRegexPlugin()); +} + +TEST_F(PluginMetadataTest, isRegexPluginShouldBeFalseForAnExactPluginFilename) { + PluginMetadata plugin(blankEsm); + + EXPECT_FALSE(plugin.IsRegexPlugin()); +} + +TEST_F(PluginMetadataTest, + isRegexPluginShouldBeTrueIfThePluginNameContainsAColon) { + PluginMetadata plugin("Blank:.esm"); + + EXPECT_TRUE(plugin.IsRegexPlugin()); +} + +TEST_F(PluginMetadataTest, + isRegexPluginShouldBeTrueIfThePluginNameContainsABackslash) { + PluginMetadata plugin("Blank\\.esm"); + + EXPECT_TRUE(plugin.IsRegexPlugin()); +} + +TEST_F(PluginMetadataTest, + isRegexPluginShouldBeTrueIfThePluginNameContainsAnAsterisk) { + PluginMetadata plugin("Blank*.esm"); + + EXPECT_TRUE(plugin.IsRegexPlugin()); +} + +TEST_F(PluginMetadataTest, + isRegexPluginShouldBeTrueIfThePluginNameContainsAQuestionMark) { + PluginMetadata plugin("Blank?.esm"); + + EXPECT_TRUE(plugin.IsRegexPlugin()); +} + +TEST_F(PluginMetadataTest, + isRegexPluginShouldBeTrueIfThePluginNameContainsAVerticalBar) { + PluginMetadata plugin("Blank|.esm"); + + EXPECT_TRUE(plugin.IsRegexPlugin()); +} + +TEST_F(PluginMetadataTest, + asYamlShouldReturnAStringContainingTheMetadataEmittedAsYaml) { + PluginMetadata plugin(blankEsm); + plugin.SetLoadAfterFiles({File(blankEsm)}); + + EXPECT_EQ( + "name: 'Blank.esm'\n" + "after: ['Blank.esm']", + plugin.AsYaml()); +} +} +} + +#endif diff --git a/cxx/src/tests/api/internals/metadata/tag_test.h b/cxx/src/tests/api/internals/metadata/tag_test.h new file mode 100644 index 00000000..e7793a4d --- /dev/null +++ b/cxx/src/tests/api/internals/metadata/tag_test.h @@ -0,0 +1,289 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERNALS_METADATA_TAG_TEST +#define LOOT_TESTS_API_INTERNALS_METADATA_TAG_TEST + +#include + +#include "loot/metadata/tag.h" + +namespace loot { +namespace test { +TEST(Tag, + defaultConstructorShouldSetEmptyNameAndConditionStringsForATagAddition) { + Tag tag; + + EXPECT_TRUE(tag.GetName().empty()); + EXPECT_TRUE(tag.IsAddition()); + EXPECT_TRUE(tag.GetCondition().empty()); +} + +TEST(Tag, dataConstructorShouldSetFieldsToGivenValues) { + Tag tag("name", false, "condition"); + + EXPECT_EQ("name", tag.GetName()); + EXPECT_FALSE(tag.IsAddition()); + EXPECT_EQ("condition", tag.GetCondition()); +} + +TEST(Tag, equalityShouldBeCaseSensitiveOnNameAndCondition) { + Tag tag1("name", true, "condition"); + Tag tag2("name", true, "condition"); + + EXPECT_TRUE(tag1 == tag2); + + tag1 = Tag("name"); + tag2 = Tag("Name"); + + EXPECT_FALSE(tag1 == tag2); + + tag1 = Tag("name", true, "condition"); + tag2 = Tag("name", true, "Condition"); + + EXPECT_FALSE(tag1 == tag2); + + tag1 = Tag("name1"); + tag2 = Tag("name2"); + + EXPECT_FALSE(tag1 == tag2); + + tag1 = Tag("name", true, "condition1"); + tag2 = Tag("name", true, "condition2"); + + EXPECT_FALSE(tag1 == tag2); +} + +TEST(Tag, equalityShouldRequireEqualAdditionStates) { + Tag tag1("name", true, "condition"); + Tag tag2("name", true, "condition"); + + EXPECT_TRUE(tag1 == tag2); + + tag1 = Tag("name", true); + tag2 = Tag("name", false); + + EXPECT_FALSE(tag1 == tag2); +} + +TEST(Tag, inequalityShouldBeTheInverseOfEquality) { + Tag tag1("name", true, "condition"); + Tag tag2("name", true, "condition"); + + EXPECT_FALSE(tag1 != tag2); + + tag1 = Tag("name"); + tag2 = Tag("Name"); + + EXPECT_TRUE(tag1 != tag2); + + tag1 = Tag("name", true, "condition"); + tag2 = Tag("name", true, "Condition"); + + EXPECT_TRUE(tag1 != tag2); + + tag1 = Tag("name1"); + tag2 = Tag("name2"); + + EXPECT_TRUE(tag1 != tag2); + + tag1 = Tag("name", true, "condition1"); + tag2 = Tag("name", true, "condition2"); + + EXPECT_TRUE(tag1 != tag2); + + tag1 = Tag("name", true, "condition"); + tag2 = Tag("name", true, "condition"); + + EXPECT_FALSE(tag1 != tag2); + + tag1 = Tag("name", true); + tag2 = Tag("name", false); + + EXPECT_TRUE(tag1 != tag2); +} + +TEST( + Tag, + lessThanOperatorShouldUseCaseSensitiveLexicographicalComparisonForNameAndCondition) { + Tag tag1("name", true, "condition"); + Tag tag2("name", true, "condition"); + + EXPECT_FALSE(tag1 < tag2); + EXPECT_FALSE(tag2 < tag1); + + tag1 = Tag("name"); + tag2 = Tag("Name"); + + EXPECT_FALSE(tag1 < tag2); + EXPECT_TRUE(tag2 < tag1); + + tag1 = Tag("name", true, "condition"); + tag2 = Tag("name", true, "Condition"); + + EXPECT_FALSE(tag1 < tag2); + EXPECT_TRUE(tag2 < tag1); + + tag1 = Tag("name1"); + tag2 = Tag("name2"); + + EXPECT_TRUE(tag1 < tag2); + EXPECT_FALSE(tag2 < tag1); + + tag1 = Tag("name", true, "condition1"); + tag2 = Tag("name", true, "condition2"); + + EXPECT_TRUE(tag1 < tag2); + EXPECT_FALSE(tag2 < tag1); +} + +TEST(Tag, lessThanOperatorShouldTreatTagAdditionsAsBeingLessThanRemovals) { + Tag tag1("name", true); + Tag tag2("name", false); + + EXPECT_TRUE(tag1 < tag2); + EXPECT_FALSE(tag2 < tag1); +} + +TEST(Tag, greaterThanOperatorShouldReturnTrueIfTheSecondTagIsLessThanTheFirst) { + Tag tag1("name", true, "condition"); + Tag tag2("name", true, "condition"); + + EXPECT_FALSE(tag1 > tag2); + EXPECT_FALSE(tag2 > tag1); + + tag1 = Tag("name"); + tag2 = Tag("Name"); + + EXPECT_TRUE(tag1 > tag2); + EXPECT_FALSE(tag2 > tag1); + + tag1 = Tag("name", true, "condition"); + tag2 = Tag("name", true, "Condition"); + + EXPECT_TRUE(tag1 > tag2); + EXPECT_FALSE(tag2 > tag1); + + tag1 = Tag("name1"); + tag2 = Tag("name2"); + + EXPECT_FALSE(tag1 > tag2); + EXPECT_TRUE(tag2 > tag1); + + tag1 = Tag("name", true, "condition1"); + tag2 = Tag("name", true, "condition2"); + + EXPECT_FALSE(tag1 > tag2); + EXPECT_TRUE(tag2 > tag1); + + tag1 = Tag("name", true); + tag2 = Tag("name", false); + + EXPECT_FALSE(tag1 > tag2); + EXPECT_TRUE(tag2 > tag1); +} + +TEST( + Tag, + lessThanOrEqualOperatorShouldReturnTrueIfTheFirstTagIsNotGreaterThanTheSecond) { + Tag tag1("name", true, "condition"); + Tag tag2("name", true, "condition"); + + EXPECT_TRUE(tag1 <= tag2); + EXPECT_TRUE(tag2 <= tag1); + + tag1 = Tag("name"); + tag2 = Tag("Name"); + + EXPECT_FALSE(tag1 <= tag2); + EXPECT_TRUE(tag2 <= tag1); + + tag1 = Tag("name", true, "condition"); + tag2 = Tag("name", true, "Condition"); + + EXPECT_FALSE(tag1 <= tag2); + EXPECT_TRUE(tag2 <= tag1); + + tag1 = Tag("name1"); + tag2 = Tag("name2"); + + EXPECT_TRUE(tag1 <= tag2); + EXPECT_FALSE(tag2 <= tag1); + + tag1 = Tag("name", true, "condition1"); + tag2 = Tag("name", true, "condition2"); + + EXPECT_TRUE(tag1 <= tag2); + EXPECT_FALSE(tag2 <= tag1); + + tag1 = Tag("name", true); + tag2 = Tag("name", false); + + EXPECT_TRUE(tag1 <= tag2); + EXPECT_FALSE(tag2 <= tag1); +} + +TEST( + Tag, + greaterThanOrEqualToOperatorShouldReturnTrueIfTheFirstTagIsNotLessThanTheSecond) { + Tag tag1("name", true, "condition"); + Tag tag2("name", true, "condition"); + + EXPECT_TRUE(tag1 >= tag2); + EXPECT_TRUE(tag2 >= tag1); + + tag1 = Tag("name"); + tag2 = Tag("Name"); + + EXPECT_TRUE(tag1 >= tag2); + EXPECT_FALSE(tag2 >= tag1); + + tag1 = Tag("name", true, "condition"); + tag2 = Tag("name", true, "Condition"); + + EXPECT_TRUE(tag1 >= tag2); + EXPECT_FALSE(tag2 >= tag1); + + tag1 = Tag("name1"); + tag2 = Tag("name2"); + + EXPECT_FALSE(tag1 >= tag2); + EXPECT_TRUE(tag2 >= tag1); + + tag1 = Tag("name", true, "condition1"); + tag2 = Tag("name", true, "condition2"); + + EXPECT_FALSE(tag1 >= tag2); + EXPECT_TRUE(tag2 >= tag1); + + tag1 = Tag("name", true); + tag2 = Tag("name", false); + + EXPECT_FALSE(tag1 >= tag2); + EXPECT_TRUE(tag2 >= tag1); +} +} +} + +#endif From e8bc6a4805742f2c8067ef66e3abfebb2699e4b1 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 20 Apr 2025 18:46:00 +0100 Subject: [PATCH 083/206] Fix replacing masterlist prelude with CRLF line endings --- src/metadata/metadata_document.rs | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index d2c6b451..be168d9f 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -383,7 +383,7 @@ fn find_prelude_bounds(masterlist: &str) -> Option<(usize, usize)> { pos += next_line_break_pos + 1; if let Some(c) = masterlist.as_bytes().get(pos) { - if *c != b' ' && *c != b'#' && *c != b'\n' { + if !matches!(*c, b' ' | b'#' | b'\n' | b'\r') { return Some((start, pos - 1)); } } @@ -412,6 +412,8 @@ mod tests { use super::*; mod metadata_document { + use crate::metadata::MessageType; + use super::*; const METADATA_LIST_YAML: &str = r#"bash_tags: @@ -576,12 +578,12 @@ plugins: let tmp_dir = tempdir().unwrap(); let masterlist_path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&masterlist_path, "prelude:\r\n - &ref\r\n type: say\r\n content: Loaded from same file\r\nglobals:\r\n - *ref\r\n").unwrap(); + std::fs::write(&masterlist_path, "prelude:\r\n - &ref\r\n type: say\r\n content: Loaded from same file\r\n\r\n - &otherRef\r\n type: error\r\n content: Error from same file\r\nglobals:\r\n - *ref\r\n - *otherRef").unwrap(); let prelude_path = tmp_dir.path().join("prelude.yaml"); std::fs::write( &prelude_path, - "common:\r\n - &ref\r\n type: say\r\n content: Loaded from prelude\r\n", + "common:\r\n - &ref\r\n type: say\r\n content: Loaded from prelude\r\n\r\n - &otherRef\r\n type: error\r\n content: An error message", ) .unwrap(); @@ -589,6 +591,14 @@ plugins: metadata_list .load_with_prelude(&masterlist_path, &prelude_path) .unwrap(); + + assert_eq!( + [ + Message::new(MessageType::Say, "Loaded from prelude".to_owned()), + Message::new(MessageType::Error, "An error message".to_owned()), + ], + metadata_list.messages() + ); } #[test] @@ -596,12 +606,12 @@ plugins: let tmp_dir = tempdir().unwrap(); let masterlist_path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&masterlist_path, "prelude:\n - &ref\n type: say\n content: Loaded from same file\nglobals:\n - *ref\n").unwrap(); + std::fs::write(&masterlist_path, "prelude:\n - &ref\n type: say\n content: Loaded from same file\n\n - &otherRef\n type: error\n content: Error from same file\nglobals:\n - *ref\n - *otherRef").unwrap(); let prelude_path = tmp_dir.path().join("prelude.yaml"); std::fs::write( &prelude_path, - "common:\n - &ref\n type: say\n content: Loaded from prelude\n", + "common:\n - &ref\n type: say\n content: Loaded from prelude\n\n - &otherRef\n type: error\n content: An error message", ) .unwrap(); @@ -609,6 +619,14 @@ plugins: metadata_list .load_with_prelude(&masterlist_path, &prelude_path) .unwrap(); + + assert_eq!( + [ + Message::new(MessageType::Say, "Loaded from prelude".to_owned()), + Message::new(MessageType::Error, "An error message".to_owned()), + ], + metadata_list.messages() + ); } #[test] From 5c871bdf0c1cc0cbef043ba517011691e3855679 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 21 Apr 2025 22:08:08 +0100 Subject: [PATCH 084/206] Fix PluginCleaningData missing in Python module --- pyo3/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyo3/src/lib.rs b/pyo3/src/lib.rs index bfe1d40d..ab6ef8a6 100644 --- a/pyo3/src/lib.rs +++ b/pyo3/src/lib.rs @@ -7,8 +7,8 @@ mod plugin; use database::{Database, EdgeType, Vertex}; use game::{Game, GameType}; use metadata::{ - File, Filename, Group, Location, Message, MessageContent, MessageType, PluginMetadata, Tag, - TagSuggestion, select_message_content, + File, Filename, Group, Location, Message, MessageContent, MessageType, PluginCleaningData, + PluginMetadata, Tag, TagSuggestion, select_message_content, }; use plugin::Plugin; use pyo3::{create_exception, exceptions::PyException, prelude::*}; @@ -59,6 +59,7 @@ fn libloot_pyo3(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; From d648011e51298dac7da820fddfc46173dff4c155 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 18:39:13 +0100 Subject: [PATCH 085/206] Refactor FFI error handling To reduce verbosity and duplication. --- cxx/src/database.rs | 3 +- cxx/src/error.rs | 90 +++++++--------------------- cxx/src/game.rs | 6 +- cxx/src/lib.rs | 3 +- ffi-errors/src/lib.rs | 60 ++++++++++++++++++- pyo3/src/database.rs | 15 ++--- pyo3/src/error.rs | 132 +++++++----------------------------------- pyo3/src/game.rs | 7 +-- pyo3/src/metadata.rs | 3 +- 9 files changed, 121 insertions(+), 198 deletions(-) diff --git a/cxx/src/database.rs b/cxx/src/database.rs index 660c75ec..3b5e5eb9 100644 --- a/cxx/src/database.rs +++ b/cxx/src/database.rs @@ -5,9 +5,10 @@ use std::{ use delegate::delegate; use libloot::{WriteMode, error::DatabaseLockPoisonError}; +use libloot_ffi_errors::UnsupportedEnumValueError; use crate::{ - UnsupportedEnumValueError, VerboseError, + VerboseError, ffi::EdgeType, metadata::{Group, Message, OptionalPluginMetadata, PluginMetadata, to_vec_of_unwrapped}, }; diff --git a/cxx/src/error.rs b/cxx/src/error.rs index d9bd7f1a..c735125c 100644 --- a/cxx/src/error.rs +++ b/cxx/src/error.rs @@ -1,5 +1,8 @@ use crate::game::NotValidUtf8; -use libloot_ffi_errors::SystemError; +use libloot_ffi_errors::{ + SystemError, SystemErrorCategory, UnsupportedEnumValueError, fmt_error_chain, + variant_box_from_error, +}; use libloot::{ error::{ @@ -16,9 +19,7 @@ use libloot::{ pub enum VerboseError { CyclicInteractionError(Vec), UndefinedGroupError(String), - EspluginError(i32, String), - LibloadorderError(i32, String), - LciError(i32, String), + SystemError(SystemError), FileAccessError(String), InvalidArgument(String), Other(Box), @@ -41,30 +42,27 @@ impl std::fmt::Display for VerboseError { Self::UndefinedGroupError(group) => { write!(f, "UndefinedGroupError: {}", group) } - Self::EspluginError(c, s) => { - write!(f, "EspluginError: {}: {}", c, s) - } - Self::LibloadorderError(c, s) => { - write!(f, "LibloadorderError: {}: {}", c, s) - } - Self::LciError(c, s) => { - write!(f, "LciError: {}: {}", c, s) + Self::SystemError(e) => { + let prefix = match e.category() { + SystemErrorCategory::Esplugin => "EspluginError", + SystemErrorCategory::Libloadorder => "LibloadorderError", + SystemErrorCategory::LootConditionInterpreter => "LciError", + }; + write!(f, "{}: {}: {}", prefix, e.code(), e.message()) } Self::FileAccessError(s) => write!(f, "FileAccessError: {}", s), Self::InvalidArgument(s) => write!(f, "InvalidArgument: {}", s), - Self::Other(e) => { - write!(f, "{}", e)?; - let mut error = e.as_ref(); - while let Some(source) = error.source() { - write!(f, ": {}", source)?; - error = source; - } - Ok(()) - } + Self::Other(e) => fmt_error_chain(e.as_ref(), f), } } } +variant_box_from_error!(UnsupportedEnumValueError, VerboseError::Other); +variant_box_from_error!(NotValidUtf8, VerboseError::Other); +variant_box_from_error!(DatabaseLockPoisonError, VerboseError::Other); +variant_box_from_error!(MultilingualMessageContentsError, VerboseError::Other); +variant_box_from_error!(RegexError, VerboseError::Other); + impl From for VerboseError { fn from(value: GameHandleCreationError) -> Self { match value { @@ -75,24 +73,6 @@ impl From for VerboseError { } } -impl From for VerboseError { - fn from(value: UnsupportedEnumValueError) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for VerboseError { - fn from(value: NotValidUtf8) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for VerboseError { - fn from(value: DatabaseLockPoisonError) -> Self { - Self::Other(Box::new(value)) - } -} - impl From for VerboseError { fn from(value: LoadPluginsError) -> Self { match value { @@ -126,8 +106,7 @@ impl From for VerboseError { impl From for VerboseError { fn from(value: LoadOrderError) -> Self { - let error = SystemError::from(value); - Self::LibloadorderError(error.code(), error.message().to_string()) + Self::SystemError(SystemError::from(value)) } } @@ -145,8 +124,7 @@ impl From for VerboseError { impl From for VerboseError { fn from(value: ConditionEvaluationError) -> Self { - let error = SystemError::from(value); - Self::LciError(error.code(), error.message().to_string()) + Self::SystemError(SystemError::from(value)) } } @@ -171,20 +149,7 @@ impl From for VerboseError { impl From for VerboseError { fn from(value: PluginDataError) -> Self { - let error = SystemError::from(value); - Self::EspluginError(error.code(), error.message().to_string()) - } -} - -impl From for VerboseError { - fn from(value: MultilingualMessageContentsError) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for VerboseError { - fn from(value: RegexError) -> Self { - Self::Other(Box::new(value)) + Self::SystemError(SystemError::from(value)) } } @@ -198,14 +163,3 @@ impl std::fmt::Display for EmptyOptionalError { } impl std::error::Error for EmptyOptionalError {} - -#[derive(Debug)] -pub struct UnsupportedEnumValueError; - -impl std::fmt::Display for UnsupportedEnumValueError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Enum value is unsupported") - } -} - -impl std::error::Error for UnsupportedEnumValueError {} diff --git a/cxx/src/game.rs b/cxx/src/game.rs index 6236edaa..95c507c3 100644 --- a/cxx/src/game.rs +++ b/cxx/src/game.rs @@ -1,11 +1,9 @@ use std::path::Path; use delegate::delegate; +use libloot_ffi_errors::UnsupportedEnumValueError; -use crate::{ - Plugin, UnsupportedEnumValueError, VerboseError, database::Database, ffi::GameType, - plugin::OptionalPlugin, -}; +use crate::{Plugin, VerboseError, database::Database, ffi::GameType, plugin::OptionalPlugin}; impl TryFrom for GameType { type Error = UnsupportedEnumValueError; diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index 66c965e5..eb87bb3d 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -5,8 +5,9 @@ mod metadata; mod plugin; use database::{Database, Vertex, new_vertex}; -use error::{EmptyOptionalError, UnsupportedEnumValueError, VerboseError}; +use error::{EmptyOptionalError, VerboseError}; use game::{Game, new_game, new_game_with_local_path}; +use libloot_ffi_errors::UnsupportedEnumValueError; use metadata::{ File, Filename, Group, Location, Message, MessageContent, OptionalMessageContentRef, OptionalPluginMetadata, PluginCleaningData, PluginMetadata, Tag, group_default_name, diff --git a/ffi-errors/src/lib.rs b/ffi-errors/src/lib.rs index 34b09daa..d2c2d750 100644 --- a/ffi-errors/src/lib.rs +++ b/ffi-errors/src/lib.rs @@ -29,7 +29,19 @@ pub enum SystemErrorCategory { LootConditionInterpreter = LIBLOOT_SYSTEM_ERROR_CATEGORY_LCI, } -#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +impl std::fmt::Display for SystemErrorCategory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SystemErrorCategory::Esplugin => write!(f, "esplugin"), + SystemErrorCategory::Libloadorder => write!(f, "libloadorder"), + SystemErrorCategory::LootConditionInterpreter => { + write!(f, "loot-condition-interpreter") + } + } + } +} + +#[derive(Debug)] pub struct SystemError { code: c_int, category: SystemErrorCategory, @@ -50,6 +62,18 @@ impl SystemError { } } +impl std::fmt::Display for SystemError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} error, code {}: {}", + self.category, self.code, self.message + ) + } +} + +impl std::error::Error for SystemError {} + impl From for SystemError { fn from(value: PluginDataError) -> Self { let error = value @@ -97,3 +121,37 @@ impl From for SystemError { } } } + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct UnsupportedEnumValueError; + +impl std::fmt::Display for UnsupportedEnumValueError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Enum value is unsupported") + } +} + +impl std::error::Error for UnsupportedEnumValueError {} + +pub fn fmt_error_chain( + mut error: &dyn std::error::Error, + f: &mut std::fmt::Formatter<'_>, +) -> std::fmt::Result { + write!(f, "{}", error)?; + while let Some(source) = error.source() { + write!(f, ": {}", source)?; + error = source; + } + Ok(()) +} + +#[macro_export] +macro_rules! variant_box_from_error { + ( $from_type:ident, $to_type:ident::$to_variant:ident ) => { + impl From<$from_type> for $to_type { + fn from(value: $from_type) -> Self { + Self::$to_variant(Box::new(value)) + } + } + }; +} diff --git a/pyo3/src/database.rs b/pyo3/src/database.rs index e94bc6b9..99dc5ee4 100644 --- a/pyo3/src/database.rs +++ b/pyo3/src/database.rs @@ -5,13 +5,14 @@ use std::{ }; use libloot::{WriteMode, error::DatabaseLockPoisonError}; +use libloot_ffi_errors::UnsupportedEnumValueError; use pyo3::{ Bound, PyResult, pyclass, pymethods, types::{PyAnyMethods, PyTypeMethods}, }; use crate::{ - error::{UnsupportedEnumValueError, VerboseError}, + error::VerboseError, metadata::{Group, Message, NONE_REPR, PluginMetadata}, }; @@ -237,15 +238,15 @@ impl Vertex { } #[getter] - fn out_edge_type(&self) -> Result, UnsupportedEnumValueError> { - self.0.out_edge_type().map(|e| e.try_into()).transpose() + fn out_edge_type(&self) -> Result, VerboseError> { + self.0 + .out_edge_type() + .map(|e| e.try_into().map_err(Into::into)) + .transpose() } #[setter] - fn set_out_edge_type( - &mut self, - out_edge_type: EdgeType, - ) -> Result<(), UnsupportedEnumValueError> { + fn set_out_edge_type(&mut self, out_edge_type: EdgeType) -> Result<(), VerboseError> { let out_edge_type = out_edge_type.try_into()?; self.0.set_out_edge_type(out_edge_type); Ok(()) diff --git a/pyo3/src/error.rs b/pyo3/src/error.rs index 119a4afa..ff48b6b9 100644 --- a/pyo3/src/error.rs +++ b/pyo3/src/error.rs @@ -8,60 +8,42 @@ use libloot::{ LoadMetadataError, MultilingualMessageContentsError, RegexError, WriteMetadataError, }, }; -use libloot_ffi_errors::SystemError; +use libloot_ffi_errors::{ + SystemError, UnsupportedEnumValueError, fmt_error_chain, variant_box_from_error, +}; use pyo3::{PyErr, exceptions::PyValueError}; use crate::{CyclicInteractionError, EspluginError, UndefinedGroupError, database::Vertex}; -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] -pub struct UnsupportedEnumValueError; - -impl std::fmt::Display for UnsupportedEnumValueError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Enum value is unsupported") - } -} - -impl std::error::Error for UnsupportedEnumValueError {} - -impl From for PyErr { - fn from(value: UnsupportedEnumValueError) -> Self { - PyValueError::new_err(value.to_string()) - } -} - #[derive(Debug)] pub enum VerboseError { CyclicInteractionError(Vec), UndefinedGroupError(String), - EspluginError(i32, String), + EspluginError(SystemError), Other(Box), } impl std::fmt::Display for VerboseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::CyclicInteractionError(c) => { - let cycle = display_cycle(c); - write!(f, "cyclic interaction detected: {}", cycle) - } - Self::UndefinedGroupError(g) => { - write!(f, "the group \"{}\" does not exist", g) - } - Self::EspluginError(_, s) => s.fmt(f), - Self::Other(e) => { - write!(f, "{}", e)?; - let mut error = e.as_ref(); - while let Some(source) = error.source() { - write!(f, ": {}", source)?; - error = source; - } - Ok(()) - } + Self::CyclicInteractionError(c) => SortPluginsError::CycleFound(c.clone()).fmt(f), + Self::UndefinedGroupError(g) => SortPluginsError::UndefinedGroup(g.clone()).fmt(f), + Self::EspluginError(e) => e.message().fmt(f), + Self::Other(e) => fmt_error_chain(e.as_ref(), f), } } } +variant_box_from_error!(UnsupportedEnumValueError, VerboseError::Other); +variant_box_from_error!(DatabaseLockPoisonError, VerboseError::Other); +variant_box_from_error!(LoadPluginsError, VerboseError::Other); +variant_box_from_error!(LoadOrderError, VerboseError::Other); +variant_box_from_error!(LoadMetadataError, VerboseError::Other); +variant_box_from_error!(WriteMetadataError, VerboseError::Other); +variant_box_from_error!(ConditionEvaluationError, VerboseError::Other); +variant_box_from_error!(MultilingualMessageContentsError, VerboseError::Other); +variant_box_from_error!(RegexError, VerboseError::Other); + impl From for VerboseError { fn from(value: GameHandleCreationError) -> Self { match value { @@ -71,27 +53,6 @@ impl From for VerboseError { } } -impl From for VerboseError { - fn from(value: UnsupportedEnumValueError) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for VerboseError { - fn from(value: DatabaseLockPoisonError) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for VerboseError { - fn from(value: LoadPluginsError) -> Self { - match value { - LoadPluginsError::PluginDataError(e) => e.into(), - _ => Self::Other(Box::new(value)), - } - } -} - impl From for VerboseError { fn from(value: SortPluginsError) -> Self { match value { @@ -113,30 +74,6 @@ impl From for VerboseError { } } -impl From for VerboseError { - fn from(value: LoadOrderError) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for VerboseError { - fn from(value: LoadMetadataError) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for VerboseError { - fn from(value: WriteMetadataError) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for VerboseError { - fn from(value: ConditionEvaluationError) -> Self { - Self::Other(Box::new(value)) - } -} - impl From for VerboseError { fn from(value: GroupsPathError) -> Self { match value { @@ -158,20 +95,7 @@ impl From for VerboseError { impl From for VerboseError { fn from(value: PluginDataError) -> Self { - let error = SystemError::from(value); - Self::EspluginError(error.code(), error.message().to_string()) - } -} - -impl From for VerboseError { - fn from(value: MultilingualMessageContentsError) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for VerboseError { - fn from(value: RegexError) -> Self { - Self::Other(Box::new(value)) + Self::EspluginError(SystemError::from(value)) } } @@ -187,22 +111,10 @@ impl From for PyErr { VerboseError::UndefinedGroupError(g) => { PyErr::new::((g, message)) } - VerboseError::EspluginError(i, s) => PyErr::new::((i, s)), + VerboseError::EspluginError(e) => { + PyErr::new::((e.code(), e.message().to_owned())) + } VerboseError::Other(_) => PyValueError::new_err(message), } } } - -fn display_cycle(cycle: &[libloot::Vertex]) -> String { - cycle - .iter() - .map(|v| { - if let Some(edge_type) = v.out_edge_type() { - format!("{} --[{}]-> ", v.name(), edge_type) - } else { - v.name().to_string() - } - }) - .chain(cycle.first().iter().map(|v| v.name().to_string())) - .collect() -} diff --git a/pyo3/src/game.rs b/pyo3/src/game.rs index 0bca69c2..484ca47d 100644 --- a/pyo3/src/game.rs +++ b/pyo3/src/game.rs @@ -1,12 +1,9 @@ use std::path::{Path, PathBuf}; +use libloot_ffi_errors::UnsupportedEnumValueError; use pyo3::{pyclass, pymethods}; -use crate::{ - database::Database, - error::{UnsupportedEnumValueError, VerboseError}, - plugin::Plugin, -}; +use crate::{database::Database, error::VerboseError, plugin::Plugin}; #[allow(non_camel_case_types)] #[pyclass(eq, frozen, hash, ord)] diff --git a/pyo3/src/metadata.rs b/pyo3/src/metadata.rs index 826d0227..5a55b9c2 100644 --- a/pyo3/src/metadata.rs +++ b/pyo3/src/metadata.rs @@ -1,11 +1,12 @@ use std::hash::{DefaultHasher, Hash, Hasher}; +use libloot_ffi_errors::UnsupportedEnumValueError; use pyo3::{ Bound, FromPyObject, PyResult, pyclass, pyfunction, pymethods, types::{PyAnyMethods, PyTypeMethods}, }; -use crate::error::{UnsupportedEnumValueError, VerboseError}; +use crate::error::VerboseError; pub const NONE_REPR: &str = "None"; From b19b4dfb6d9f783ba8b4b87afdc320b78e8c58b4 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 21 Apr 2025 17:35:31 +0100 Subject: [PATCH 086/206] Add generated files for Node.js wrapper module Generated using @napi-rs/cli's "napi new". --- Cargo.lock | 103 ++ Cargo.toml | 2 +- nodejs/.cargo/config.toml | 2 + nodejs/.github/workflows/CI.yml | 203 ++++ nodejs/.gitignore | 197 ++++ nodejs/.npmignore | 13 + nodejs/.yarnrc.yml | 1 + nodejs/Cargo.toml | 19 + nodejs/__test__/index.spec.mjs | 7 + nodejs/build.rs | 5 + nodejs/index.d.ts | 6 + nodejs/index.js | 315 ++++++ nodejs/npm/linux-x64-gnu/README.md | 3 + nodejs/npm/linux-x64-gnu/package.json | 21 + nodejs/npm/win32-x64-msvc/README.md | 3 + nodejs/npm/win32-x64-msvc/package.json | 18 + nodejs/package.json | 37 + nodejs/rustfmt.toml | 2 + nodejs/src/lib.rs | 9 + nodejs/yarn.lock | 1373 ++++++++++++++++++++++++ 20 files changed, 2338 insertions(+), 1 deletion(-) create mode 100644 nodejs/.cargo/config.toml create mode 100644 nodejs/.github/workflows/CI.yml create mode 100644 nodejs/.gitignore create mode 100644 nodejs/.npmignore create mode 100644 nodejs/.yarnrc.yml create mode 100644 nodejs/Cargo.toml create mode 100644 nodejs/__test__/index.spec.mjs create mode 100644 nodejs/build.rs create mode 100644 nodejs/index.d.ts create mode 100644 nodejs/index.js create mode 100644 nodejs/npm/linux-x64-gnu/README.md create mode 100644 nodejs/npm/linux-x64-gnu/package.json create mode 100644 nodejs/npm/win32-x64-msvc/README.md create mode 100644 nodejs/npm/win32-x64-msvc/package.json create mode 100644 nodejs/package.json create mode 100644 nodejs/rustfmt.toml create mode 100644 nodejs/src/lib.rs create mode 100644 nodejs/yarn.lock diff --git a/Cargo.lock b/Cargo.lock index dca8714f..758ad4bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,15 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -202,6 +211,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "cxx" version = "1.0.156" @@ -546,6 +565,16 @@ version = "0.2.171" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +[[package]] +name = "libloading" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +dependencies = [ + "cfg-if", + "windows-targets", +] + [[package]] name = "libloadorder" version = "18.3.0" @@ -607,6 +636,17 @@ dependencies = [ "loot-condition-interpreter", ] +[[package]] +name = "libloot-nodejs" +version = "0.0.0" +dependencies = [ + "libloot", + "libloot-ffi-errors", + "napi", + "napi-build", + "napi-derive", +] + [[package]] name = "libloot_pyo3" version = "0.26.0" @@ -677,6 +717,63 @@ dependencies = [ "autocfg", ] +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", +] + +[[package]] +name = "napi-build" +version = "2.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28acfa557c083f6e254a786e01ba253fc56f18ee000afcd4f79af735f73a6da" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if", + "convert_case", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + [[package]] name = "no-std-compat" version = "0.4.1" @@ -1347,6 +1444,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 9f0142dd..369d7899 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ rstest_reuse = "0.7.0" tempfile = "3.17.1" [workspace] -members = ["cxx", "ffi-errors", "pyo3"] +members = ["cxx", "ffi-errors", "nodejs", "pyo3"] [workspace.dependencies] esplugin = "6.1.2" diff --git a/nodejs/.cargo/config.toml b/nodejs/.cargo/config.toml new file mode 100644 index 00000000..0c17df09 --- /dev/null +++ b/nodejs/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] \ No newline at end of file diff --git a/nodejs/.github/workflows/CI.yml b/nodejs/.github/workflows/CI.yml new file mode 100644 index 00000000..c4112c8b --- /dev/null +++ b/nodejs/.github/workflows/CI.yml @@ -0,0 +1,203 @@ +name: CI +env: + DEBUG: napi:* + APP_NAME: libloot-nodejs + MACOSX_DEPLOYMENT_TARGET: '10.13' +permissions: + contents: write + id-token: write +'on': + push: + branches: + - main + tags-ignore: + - '**' + paths-ignore: + - '**/*.md' + - LICENSE + - '**/*.gitignore' + - .editorconfig + - docs/** + pull_request: null +jobs: + build: + strategy: + fail-fast: false + matrix: + settings: + - host: windows-latest + build: yarn build --target x86_64-pc-windows-msvc + target: x86_64-pc-windows-msvc + - host: ubuntu-latest + target: x86_64-unknown-linux-gnu + docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian + build: yarn build --target x86_64-unknown-linux-gnu + name: stable - ${{ matrix.settings.target }} - node@20 + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + if: ${{ !matrix.settings.docker }} + with: + node-version: 20 + cache: yarn + - name: Install + uses: dtolnay/rust-toolchain@stable + if: ${{ !matrix.settings.docker }} + with: + toolchain: stable + targets: ${{ matrix.settings.target }} + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + .cargo-cache + target/ + key: ${{ matrix.settings.target }}-cargo-${{ matrix.settings.host }} + - uses: goto-bus-stop/setup-zig@v2 + if: ${{ matrix.settings.target == 'armv7-unknown-linux-gnueabihf' || matrix.settings.target == 'armv7-unknown-linux-musleabihf' }} + with: + version: 0.13.0 + - name: Setup toolchain + run: ${{ matrix.settings.setup }} + if: ${{ matrix.settings.setup }} + shell: bash + - name: Setup node x86 + if: matrix.settings.target == 'i686-pc-windows-msvc' + run: yarn config set supportedArchitectures.cpu "ia32" + shell: bash + - name: Install dependencies + run: yarn install + - name: Setup node x86 + uses: actions/setup-node@v4 + if: matrix.settings.target == 'i686-pc-windows-msvc' + with: + node-version: 20 + cache: yarn + architecture: x86 + - name: Build in docker + uses: addnab/docker-run-action@v3 + if: ${{ matrix.settings.docker }} + with: + image: ${{ matrix.settings.docker }} + options: '--user 0:0 -v ${{ github.workspace }}/.cargo-cache/git/db:/usr/local/cargo/git/db -v ${{ github.workspace }}/.cargo/registry/cache:/usr/local/cargo/registry/cache -v ${{ github.workspace }}/.cargo/registry/index:/usr/local/cargo/registry/index -v ${{ github.workspace }}:/build -w /build' + run: ${{ matrix.settings.build }} + - name: Build + run: ${{ matrix.settings.build }} + if: ${{ !matrix.settings.docker }} + shell: bash + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: bindings-${{ matrix.settings.target }} + path: ${{ env.APP_NAME }}.*.node + if-no-files-found: error + test-macOS-windows-binding: + name: Test bindings on ${{ matrix.settings.target }} - node@${{ matrix.node }} + needs: + - build + strategy: + fail-fast: false + matrix: + settings: + - host: windows-latest + target: x86_64-pc-windows-msvc + node: + - '18' + - '20' + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: yarn + architecture: x64 + - name: Install dependencies + run: yarn install + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: bindings-${{ matrix.settings.target }} + path: . + - name: List packages + run: ls -R . + shell: bash + - name: Test bindings + run: yarn test + test-linux-x64-gnu-binding: + name: Test bindings on Linux-x64-gnu - node@${{ matrix.node }} + needs: + - build + strategy: + fail-fast: false + matrix: + node: + - '18' + - '20' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: yarn + - name: Install dependencies + run: yarn install + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: bindings-x86_64-unknown-linux-gnu + path: . + - name: List packages + run: ls -R . + shell: bash + - name: Test bindings + run: docker run --rm -v $(pwd):/build -w /build node:${{ matrix.node }}-slim yarn test + publish: + name: Publish + runs-on: ubuntu-latest + needs: + - test-macOS-windows-binding + - test-linux-x64-gnu-binding + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + - name: Install dependencies + run: yarn install + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Move artifacts + run: yarn artifacts + - name: List packages + run: ls -R ./npm + shell: bash + - name: Publish + run: | + npm config set provenance true + if git log -1 --pretty=%B | grep "^[0-9]\+\.[0-9]\+\.[0-9]\+$"; + then + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + npm publish --access public + elif git log -1 --pretty=%B | grep "^[0-9]\+\.[0-9]\+\.[0-9]\+"; + then + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + npm publish --tag next --access public + else + echo "Not a release, skipping publish" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/nodejs/.gitignore b/nodejs/.gitignore new file mode 100644 index 00000000..5e2577b3 --- /dev/null +++ b/nodejs/.gitignore @@ -0,0 +1,197 @@ +# Created by https://www.toptal.com/developers/gitignore/api/node +# Edit at https://www.toptal.com/developers/gitignore?templates=node + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# End of https://www.toptal.com/developers/gitignore/api/node + +# Created by https://www.toptal.com/developers/gitignore/api/macos +# Edit at https://www.toptal.com/developers/gitignore?templates=macos + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +# End of https://www.toptal.com/developers/gitignore/api/macos + +# Created by https://www.toptal.com/developers/gitignore/api/windows +# Edit at https://www.toptal.com/developers/gitignore?templates=windows + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/windows + +#Added by cargo + +/target +Cargo.lock + +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +*.node diff --git a/nodejs/.npmignore b/nodejs/.npmignore new file mode 100644 index 00000000..ec144db2 --- /dev/null +++ b/nodejs/.npmignore @@ -0,0 +1,13 @@ +target +Cargo.lock +.cargo +.github +npm +.eslintrc +.prettierignore +rustfmt.toml +yarn.lock +*.node +.yarn +__test__ +renovate.json diff --git a/nodejs/.yarnrc.yml b/nodejs/.yarnrc.yml new file mode 100644 index 00000000..3186f3f0 --- /dev/null +++ b/nodejs/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml new file mode 100644 index 00000000..0c132558 --- /dev/null +++ b/nodejs/Cargo.toml @@ -0,0 +1,19 @@ +[package] +edition = "2021" +name = "libloot-nodejs" +version = "0.0.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix +napi = { version = "2.12.2", default-features = false, features = ["napi4"] } +napi-derive = "2.12.2" + +[build-dependencies] +napi-build = "2.0.1" + +[profile.release] +lto = true +strip = "symbols" diff --git a/nodejs/__test__/index.spec.mjs b/nodejs/__test__/index.spec.mjs new file mode 100644 index 00000000..1ade4caf --- /dev/null +++ b/nodejs/__test__/index.spec.mjs @@ -0,0 +1,7 @@ +import test from 'ava' + +import { sum } from '../index.js' + +test('sum from native', (t) => { + t.is(sum(1, 2), 3) +}) diff --git a/nodejs/build.rs b/nodejs/build.rs new file mode 100644 index 00000000..1f866b6a --- /dev/null +++ b/nodejs/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts new file mode 100644 index 00000000..a43fa370 --- /dev/null +++ b/nodejs/index.d.ts @@ -0,0 +1,6 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +export declare function sum(a: number, b: number): number diff --git a/nodejs/index.js b/nodejs/index.js new file mode 100644 index 00000000..47dbe20c --- /dev/null +++ b/nodejs/index.js @@ -0,0 +1,315 @@ +/* tslint:disable */ +/* eslint-disable */ +/* prettier-ignore */ + +/* auto-generated by NAPI-RS */ + +const { existsSync, readFileSync } = require('fs') +const { join } = require('path') + +const { platform, arch } = process + +let nativeBinding = null +let localFileExisted = false +let loadError = null + +function isMusl() { + // For Node 10 + if (!process.report || typeof process.report.getReport !== 'function') { + try { + const lddPath = require('child_process').execSync('which ldd').toString().trim() + return readFileSync(lddPath, 'utf8').includes('musl') + } catch (e) { + return true + } + } else { + const { glibcVersionRuntime } = process.report.getReport().header + return !glibcVersionRuntime + } +} + +switch (platform) { + case 'android': + switch (arch) { + case 'arm64': + localFileExisted = existsSync(join(__dirname, 'libloot-nodejs.android-arm64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.android-arm64.node') + } else { + nativeBinding = require('libloot-nodejs-android-arm64') + } + } catch (e) { + loadError = e + } + break + case 'arm': + localFileExisted = existsSync(join(__dirname, 'libloot-nodejs.android-arm-eabi.node')) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.android-arm-eabi.node') + } else { + nativeBinding = require('libloot-nodejs-android-arm-eabi') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Android ${arch}`) + } + break + case 'win32': + switch (arch) { + case 'x64': + localFileExisted = existsSync( + join(__dirname, 'libloot-nodejs.win32-x64-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.win32-x64-msvc.node') + } else { + nativeBinding = require('libloot-nodejs-win32-x64-msvc') + } + } catch (e) { + loadError = e + } + break + case 'ia32': + localFileExisted = existsSync( + join(__dirname, 'libloot-nodejs.win32-ia32-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.win32-ia32-msvc.node') + } else { + nativeBinding = require('libloot-nodejs-win32-ia32-msvc') + } + } catch (e) { + loadError = e + } + break + case 'arm64': + localFileExisted = existsSync( + join(__dirname, 'libloot-nodejs.win32-arm64-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.win32-arm64-msvc.node') + } else { + nativeBinding = require('libloot-nodejs-win32-arm64-msvc') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Windows: ${arch}`) + } + break + case 'darwin': + localFileExisted = existsSync(join(__dirname, 'libloot-nodejs.darwin-universal.node')) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.darwin-universal.node') + } else { + nativeBinding = require('libloot-nodejs-darwin-universal') + } + break + } catch {} + switch (arch) { + case 'x64': + localFileExisted = existsSync(join(__dirname, 'libloot-nodejs.darwin-x64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.darwin-x64.node') + } else { + nativeBinding = require('libloot-nodejs-darwin-x64') + } + } catch (e) { + loadError = e + } + break + case 'arm64': + localFileExisted = existsSync( + join(__dirname, 'libloot-nodejs.darwin-arm64.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.darwin-arm64.node') + } else { + nativeBinding = require('libloot-nodejs-darwin-arm64') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on macOS: ${arch}`) + } + break + case 'freebsd': + if (arch !== 'x64') { + throw new Error(`Unsupported architecture on FreeBSD: ${arch}`) + } + localFileExisted = existsSync(join(__dirname, 'libloot-nodejs.freebsd-x64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.freebsd-x64.node') + } else { + nativeBinding = require('libloot-nodejs-freebsd-x64') + } + } catch (e) { + loadError = e + } + break + case 'linux': + switch (arch) { + case 'x64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'libloot-nodejs.linux-x64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.linux-x64-musl.node') + } else { + nativeBinding = require('libloot-nodejs-linux-x64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'libloot-nodejs.linux-x64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.linux-x64-gnu.node') + } else { + nativeBinding = require('libloot-nodejs-linux-x64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 'arm64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'libloot-nodejs.linux-arm64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.linux-arm64-musl.node') + } else { + nativeBinding = require('libloot-nodejs-linux-arm64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'libloot-nodejs.linux-arm64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.linux-arm64-gnu.node') + } else { + nativeBinding = require('libloot-nodejs-linux-arm64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 'arm': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'libloot-nodejs.linux-arm-musleabihf.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.linux-arm-musleabihf.node') + } else { + nativeBinding = require('libloot-nodejs-linux-arm-musleabihf') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'libloot-nodejs.linux-arm-gnueabihf.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.linux-arm-gnueabihf.node') + } else { + nativeBinding = require('libloot-nodejs-linux-arm-gnueabihf') + } + } catch (e) { + loadError = e + } + } + break + case 'riscv64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'libloot-nodejs.linux-riscv64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.linux-riscv64-musl.node') + } else { + nativeBinding = require('libloot-nodejs-linux-riscv64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'libloot-nodejs.linux-riscv64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.linux-riscv64-gnu.node') + } else { + nativeBinding = require('libloot-nodejs-linux-riscv64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 's390x': + localFileExisted = existsSync( + join(__dirname, 'libloot-nodejs.linux-s390x-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./libloot-nodejs.linux-s390x-gnu.node') + } else { + nativeBinding = require('libloot-nodejs-linux-s390x-gnu') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Linux: ${arch}`) + } + break + default: + throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`) +} + +if (!nativeBinding) { + if (loadError) { + throw loadError + } + throw new Error(`Failed to load native binding`) +} + +const { sum } = nativeBinding + +module.exports.sum = sum diff --git a/nodejs/npm/linux-x64-gnu/README.md b/nodejs/npm/linux-x64-gnu/README.md new file mode 100644 index 00000000..8da13516 --- /dev/null +++ b/nodejs/npm/linux-x64-gnu/README.md @@ -0,0 +1,3 @@ +# `libloot-nodejs-linux-x64-gnu` + +This is the **x86_64-unknown-linux-gnu** binary for `libloot-nodejs` diff --git a/nodejs/npm/linux-x64-gnu/package.json b/nodejs/npm/linux-x64-gnu/package.json new file mode 100644 index 00000000..9f590740 --- /dev/null +++ b/nodejs/npm/linux-x64-gnu/package.json @@ -0,0 +1,21 @@ +{ + "name": "libloot-nodejs-linux-x64-gnu", + "version": "0.0.0", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "main": "libloot-nodejs.linux-x64-gnu.node", + "files": [ + "libloot-nodejs.linux-x64-gnu.node" + ], + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "libc": [ + "glibc" + ] +} \ No newline at end of file diff --git a/nodejs/npm/win32-x64-msvc/README.md b/nodejs/npm/win32-x64-msvc/README.md new file mode 100644 index 00000000..8a99f69d --- /dev/null +++ b/nodejs/npm/win32-x64-msvc/README.md @@ -0,0 +1,3 @@ +# `libloot-nodejs-win32-x64-msvc` + +This is the **x86_64-pc-windows-msvc** binary for `libloot-nodejs` diff --git a/nodejs/npm/win32-x64-msvc/package.json b/nodejs/npm/win32-x64-msvc/package.json new file mode 100644 index 00000000..73b1720c --- /dev/null +++ b/nodejs/npm/win32-x64-msvc/package.json @@ -0,0 +1,18 @@ +{ + "name": "libloot-nodejs-win32-x64-msvc", + "version": "0.0.0", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "main": "libloot-nodejs.win32-x64-msvc.node", + "files": [ + "libloot-nodejs.win32-x64-msvc.node" + ], + "license": "MIT", + "engines": { + "node": ">= 10" + } +} \ No newline at end of file diff --git a/nodejs/package.json b/nodejs/package.json new file mode 100644 index 00000000..af87a237 --- /dev/null +++ b/nodejs/package.json @@ -0,0 +1,37 @@ +{ + "name": "libloot-nodejs", + "version": "0.0.0", + "main": "index.js", + "types": "index.d.ts", + "napi": { + "name": "libloot-nodejs", + "triples": { + "defaults": false, + "additional": [ + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu" + ] + } + }, + "license": "MIT", + "devDependencies": { + "@napi-rs/cli": "^2.18.4", + "ava": "^6.0.1" + }, + "ava": { + "timeout": "3m" + }, + "engines": { + "node": ">= 10" + }, + "scripts": { + "artifacts": "napi artifacts", + "build": "napi build --platform --release", + "build:debug": "napi build --platform", + "prepublishOnly": "napi prepublish -t npm", + "test": "ava", + "universal": "napi universal", + "version": "napi version" + }, + "packageManager": "yarn@4.9.1" +} diff --git a/nodejs/rustfmt.toml b/nodejs/rustfmt.toml new file mode 100644 index 00000000..cab5731e --- /dev/null +++ b/nodejs/rustfmt.toml @@ -0,0 +1,2 @@ +tab_spaces = 2 +edition = "2021" diff --git a/nodejs/src/lib.rs b/nodejs/src/lib.rs new file mode 100644 index 00000000..b479ed71 --- /dev/null +++ b/nodejs/src/lib.rs @@ -0,0 +1,9 @@ +#![deny(clippy::all)] + +#[macro_use] +extern crate napi_derive; + +#[napi] +pub fn sum(a: i32, b: i32) -> i32 { + a + b +} diff --git a/nodejs/yarn.lock b/nodejs/yarn.lock new file mode 100644 index 00000000..edd0375e --- /dev/null +++ b/nodejs/yarn.lock @@ -0,0 +1,1373 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" + dependencies: + minipass: "npm:^7.0.4" + checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 + languageName: node + linkType: hard + +"@mapbox/node-pre-gyp@npm:^2.0.0-rc.0": + version: 2.0.0 + resolution: "@mapbox/node-pre-gyp@npm:2.0.0" + dependencies: + consola: "npm:^3.2.3" + detect-libc: "npm:^2.0.0" + https-proxy-agent: "npm:^7.0.5" + node-fetch: "npm:^2.6.7" + nopt: "npm:^8.0.0" + semver: "npm:^7.5.3" + tar: "npm:^7.4.0" + bin: + node-pre-gyp: bin/node-pre-gyp + checksum: 10c0/7d874c7f6f5560a87be7207f28d9a4e53b750085a82167608fd573aab8073645e95b3608f69e244df0e1d24e90a66525aeae708aba82ca73ff668ed0ab6abda6 + languageName: node + linkType: hard + +"@napi-rs/cli@npm:^2.18.4": + version: 2.18.4 + resolution: "@napi-rs/cli@npm:2.18.4" + bin: + napi: scripts/index.js + checksum: 10c0/cb80bc86687ba01077a0faa8d38198bc632b7caacf3a42ec42c3ec04700084ca26be9eec13bf7ac7da90e6afb46c65a39de8a86825a53b74dec2f2f459c0a7ea + languageName: node + linkType: hard + +"@nodelib/fs.scandir@npm:2.1.5": + version: 2.1.5 + resolution: "@nodelib/fs.scandir@npm:2.1.5" + dependencies: + "@nodelib/fs.stat": "npm:2.0.5" + run-parallel: "npm:^1.1.9" + checksum: 10c0/732c3b6d1b1e967440e65f284bd06e5821fedf10a1bea9ed2bb75956ea1f30e08c44d3def9d6a230666574edbaf136f8cfd319c14fd1f87c66e6a44449afb2eb + languageName: node + linkType: hard + +"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2": + version: 2.0.5 + resolution: "@nodelib/fs.stat@npm:2.0.5" + checksum: 10c0/88dafe5e3e29a388b07264680dc996c17f4bda48d163a9d4f5c1112979f0ce8ec72aa7116122c350b4e7976bc5566dc3ddb579be1ceaacc727872eb4ed93926d + languageName: node + linkType: hard + +"@nodelib/fs.walk@npm:^1.2.3": + version: 1.2.8 + resolution: "@nodelib/fs.walk@npm:1.2.8" + dependencies: + "@nodelib/fs.scandir": "npm:2.1.5" + fastq: "npm:^1.6.0" + checksum: 10c0/db9de047c3bb9b51f9335a7bb46f4fcfb6829fb628318c12115fbaf7d369bfce71c15b103d1fc3b464812d936220ee9bc1c8f762d032c9f6be9acc99249095b1 + languageName: node + linkType: hard + +"@rollup/pluginutils@npm:^5.1.3": + version: 5.1.4 + resolution: "@rollup/pluginutils@npm:5.1.4" + dependencies: + "@types/estree": "npm:^1.0.0" + estree-walker: "npm:^2.0.2" + picomatch: "npm:^4.0.2" + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10c0/6d58fbc6f1024eb4b087bc9bf59a1d655a8056a60c0b4021d3beaeec3f0743503f52467fd89d2cf0e7eccf2831feb40a05ad541a17637ea21ba10b21c2004deb + languageName: node + linkType: hard + +"@sindresorhus/merge-streams@npm:^2.1.0": + version: 2.3.0 + resolution: "@sindresorhus/merge-streams@npm:2.3.0" + checksum: 10c0/69ee906f3125fb2c6bb6ec5cdd84e8827d93b49b3892bce8b62267116cc7e197b5cccf20c160a1d32c26014ecd14470a72a5e3ee37a58f1d6dadc0db1ccf3894 + languageName: node + linkType: hard + +"@types/estree@npm:^1.0.0": + version: 1.0.7 + resolution: "@types/estree@npm:1.0.7" + checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c + languageName: node + linkType: hard + +"@vercel/nft@npm:^0.27.5": + version: 0.27.10 + resolution: "@vercel/nft@npm:0.27.10" + dependencies: + "@mapbox/node-pre-gyp": "npm:^2.0.0-rc.0" + "@rollup/pluginutils": "npm:^5.1.3" + acorn: "npm:^8.6.0" + acorn-import-attributes: "npm:^1.9.5" + async-sema: "npm:^3.1.1" + bindings: "npm:^1.4.0" + estree-walker: "npm:2.0.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + node-gyp-build: "npm:^4.2.2" + picomatch: "npm:^4.0.2" + resolve-from: "npm:^5.0.0" + bin: + nft: out/cli.js + checksum: 10c0/8fec187317d8e390e00f5cacc07839add40a67977dfce2edab0844b51821d012bb807d692c08b0ede03e767e735fb592e6c71d6dd2379ba4dc298a96df09e3d6 + languageName: node + linkType: hard + +"abbrev@npm:^3.0.0": + version: 3.0.1 + resolution: "abbrev@npm:3.0.1" + checksum: 10c0/21ba8f574ea57a3106d6d35623f2c4a9111d9ee3e9a5be47baed46ec2457d2eac46e07a5c4a60186f88cb98abbe3e24f2d4cca70bc2b12f1692523e2209a9ccf + languageName: node + linkType: hard + +"acorn-import-attributes@npm:^1.9.5": + version: 1.9.5 + resolution: "acorn-import-attributes@npm:1.9.5" + peerDependencies: + acorn: ^8 + checksum: 10c0/5926eaaead2326d5a86f322ff1b617b0f698aa61dc719a5baa0e9d955c9885cc71febac3fb5bacff71bbf2c4f9c12db2056883c68c53eb962c048b952e1e013d + languageName: node + linkType: hard + +"acorn-walk@npm:^8.3.4": + version: 8.3.4 + resolution: "acorn-walk@npm:8.3.4" + dependencies: + acorn: "npm:^8.11.0" + checksum: 10c0/76537ac5fb2c37a64560feaf3342023dadc086c46da57da363e64c6148dc21b57d49ace26f949e225063acb6fb441eabffd89f7a3066de5ad37ab3e328927c62 + languageName: node + linkType: hard + +"acorn@npm:^8.11.0, acorn@npm:^8.13.0, acorn@npm:^8.6.0": + version: 8.14.1 + resolution: "acorn@npm:8.14.1" + bin: + acorn: bin/acorn + checksum: 10c0/dbd36c1ed1d2fa3550140000371fcf721578095b18777b85a79df231ca093b08edc6858d75d6e48c73e431c174dcf9214edbd7e6fa5911b93bd8abfa54e47123 + languageName: node + linkType: hard + +"agent-base@npm:^7.1.2": + version: 7.1.3 + resolution: "agent-base@npm:7.1.3" + checksum: 10c0/6192b580c5b1d8fb399b9c62bf8343d76654c2dd62afcb9a52b2cf44a8b6ace1e3b704d3fe3547d91555c857d3df02603341ff2cb961b9cfe2b12f9f3c38ee11 + languageName: node + linkType: hard + +"ansi-regex@npm:^5.0.1": + version: 5.0.1 + resolution: "ansi-regex@npm:5.0.1" + checksum: 10c0/9a64bb8627b434ba9327b60c027742e5d17ac69277960d041898596271d992d4d52ba7267a63ca10232e29f6107fc8a835f6ce8d719b88c5f8493f8254813737 + languageName: node + linkType: hard + +"ansi-regex@npm:^6.0.1": + version: 6.1.0 + resolution: "ansi-regex@npm:6.1.0" + checksum: 10c0/a91daeddd54746338478eef88af3439a7edf30f8e23196e2d6ed182da9add559c601266dbef01c2efa46a958ad6f1f8b176799657616c702b5b02e799e7fd8dc + languageName: node + linkType: hard + +"ansi-styles@npm:^4.0.0": + version: 4.3.0 + resolution: "ansi-styles@npm:4.3.0" + dependencies: + color-convert: "npm:^2.0.1" + checksum: 10c0/895a23929da416f2bd3de7e9cb4eabd340949328ab85ddd6e484a637d8f6820d485f53933446f5291c3b760cbc488beb8e88573dd0f9c7daf83dccc8fe81b041 + languageName: node + linkType: hard + +"ansi-styles@npm:^6.0.0, ansi-styles@npm:^6.2.1": + version: 6.2.1 + resolution: "ansi-styles@npm:6.2.1" + checksum: 10c0/5d1ec38c123984bcedd996eac680d548f31828bd679a66db2bdf11844634dde55fec3efa9c6bb1d89056a5e79c1ac540c4c784d592ea1d25028a92227d2f2d5c + languageName: node + linkType: hard + +"argparse@npm:^1.0.7": + version: 1.0.10 + resolution: "argparse@npm:1.0.10" + dependencies: + sprintf-js: "npm:~1.0.2" + checksum: 10c0/b2972c5c23c63df66bca144dbc65d180efa74f25f8fd9b7d9a0a6c88ae839db32df3d54770dcb6460cf840d232b60695d1a6b1053f599d84e73f7437087712de + languageName: node + linkType: hard + +"array-find-index@npm:^1.0.1": + version: 1.0.2 + resolution: "array-find-index@npm:1.0.2" + checksum: 10c0/86b9485c74ddd324feab807e10a6de3f9c1683856267236fac4bb4d4667ada6463e106db3f6c540ae6b720e0442b590ec701d13676df4c6af30ebf4da09b4f57 + languageName: node + linkType: hard + +"arrgv@npm:^1.0.2": + version: 1.0.2 + resolution: "arrgv@npm:1.0.2" + checksum: 10c0/7e6e782e6b749923ac7cbc4048ef6fe0844c4a59bfc8932fcd4c44566ba25eed46501f94dd7cf3c7297da88f3f599ca056bfb77d0c2484aebc92f04239f69124 + languageName: node + linkType: hard + +"arrify@npm:^3.0.0": + version: 3.0.0 + resolution: "arrify@npm:3.0.0" + checksum: 10c0/2e26601b8486f29780f1f70f7ac05a226755814c2a3ab42e196748f650af1dc310cd575a11dd4b9841c70fd7460b2dd2b8fe6fb7a3375878e2660706efafa58e + languageName: node + linkType: hard + +"async-sema@npm:^3.1.1": + version: 3.1.1 + resolution: "async-sema@npm:3.1.1" + checksum: 10c0/a16da9f7f2dbdd00a969bf264b7ad331b59df3eac2b38f529b881c5cc8662594e68ed096d927ec2aabdc13454379cdc6d677bcdb0a3d2db338fb4be17957832b + languageName: node + linkType: hard + +"ava@npm:^6.0.1": + version: 6.2.0 + resolution: "ava@npm:6.2.0" + dependencies: + "@vercel/nft": "npm:^0.27.5" + acorn: "npm:^8.13.0" + acorn-walk: "npm:^8.3.4" + ansi-styles: "npm:^6.2.1" + arrgv: "npm:^1.0.2" + arrify: "npm:^3.0.0" + callsites: "npm:^4.2.0" + cbor: "npm:^9.0.2" + chalk: "npm:^5.3.0" + chunkd: "npm:^2.0.1" + ci-info: "npm:^4.0.0" + ci-parallel-vars: "npm:^1.0.1" + cli-truncate: "npm:^4.0.0" + code-excerpt: "npm:^4.0.0" + common-path-prefix: "npm:^3.0.0" + concordance: "npm:^5.0.4" + currently-unhandled: "npm:^0.4.1" + debug: "npm:^4.3.7" + emittery: "npm:^1.0.3" + figures: "npm:^6.1.0" + globby: "npm:^14.0.2" + ignore-by-default: "npm:^2.1.0" + indent-string: "npm:^5.0.0" + is-plain-object: "npm:^5.0.0" + is-promise: "npm:^4.0.0" + matcher: "npm:^5.0.0" + memoize: "npm:^10.0.0" + ms: "npm:^2.1.3" + p-map: "npm:^7.0.2" + package-config: "npm:^5.0.0" + picomatch: "npm:^4.0.2" + plur: "npm:^5.1.0" + pretty-ms: "npm:^9.1.0" + resolve-cwd: "npm:^3.0.0" + stack-utils: "npm:^2.0.6" + strip-ansi: "npm:^7.1.0" + supertap: "npm:^3.0.1" + temp-dir: "npm:^3.0.0" + write-file-atomic: "npm:^6.0.0" + yargs: "npm:^17.7.2" + peerDependencies: + "@ava/typescript": "*" + peerDependenciesMeta: + "@ava/typescript": + optional: true + bin: + ava: entrypoints/cli.mjs + checksum: 10c0/25a37413c9ee1b5322dc5a266f546236ea4b52e5c04ae4b52a7b26db9263eebe2dbcda687bf4d464867e558e9148e4567aa09a7ec91d46e3218ab93204e3c653 + languageName: node + linkType: hard + +"balanced-match@npm:^1.0.0": + version: 1.0.2 + resolution: "balanced-match@npm:1.0.2" + checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee + languageName: node + linkType: hard + +"bindings@npm:^1.4.0": + version: 1.5.0 + resolution: "bindings@npm:1.5.0" + dependencies: + file-uri-to-path: "npm:1.0.0" + checksum: 10c0/3dab2491b4bb24124252a91e656803eac24292473e56554e35bbfe3cc1875332cfa77600c3bac7564049dc95075bf6fcc63a4609920ff2d64d0fe405fcf0d4ba + languageName: node + linkType: hard + +"blueimp-md5@npm:^2.10.0": + version: 2.19.0 + resolution: "blueimp-md5@npm:2.19.0" + checksum: 10c0/85d04343537dd99a288c62450341dcce7380d3454c81f8e5a971ddd80307d6f9ef51b5b92ad7d48aaaa92fd6d3a1f6b2f4fada068faae646887f7bfabc17a346 + languageName: node + linkType: hard + +"brace-expansion@npm:^1.1.7": + version: 1.1.11 + resolution: "brace-expansion@npm:1.1.11" + dependencies: + balanced-match: "npm:^1.0.0" + concat-map: "npm:0.0.1" + checksum: 10c0/695a56cd058096a7cb71fb09d9d6a7070113c7be516699ed361317aca2ec169f618e28b8af352e02ab4233fb54eb0168460a40dc320bab0034b36ab59aaad668 + languageName: node + linkType: hard + +"braces@npm:^3.0.3": + version: 3.0.3 + resolution: "braces@npm:3.0.3" + dependencies: + fill-range: "npm:^7.1.1" + checksum: 10c0/7c6dfd30c338d2997ba77500539227b9d1f85e388a5f43220865201e407e076783d0881f2d297b9f80951b4c957fcf0b51c1d2d24227631643c3f7c284b0aa04 + languageName: node + linkType: hard + +"callsites@npm:^4.2.0": + version: 4.2.0 + resolution: "callsites@npm:4.2.0" + checksum: 10c0/8f7e269ec09fc0946bb22d838a8bc7932e1909ab4a833b964749f4d0e8bdeaa1f253287c4f911f61781f09620b6925ccd19a5ea4897489c4e59442c660c312a3 + languageName: node + linkType: hard + +"cbor@npm:^9.0.2": + version: 9.0.2 + resolution: "cbor@npm:9.0.2" + dependencies: + nofilter: "npm:^3.1.0" + checksum: 10c0/709d4378067e663107b3d63a02d123a7b33e28946b4c5cc40c102f2f0ba13b072a79adc4369bb87a4e743399fce45deec30463fc84d363ab7cb39192d0fe5f30 + languageName: node + linkType: hard + +"chalk@npm:^5.3.0": + version: 5.4.1 + resolution: "chalk@npm:5.4.1" + checksum: 10c0/b23e88132c702f4855ca6d25cb5538b1114343e41472d5263ee8a37cccfccd9c4216d111e1097c6a27830407a1dc81fecdf2a56f2c63033d4dbbd88c10b0dcef + languageName: node + linkType: hard + +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 + languageName: node + linkType: hard + +"chunkd@npm:^2.0.1": + version: 2.0.1 + resolution: "chunkd@npm:2.0.1" + checksum: 10c0/4e0c5aac6048ecedfa4cd0a5f6c4f010c70a7b7645aeca7bfeb47cb0733c3463054f0ced3f2667b2e0e67edd75d68a8e05481b01115ba3f8a952a93026254504 + languageName: node + linkType: hard + +"ci-info@npm:^4.0.0": + version: 4.2.0 + resolution: "ci-info@npm:4.2.0" + checksum: 10c0/37a2f4b6a213a5cf835890eb0241f0d5b022f6cfefde58a69e9af8e3a0e71e06d6ad7754b0d4efb9cd2613e58a7a33996d71b56b0d04242722e86666f3f3d058 + languageName: node + linkType: hard + +"ci-parallel-vars@npm:^1.0.1": + version: 1.0.1 + resolution: "ci-parallel-vars@npm:1.0.1" + checksum: 10c0/80952f699cbbc146092b077b4f3e28d085620eb4e6be37f069b4dbb3db0ee70e8eec3beef4ebe70ff60631e9fc743b9d0869678489f167442cac08b260e5ac08 + languageName: node + linkType: hard + +"cli-truncate@npm:^4.0.0": + version: 4.0.0 + resolution: "cli-truncate@npm:4.0.0" + dependencies: + slice-ansi: "npm:^5.0.0" + string-width: "npm:^7.0.0" + checksum: 10c0/d7f0b73e3d9b88cb496e6c086df7410b541b56a43d18ade6a573c9c18bd001b1c3fba1ad578f741a4218fdc794d042385f8ac02c25e1c295a2d8b9f3cb86eb4c + languageName: node + linkType: hard + +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^7.0.0" + checksum: 10c0/4bda0f09c340cbb6dfdc1ed508b3ca080f12992c18d68c6be4d9cf51756033d5266e61ec57529e610dacbf4da1c634423b0c1b11037709cc6b09045cbd815df5 + languageName: node + linkType: hard + +"code-excerpt@npm:^4.0.0": + version: 4.0.0 + resolution: "code-excerpt@npm:4.0.0" + dependencies: + convert-to-spaces: "npm:^2.0.1" + checksum: 10c0/b6c5a06e039cecd2ab6a0e10ee0831de8362107d1f298ca3558b5f9004cb8e0260b02dd6c07f57b9a0e346c76864d2873311ee1989809fdeb05bd5fbbadde773 + languageName: node + linkType: hard + +"color-convert@npm:^2.0.1": + version: 2.0.1 + resolution: "color-convert@npm:2.0.1" + dependencies: + color-name: "npm:~1.1.4" + checksum: 10c0/37e1150172f2e311fe1b2df62c6293a342ee7380da7b9cfdba67ea539909afbd74da27033208d01d6d5cfc65ee7868a22e18d7e7648e004425441c0f8a15a7d7 + languageName: node + linkType: hard + +"color-name@npm:~1.1.4": + version: 1.1.4 + resolution: "color-name@npm:1.1.4" + checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95 + languageName: node + linkType: hard + +"common-path-prefix@npm:^3.0.0": + version: 3.0.0 + resolution: "common-path-prefix@npm:3.0.0" + checksum: 10c0/c4a74294e1b1570f4a8ab435285d185a03976c323caa16359053e749db4fde44e3e6586c29cd051100335e11895767cbbd27ea389108e327d62f38daf4548fdb + languageName: node + linkType: hard + +"concat-map@npm:0.0.1": + version: 0.0.1 + resolution: "concat-map@npm:0.0.1" + checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f + languageName: node + linkType: hard + +"concordance@npm:^5.0.4": + version: 5.0.4 + resolution: "concordance@npm:5.0.4" + dependencies: + date-time: "npm:^3.1.0" + esutils: "npm:^2.0.3" + fast-diff: "npm:^1.2.0" + js-string-escape: "npm:^1.0.1" + lodash: "npm:^4.17.15" + md5-hex: "npm:^3.0.1" + semver: "npm:^7.3.2" + well-known-symbols: "npm:^2.0.0" + checksum: 10c0/59b440f330df3a7c9aa148ba588b3e99aed86acab225b4f01ffcea34ace4cf11f817e31153254e8f38ed48508998dad40b9106951a743c334d751f7ab21afb8a + languageName: node + linkType: hard + +"consola@npm:^3.2.3": + version: 3.4.2 + resolution: "consola@npm:3.4.2" + checksum: 10c0/7cebe57ecf646ba74b300bcce23bff43034ed6fbec9f7e39c27cee1dc00df8a21cd336b466ad32e304ea70fba04ec9e890c200270de9a526ce021ba8a7e4c11a + languageName: node + linkType: hard + +"convert-to-spaces@npm:^2.0.1": + version: 2.0.1 + resolution: "convert-to-spaces@npm:2.0.1" + checksum: 10c0/d90aa0e3b6a27f9d5265a8d32def3c5c855b3e823a9db1f26d772f8146d6b91020a2fdfd905ce8048a73fad3aaf836fef8188c67602c374405e2ae8396c4ac46 + languageName: node + linkType: hard + +"currently-unhandled@npm:^0.4.1": + version: 0.4.1 + resolution: "currently-unhandled@npm:0.4.1" + dependencies: + array-find-index: "npm:^1.0.1" + checksum: 10c0/32d197689ec32f035910202c1abb0dc6424dce01d7b51779c685119b380d98535c110ffff67a262fc7e367612a7dfd30d3d3055f9a6634b5a9dd1302de7ef11c + languageName: node + linkType: hard + +"date-time@npm:^3.1.0": + version: 3.1.0 + resolution: "date-time@npm:3.1.0" + dependencies: + time-zone: "npm:^1.0.0" + checksum: 10c0/aa3e2e930d74b0b9e90f69de7a16d3376e30f21f1f4ce9a2311d8fec32d760e776efea752dafad0ce188187265235229013036202be053fc2d7979813bfb6ded + languageName: node + linkType: hard + +"debug@npm:4, debug@npm:^4.3.7": + version: 4.4.0 + resolution: "debug@npm:4.4.0" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/db94f1a182bf886f57b4755f85b3a74c39b5114b9377b7ab375dc2cfa3454f09490cc6c30f829df3fc8042bc8b8995f6567ce5cd96f3bc3688bd24027197d9de + languageName: node + linkType: hard + +"detect-libc@npm:^2.0.0": + version: 2.0.3 + resolution: "detect-libc@npm:2.0.3" + checksum: 10c0/88095bda8f90220c95f162bf92cad70bd0e424913e655c20578600e35b91edc261af27531cf160a331e185c0ced93944bc7e09939143225f56312d7fd800fdb7 + languageName: node + linkType: hard + +"emittery@npm:^1.0.3": + version: 1.1.0 + resolution: "emittery@npm:1.1.0" + checksum: 10c0/645d4d7307b52c81bb2d2f9f320aa6a3c0225f53a4bfef2d337be8086df975746f7dd619f1dd7b2ffd4f2288103f28019e7b8567718677600e47507496d3af5f + languageName: node + linkType: hard + +"emoji-regex@npm:^10.3.0": + version: 10.4.0 + resolution: "emoji-regex@npm:10.4.0" + checksum: 10c0/a3fcedfc58bfcce21a05a5f36a529d81e88d602100145fcca3dc6f795e3c8acc4fc18fe773fbf9b6d6e9371205edb3afa2668ec3473fa2aa7fd47d2a9d46482d + languageName: node + linkType: hard + +"emoji-regex@npm:^8.0.0": + version: 8.0.0 + resolution: "emoji-regex@npm:8.0.0" + checksum: 10c0/b6053ad39951c4cf338f9092d7bfba448cdfd46fe6a2a034700b149ac9ffbc137e361cbd3c442297f86bed2e5f7576c1b54cc0a6bf8ef5106cc62f496af35010 + languageName: node + linkType: hard + +"escalade@npm:^3.1.1": + version: 3.2.0 + resolution: "escalade@npm:3.2.0" + checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^2.0.0": + version: 2.0.0 + resolution: "escape-string-regexp@npm:2.0.0" + checksum: 10c0/2530479fe8db57eace5e8646c9c2a9c80fa279614986d16dcc6bcaceb63ae77f05a851ba6c43756d816c61d7f4534baf56e3c705e3e0d884818a46808811c507 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^5.0.0": + version: 5.0.0 + resolution: "escape-string-regexp@npm:5.0.0" + checksum: 10c0/6366f474c6f37a802800a435232395e04e9885919873e382b157ab7e8f0feb8fed71497f84a6f6a81a49aab41815522f5839112bd38026d203aea0c91622df95 + languageName: node + linkType: hard + +"esprima@npm:^4.0.0": + version: 4.0.1 + resolution: "esprima@npm:4.0.1" + bin: + esparse: ./bin/esparse.js + esvalidate: ./bin/esvalidate.js + checksum: 10c0/ad4bab9ead0808cf56501750fd9d3fb276f6b105f987707d059005d57e182d18a7c9ec7f3a01794ebddcca676773e42ca48a32d67a250c9d35e009ca613caba3 + languageName: node + linkType: hard + +"estree-walker@npm:2.0.2, estree-walker@npm:^2.0.2": + version: 2.0.2 + resolution: "estree-walker@npm:2.0.2" + checksum: 10c0/53a6c54e2019b8c914dc395890153ffdc2322781acf4bd7d1a32d7aedc1710807bdcd866ac133903d5629ec601fbb50abe8c2e5553c7f5a0afdd9b6af6c945af + languageName: node + linkType: hard + +"esutils@npm:^2.0.3": + version: 2.0.3 + resolution: "esutils@npm:2.0.3" + checksum: 10c0/9a2fe69a41bfdade834ba7c42de4723c97ec776e40656919c62cbd13607c45e127a003f05f724a1ea55e5029a4cf2de444b13009f2af71271e42d93a637137c7 + languageName: node + linkType: hard + +"fast-diff@npm:^1.2.0": + version: 1.3.0 + resolution: "fast-diff@npm:1.3.0" + checksum: 10c0/5c19af237edb5d5effda008c891a18a585f74bf12953be57923f17a3a4d0979565fc64dbc73b9e20926b9d895f5b690c618cbb969af0cf022e3222471220ad29 + languageName: node + linkType: hard + +"fast-glob@npm:^3.3.3": + version: 3.3.3 + resolution: "fast-glob@npm:3.3.3" + dependencies: + "@nodelib/fs.stat": "npm:^2.0.2" + "@nodelib/fs.walk": "npm:^1.2.3" + glob-parent: "npm:^5.1.2" + merge2: "npm:^1.3.0" + micromatch: "npm:^4.0.8" + checksum: 10c0/f6aaa141d0d3384cf73cbcdfc52f475ed293f6d5b65bfc5def368b09163a9f7e5ec2b3014d80f733c405f58e470ee0cc451c2937685045cddcdeaa24199c43fe + languageName: node + linkType: hard + +"fastq@npm:^1.6.0": + version: 1.19.1 + resolution: "fastq@npm:1.19.1" + dependencies: + reusify: "npm:^1.0.4" + checksum: 10c0/ebc6e50ac7048daaeb8e64522a1ea7a26e92b3cee5cd1c7f2316cdca81ba543aa40a136b53891446ea5c3a67ec215fbaca87ad405f102dd97012f62916905630 + languageName: node + linkType: hard + +"figures@npm:^6.1.0": + version: 6.1.0 + resolution: "figures@npm:6.1.0" + dependencies: + is-unicode-supported: "npm:^2.0.0" + checksum: 10c0/9159df4264d62ef447a3931537de92f5012210cf5135c35c010df50a2169377581378149abfe1eb238bd6acbba1c0d547b1f18e0af6eee49e30363cedaffcfe4 + languageName: node + linkType: hard + +"file-uri-to-path@npm:1.0.0": + version: 1.0.0 + resolution: "file-uri-to-path@npm:1.0.0" + checksum: 10c0/3b545e3a341d322d368e880e1c204ef55f1d45cdea65f7efc6c6ce9e0c4d22d802d5629320eb779d006fe59624ac17b0e848d83cc5af7cd101f206cb704f5519 + languageName: node + linkType: hard + +"fill-range@npm:^7.1.1": + version: 7.1.1 + resolution: "fill-range@npm:7.1.1" + dependencies: + to-regex-range: "npm:^5.0.1" + checksum: 10c0/b75b691bbe065472f38824f694c2f7449d7f5004aa950426a2c28f0306c60db9b880c0b0e4ed819997ffb882d1da02cfcfc819bddc94d71627f5269682edf018 + languageName: node + linkType: hard + +"find-up-simple@npm:^1.0.0": + version: 1.0.1 + resolution: "find-up-simple@npm:1.0.1" + checksum: 10c0/ad34de157b7db925d50ff78302fefb28e309f3bc947c93ffca0f9b0bccf9cf1a2dc57d805d5c94ec9fc60f4838f5dbdfd2a48ecd77c23015fa44c6dd5f60bc40 + languageName: node + linkType: hard + +"fs.realpath@npm:^1.0.0": + version: 1.0.0 + resolution: "fs.realpath@npm:1.0.0" + checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948 + languageName: node + linkType: hard + +"get-caller-file@npm:^2.0.5": + version: 2.0.5 + resolution: "get-caller-file@npm:2.0.5" + checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde + languageName: node + linkType: hard + +"get-east-asian-width@npm:^1.0.0": + version: 1.3.0 + resolution: "get-east-asian-width@npm:1.3.0" + checksum: 10c0/1a049ba697e0f9a4d5514c4623781c5246982bdb61082da6b5ae6c33d838e52ce6726407df285cdbb27ec1908b333cf2820989bd3e986e37bb20979437fdf34b + languageName: node + linkType: hard + +"glob-parent@npm:^5.1.2": + version: 5.1.2 + resolution: "glob-parent@npm:5.1.2" + dependencies: + is-glob: "npm:^4.0.1" + checksum: 10c0/cab87638e2112bee3f839ef5f6e0765057163d39c66be8ec1602f3823da4692297ad4e972de876ea17c44d652978638d2fd583c6713d0eb6591706825020c9ee + languageName: node + linkType: hard + +"glob@npm:^7.1.3": + version: 7.2.3 + resolution: "glob@npm:7.2.3" + dependencies: + fs.realpath: "npm:^1.0.0" + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:^3.1.1" + once: "npm:^1.3.0" + path-is-absolute: "npm:^1.0.0" + checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe + languageName: node + linkType: hard + +"globby@npm:^14.0.2": + version: 14.1.0 + resolution: "globby@npm:14.1.0" + dependencies: + "@sindresorhus/merge-streams": "npm:^2.1.0" + fast-glob: "npm:^3.3.3" + ignore: "npm:^7.0.3" + path-type: "npm:^6.0.0" + slash: "npm:^5.1.0" + unicorn-magic: "npm:^0.3.0" + checksum: 10c0/527a1063c5958255969620c6fa4444a2b2e9278caddd571d46dfbfa307cb15977afb746e84d682ba5b6c94fc081e8997f80ff05dd235441ba1cb16f86153e58e + languageName: node + linkType: hard + +"graceful-fs@npm:^4.2.9": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"https-proxy-agent@npm:^7.0.5": + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" + dependencies: + agent-base: "npm:^7.1.2" + debug: "npm:4" + checksum: 10c0/f729219bc735edb621fa30e6e84e60ee5d00802b8247aac0d7b79b0bd6d4b3294737a337b93b86a0bd9e68099d031858a39260c976dc14cdbba238ba1f8779ac + languageName: node + linkType: hard + +"ignore-by-default@npm:^2.1.0": + version: 2.1.0 + resolution: "ignore-by-default@npm:2.1.0" + checksum: 10c0/3a6040dac25ed9da39dee73bf1634fdd1e15b0eb7cf52a6bdec81c310565782d8811c104ce40acb3d690d61c5fc38a91c78e6baee830a8a2232424dbc6b66981 + languageName: node + linkType: hard + +"ignore@npm:^7.0.3": + version: 7.0.3 + resolution: "ignore@npm:7.0.3" + checksum: 10c0/8e21637513cbcd888a4873d34d5c651a2e24b3c4c9a6b159335a26bed348c3c386c51d6fab23577f59140e1b226323138fbd50e63882d4568fd12aa6c822029e + languageName: node + linkType: hard + +"imurmurhash@npm:^0.1.4": + version: 0.1.4 + resolution: "imurmurhash@npm:0.1.4" + checksum: 10c0/8b51313850dd33605c6c9d3fd9638b714f4c4c40250cff658209f30d40da60f78992fb2df5dabee4acf589a6a82bbc79ad5486550754bd9ec4e3fc0d4a57d6a6 + languageName: node + linkType: hard + +"indent-string@npm:^5.0.0": + version: 5.0.0 + resolution: "indent-string@npm:5.0.0" + checksum: 10c0/8ee77b57d92e71745e133f6f444d6fa3ed503ad0e1bcd7e80c8da08b42375c07117128d670589725ed07b1978065803fa86318c309ba45415b7fe13e7f170220 + languageName: node + linkType: hard + +"inflight@npm:^1.0.4": + version: 1.0.6 + resolution: "inflight@npm:1.0.6" + dependencies: + once: "npm:^1.3.0" + wrappy: "npm:1" + checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2 + languageName: node + linkType: hard + +"inherits@npm:2": + version: 2.0.4 + resolution: "inherits@npm:2.0.4" + checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 + languageName: node + linkType: hard + +"irregular-plurals@npm:^3.3.0": + version: 3.5.0 + resolution: "irregular-plurals@npm:3.5.0" + checksum: 10c0/7c033bbe7325e5a6e0a26949cc6863b6ce273403d4cd5b93bd99b33fecb6605b0884097c4259c23ed0c52c2133bf7d1cdcdd7a0630e8c325161fe269b3447918 + languageName: node + linkType: hard + +"is-extglob@npm:^2.1.1": + version: 2.1.1 + resolution: "is-extglob@npm:2.1.1" + checksum: 10c0/5487da35691fbc339700bbb2730430b07777a3c21b9ebaecb3072512dfd7b4ba78ac2381a87e8d78d20ea08affb3f1971b4af629173a6bf435ff8a4c47747912 + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^3.0.0": + version: 3.0.0 + resolution: "is-fullwidth-code-point@npm:3.0.0" + checksum: 10c0/bb11d825e049f38e04c06373a8d72782eee0205bda9d908cc550ccb3c59b99d750ff9537982e01733c1c94a58e35400661f57042158ff5e8f3e90cf936daf0fc + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^4.0.0": + version: 4.0.0 + resolution: "is-fullwidth-code-point@npm:4.0.0" + checksum: 10c0/df2a717e813567db0f659c306d61f2f804d480752526886954a2a3e2246c7745fd07a52b5fecf2b68caf0a6c79dcdace6166fdf29cc76ed9975cc334f0a018b8 + languageName: node + linkType: hard + +"is-glob@npm:^4.0.1": + version: 4.0.3 + resolution: "is-glob@npm:4.0.3" + dependencies: + is-extglob: "npm:^2.1.1" + checksum: 10c0/17fb4014e22be3bbecea9b2e3a76e9e34ff645466be702f1693e8f1ee1adac84710d0be0bd9f967d6354036fd51ab7c2741d954d6e91dae6bb69714de92c197a + languageName: node + linkType: hard + +"is-number@npm:^7.0.0": + version: 7.0.0 + resolution: "is-number@npm:7.0.0" + checksum: 10c0/b4686d0d3053146095ccd45346461bc8e53b80aeb7671cc52a4de02dbbf7dc0d1d2a986e2fe4ae206984b4d34ef37e8b795ebc4f4295c978373e6575e295d811 + languageName: node + linkType: hard + +"is-plain-object@npm:^5.0.0": + version: 5.0.0 + resolution: "is-plain-object@npm:5.0.0" + checksum: 10c0/893e42bad832aae3511c71fd61c0bf61aa3a6d853061c62a307261842727d0d25f761ce9379f7ba7226d6179db2a3157efa918e7fe26360f3bf0842d9f28942c + languageName: node + linkType: hard + +"is-promise@npm:^4.0.0": + version: 4.0.0 + resolution: "is-promise@npm:4.0.0" + checksum: 10c0/ebd5c672d73db781ab33ccb155fb9969d6028e37414d609b115cc534654c91ccd061821d5b987eefaa97cf4c62f0b909bb2f04db88306de26e91bfe8ddc01503 + languageName: node + linkType: hard + +"is-unicode-supported@npm:^2.0.0": + version: 2.1.0 + resolution: "is-unicode-supported@npm:2.1.0" + checksum: 10c0/a0f53e9a7c1fdbcf2d2ef6e40d4736fdffff1c9f8944c75e15425118ff3610172c87bf7bc6c34d3903b04be59790bb2212ddbe21ee65b5a97030fc50370545a5 + languageName: node + linkType: hard + +"js-string-escape@npm:^1.0.1": + version: 1.0.1 + resolution: "js-string-escape@npm:1.0.1" + checksum: 10c0/2c33b9ff1ba6b84681c51ca0997e7d5a1639813c95d5b61cb7ad47e55cc28fa4a0b1935c3d218710d8e6bcee5d0cd8c44755231e3a4e45fc604534d9595a3628 + languageName: node + linkType: hard + +"js-yaml@npm:^3.14.1": + version: 3.14.1 + resolution: "js-yaml@npm:3.14.1" + dependencies: + argparse: "npm:^1.0.7" + esprima: "npm:^4.0.0" + bin: + js-yaml: bin/js-yaml.js + checksum: 10c0/6746baaaeac312c4db8e75fa22331d9a04cccb7792d126ed8ce6a0bbcfef0cedaddd0c5098fade53db067c09fe00aa1c957674b4765610a8b06a5a189e46433b + languageName: node + linkType: hard + +"libloot-nodejs@workspace:.": + version: 0.0.0-use.local + resolution: "libloot-nodejs@workspace:." + dependencies: + "@napi-rs/cli": "npm:^2.18.4" + ava: "npm:^6.0.1" + languageName: unknown + linkType: soft + +"load-json-file@npm:^7.0.1": + version: 7.0.1 + resolution: "load-json-file@npm:7.0.1" + checksum: 10c0/7117459608a0b6329c7f78e6e1f541b3162dd901c29dd5af721fec8b270177d2e3d7999c971f344fff04daac368d052732e2c7146014bc84d15e0b636975e19a + languageName: node + linkType: hard + +"lodash@npm:^4.17.15": + version: 4.17.21 + resolution: "lodash@npm:4.17.21" + checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c + languageName: node + linkType: hard + +"matcher@npm:^5.0.0": + version: 5.0.0 + resolution: "matcher@npm:5.0.0" + dependencies: + escape-string-regexp: "npm:^5.0.0" + checksum: 10c0/eda5471fc9d5b7264d63c81727824adc3585ddb5cfdc5fce5a9b7c86f946ff181610735d330b1c37a84811df872d1290bf4e9401d2be2a414204343701144b18 + languageName: node + linkType: hard + +"md5-hex@npm:^3.0.1": + version: 3.0.1 + resolution: "md5-hex@npm:3.0.1" + dependencies: + blueimp-md5: "npm:^2.10.0" + checksum: 10c0/ee2b4d8da16b527b3a3fe4d7a96720f43afd07b46a82d49421208b5a126235fb75cfb30b80d4029514772c8844273f940bddfbf4155c787f968f3be4060d01e4 + languageName: node + linkType: hard + +"memoize@npm:^10.0.0": + version: 10.1.0 + resolution: "memoize@npm:10.1.0" + dependencies: + mimic-function: "npm:^5.0.1" + checksum: 10c0/6cf71f673b89778b05cd1131f573ba858627daa8fec60f2197328386acf7ab184a89e52527abbd5a605b5ccf5ee12dc0cb96efb651d9a30dcfcc89e9baacc84d + languageName: node + linkType: hard + +"merge2@npm:^1.3.0": + version: 1.4.1 + resolution: "merge2@npm:1.4.1" + checksum: 10c0/254a8a4605b58f450308fc474c82ac9a094848081bf4c06778200207820e5193726dc563a0d2c16468810516a5c97d9d3ea0ca6585d23c58ccfff2403e8dbbeb + languageName: node + linkType: hard + +"micromatch@npm:^4.0.8": + version: 4.0.8 + resolution: "micromatch@npm:4.0.8" + dependencies: + braces: "npm:^3.0.3" + picomatch: "npm:^2.3.1" + checksum: 10c0/166fa6eb926b9553f32ef81f5f531d27b4ce7da60e5baf8c021d043b27a388fb95e46a8038d5045877881e673f8134122b59624d5cecbd16eb50a42e7a6b5ca8 + languageName: node + linkType: hard + +"mimic-function@npm:^5.0.1": + version: 5.0.1 + resolution: "mimic-function@npm:5.0.1" + checksum: 10c0/f3d9464dd1816ecf6bdf2aec6ba32c0728022039d992f178237d8e289b48764fee4131319e72eedd4f7f094e22ded0af836c3187a7edc4595d28dd74368fd81d + languageName: node + linkType: hard + +"minimatch@npm:^3.1.1": + version: 3.1.2 + resolution: "minimatch@npm:3.1.2" + dependencies: + brace-expansion: "npm:^1.1.7" + checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 + languageName: node + linkType: hard + +"minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.2 + resolution: "minipass@npm:7.1.2" + checksum: 10c0/b0fd20bb9fb56e5fa9a8bfac539e8915ae07430a619e4b86ff71f5fc757ef3924b23b2c4230393af1eda647ed3d75739e4e0acb250a6b1eb277cf7f8fe449557 + languageName: node + linkType: hard + +"minizlib@npm:^3.0.1": + version: 3.0.2 + resolution: "minizlib@npm:3.0.2" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/9f3bd35e41d40d02469cb30470c55ccc21cae0db40e08d1d0b1dff01cc8cc89a6f78e9c5d2b7c844e485ec0a8abc2238111213fdc5b2038e6d1012eacf316f78 + languageName: node + linkType: hard + +"mkdirp@npm:^3.0.1": + version: 3.0.1 + resolution: "mkdirp@npm:3.0.1" + bin: + mkdirp: dist/cjs/src/bin.js + checksum: 10c0/9f2b975e9246351f5e3a40dcfac99fcd0baa31fbfab615fe059fb11e51f10e4803c63de1f384c54d656e4db31d000e4767e9ef076a22e12a641357602e31d57d + languageName: node + linkType: hard + +"ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 + languageName: node + linkType: hard + +"node-fetch@npm:^2.6.7": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: "npm:^5.0.0" + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8 + languageName: node + linkType: hard + +"node-gyp-build@npm:^4.2.2": + version: 4.8.4 + resolution: "node-gyp-build@npm:4.8.4" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 10c0/444e189907ece2081fe60e75368784f7782cfddb554b60123743dfb89509df89f1f29c03bbfa16b3a3e0be3f48799a4783f487da6203245fa5bed239ba7407e1 + languageName: node + linkType: hard + +"nofilter@npm:^3.1.0": + version: 3.1.0 + resolution: "nofilter@npm:3.1.0" + checksum: 10c0/92459f3864a067b347032263f0b536223cbfc98153913b5dce350cb39c8470bc1813366e41993f22c33cc6400c0f392aa324a4b51e24c22040635c1cdb046499 + languageName: node + linkType: hard + +"nopt@npm:^8.0.0": + version: 8.1.0 + resolution: "nopt@npm:8.1.0" + dependencies: + abbrev: "npm:^3.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/62e9ea70c7a3eb91d162d2c706b6606c041e4e7b547cbbb48f8b3695af457dd6479904d7ace600856bf923dd8d1ed0696f06195c8c20f02ac87c1da0e1d315ef + languageName: node + linkType: hard + +"once@npm:^1.3.0": + version: 1.4.0 + resolution: "once@npm:1.4.0" + dependencies: + wrappy: "npm:1" + checksum: 10c0/5d48aca287dfefabd756621c5dfce5c91a549a93e9fdb7b8246bc4c4790aa2ec17b34a260530474635147aeb631a2dcc8b32c613df0675f96041cbb8244517d0 + languageName: node + linkType: hard + +"p-map@npm:^7.0.2": + version: 7.0.3 + resolution: "p-map@npm:7.0.3" + checksum: 10c0/46091610da2b38ce47bcd1d8b4835a6fa4e832848a6682cf1652bc93915770f4617afc844c10a77d1b3e56d2472bb2d5622353fa3ead01a7f42b04fc8e744a5c + languageName: node + linkType: hard + +"package-config@npm:^5.0.0": + version: 5.0.0 + resolution: "package-config@npm:5.0.0" + dependencies: + find-up-simple: "npm:^1.0.0" + load-json-file: "npm:^7.0.1" + checksum: 10c0/f6c48930700b73a41d839bf2898b628d23665827488a4f34aed2d05e4a99d7a70a70ada115c3546765947fbc8accff94c0779da21ea084b25df47cb774531eeb + languageName: node + linkType: hard + +"parse-ms@npm:^4.0.0": + version: 4.0.0 + resolution: "parse-ms@npm:4.0.0" + checksum: 10c0/a7900f4f1ebac24cbf5e9708c16fb2fd482517fad353aecd7aefb8c2ba2f85ce017913ccb8925d231770404780df46244ea6fec598b3bde6490882358b4d2d16 + languageName: node + linkType: hard + +"path-is-absolute@npm:^1.0.0": + version: 1.0.1 + resolution: "path-is-absolute@npm:1.0.1" + checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078 + languageName: node + linkType: hard + +"path-type@npm:^6.0.0": + version: 6.0.0 + resolution: "path-type@npm:6.0.0" + checksum: 10c0/55baa8b1187d6dc683d5a9cfcc866168d6adff58e5db91126795376d818eee46391e00b2a4d53e44d844c7524a7d96aa68cc68f4f3e500d3d069a39e6535481c + languageName: node + linkType: hard + +"picomatch@npm:^2.3.1": + version: 2.3.1 + resolution: "picomatch@npm:2.3.1" + checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be + languageName: node + linkType: hard + +"picomatch@npm:^4.0.2": + version: 4.0.2 + resolution: "picomatch@npm:4.0.2" + checksum: 10c0/7c51f3ad2bb42c776f49ebf964c644958158be30d0a510efd5a395e8d49cb5acfed5b82c0c5b365523ce18e6ab85013c9ebe574f60305892ec3fa8eee8304ccc + languageName: node + linkType: hard + +"plur@npm:^5.1.0": + version: 5.1.0 + resolution: "plur@npm:5.1.0" + dependencies: + irregular-plurals: "npm:^3.3.0" + checksum: 10c0/26bb622b8545fcfd47bbf56fbcca66c08693708a232e403fa3589e00003c56c14231ac57c7588ca5db83ef4be1f61383402c4ea954000768f779f8aef6eb6da8 + languageName: node + linkType: hard + +"pretty-ms@npm:^9.1.0": + version: 9.2.0 + resolution: "pretty-ms@npm:9.2.0" + dependencies: + parse-ms: "npm:^4.0.0" + checksum: 10c0/ab6d066f90e9f77020426986e1b018369f41575674544c539aabec2e63a20fec01166d8cf6571d0e165ad11cfe5a8134a2a48a36d42ab291c59c6deca5264cbb + languageName: node + linkType: hard + +"queue-microtask@npm:^1.2.2": + version: 1.2.3 + resolution: "queue-microtask@npm:1.2.3" + checksum: 10c0/900a93d3cdae3acd7d16f642c29a642aea32c2026446151f0778c62ac089d4b8e6c986811076e1ae180a694cedf077d453a11b58ff0a865629a4f82ab558e102 + languageName: node + linkType: hard + +"require-directory@npm:^2.1.1": + version: 2.1.1 + resolution: "require-directory@npm:2.1.1" + checksum: 10c0/83aa76a7bc1531f68d92c75a2ca2f54f1b01463cb566cf3fbc787d0de8be30c9dbc211d1d46be3497dac5785fe296f2dd11d531945ac29730643357978966e99 + languageName: node + linkType: hard + +"resolve-cwd@npm:^3.0.0": + version: 3.0.0 + resolution: "resolve-cwd@npm:3.0.0" + dependencies: + resolve-from: "npm:^5.0.0" + checksum: 10c0/e608a3ebd15356264653c32d7ecbc8fd702f94c6703ea4ac2fb81d9c359180cba0ae2e6b71faa446631ed6145454d5a56b227efc33a2d40638ac13f8beb20ee4 + languageName: node + linkType: hard + +"resolve-from@npm:^5.0.0": + version: 5.0.0 + resolution: "resolve-from@npm:5.0.0" + checksum: 10c0/b21cb7f1fb746de8107b9febab60095187781137fd803e6a59a76d421444b1531b641bba5857f5dc011974d8a5c635d61cec49e6bd3b7fc20e01f0fafc4efbf2 + languageName: node + linkType: hard + +"reusify@npm:^1.0.4": + version: 1.1.0 + resolution: "reusify@npm:1.1.0" + checksum: 10c0/4eff0d4a5f9383566c7d7ec437b671cc51b25963bd61bf127c3f3d3f68e44a026d99b8d2f1ad344afff8d278a8fe70a8ea092650a716d22287e8bef7126bb2fa + languageName: node + linkType: hard + +"run-parallel@npm:^1.1.9": + version: 1.2.0 + resolution: "run-parallel@npm:1.2.0" + dependencies: + queue-microtask: "npm:^1.2.2" + checksum: 10c0/200b5ab25b5b8b7113f9901bfe3afc347e19bb7475b267d55ad0eb86a62a46d77510cb0f232507c9e5d497ebda569a08a9867d0d14f57a82ad5564d991588b39 + languageName: node + linkType: hard + +"semver@npm:^7.3.2, semver@npm:^7.5.3": + version: 7.7.1 + resolution: "semver@npm:7.7.1" + bin: + semver: bin/semver.js + checksum: 10c0/fd603a6fb9c399c6054015433051bdbe7b99a940a8fb44b85c2b524c4004b023d7928d47cb22154f8d054ea7ee8597f586605e05b52047f048278e4ac56ae958 + languageName: node + linkType: hard + +"serialize-error@npm:^7.0.1": + version: 7.0.1 + resolution: "serialize-error@npm:7.0.1" + dependencies: + type-fest: "npm:^0.13.1" + checksum: 10c0/7982937d578cd901276c8ab3e2c6ed8a4c174137730f1fb0402d005af209a0e84d04acc874e317c936724c7b5b26c7a96ff7e4b8d11a469f4924a4b0ea814c05 + languageName: node + linkType: hard + +"signal-exit@npm:^4.0.1": + version: 4.1.0 + resolution: "signal-exit@npm:4.1.0" + checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 + languageName: node + linkType: hard + +"slash@npm:^5.1.0": + version: 5.1.0 + resolution: "slash@npm:5.1.0" + checksum: 10c0/eb48b815caf0bdc390d0519d41b9e0556a14380f6799c72ba35caf03544d501d18befdeeef074bc9c052acf69654bc9e0d79d7f1de0866284137a40805299eb3 + languageName: node + linkType: hard + +"slice-ansi@npm:^5.0.0": + version: 5.0.0 + resolution: "slice-ansi@npm:5.0.0" + dependencies: + ansi-styles: "npm:^6.0.0" + is-fullwidth-code-point: "npm:^4.0.0" + checksum: 10c0/2d4d40b2a9d5cf4e8caae3f698fe24ae31a4d778701724f578e984dcb485ec8c49f0c04dab59c401821e80fcdfe89cace9c66693b0244e40ec485d72e543914f + languageName: node + linkType: hard + +"sprintf-js@npm:~1.0.2": + version: 1.0.3 + resolution: "sprintf-js@npm:1.0.3" + checksum: 10c0/ecadcfe4c771890140da5023d43e190b7566d9cf8b2d238600f31bec0fc653f328da4450eb04bd59a431771a8e9cc0e118f0aa3974b683a4981b4e07abc2a5bb + languageName: node + linkType: hard + +"stack-utils@npm:^2.0.6": + version: 2.0.6 + resolution: "stack-utils@npm:2.0.6" + dependencies: + escape-string-regexp: "npm:^2.0.0" + checksum: 10c0/651c9f87667e077584bbe848acaecc6049bc71979f1e9a46c7b920cad4431c388df0f51b8ad7cfd6eed3db97a2878d0fc8b3122979439ea8bac29c61c95eec8a + languageName: node + linkType: hard + +"string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": + version: 4.2.3 + resolution: "string-width@npm:4.2.3" + dependencies: + emoji-regex: "npm:^8.0.0" + is-fullwidth-code-point: "npm:^3.0.0" + strip-ansi: "npm:^6.0.1" + checksum: 10c0/1e525e92e5eae0afd7454086eed9c818ee84374bb80328fc41217ae72ff5f065ef1c9d7f72da41de40c75fa8bb3dee63d92373fd492c84260a552c636392a47b + languageName: node + linkType: hard + +"string-width@npm:^7.0.0": + version: 7.2.0 + resolution: "string-width@npm:7.2.0" + dependencies: + emoji-regex: "npm:^10.3.0" + get-east-asian-width: "npm:^1.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/eb0430dd43f3199c7a46dcbf7a0b34539c76fe3aa62763d0b0655acdcbdf360b3f66f3d58ca25ba0205f42ea3491fa00f09426d3b7d3040e506878fc7664c9b9 + languageName: node + linkType: hard + +"strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": + version: 6.0.1 + resolution: "strip-ansi@npm:6.0.1" + dependencies: + ansi-regex: "npm:^5.0.1" + checksum: 10c0/1ae5f212a126fe5b167707f716942490e3933085a5ff6c008ab97ab2f272c8025d3aa218b7bd6ab25729ca20cc81cddb252102f8751e13482a5199e873680952 + languageName: node + linkType: hard + +"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0": + version: 7.1.0 + resolution: "strip-ansi@npm:7.1.0" + dependencies: + ansi-regex: "npm:^6.0.1" + checksum: 10c0/a198c3762e8832505328cbf9e8c8381de14a4fa50a4f9b2160138158ea88c0f5549fb50cb13c651c3088f47e63a108b34622ec18c0499b6c8c3a5ddf6b305ac4 + languageName: node + linkType: hard + +"supertap@npm:^3.0.1": + version: 3.0.1 + resolution: "supertap@npm:3.0.1" + dependencies: + indent-string: "npm:^5.0.0" + js-yaml: "npm:^3.14.1" + serialize-error: "npm:^7.0.1" + strip-ansi: "npm:^7.0.1" + checksum: 10c0/8164674f2e280cab875f0fef5bb36c15553c13e29697ff92f4e0d6bc62149f0303a89eee47535413ed145ea72e14a24d065bab233059d48a499ec5ebb4566b0f + languageName: node + linkType: hard + +"tar@npm:^7.4.0": + version: 7.4.3 + resolution: "tar@npm:7.4.3" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^7.1.2" + minizlib: "npm:^3.0.1" + mkdirp: "npm:^3.0.1" + yallist: "npm:^5.0.0" + checksum: 10c0/d4679609bb2a9b48eeaf84632b6d844128d2412b95b6de07d53d8ee8baf4ca0857c9331dfa510390a0727b550fd543d4d1a10995ad86cdf078423fbb8d99831d + languageName: node + linkType: hard + +"temp-dir@npm:^3.0.0": + version: 3.0.0 + resolution: "temp-dir@npm:3.0.0" + checksum: 10c0/a86978a400984cd5f315b77ebf3fe53bb58c61f192278cafcb1f3fb32d584a21dc8e08b93171d7874b7cc972234d3455c467306cc1bfc4524b622e5ad3bfd671 + languageName: node + linkType: hard + +"time-zone@npm:^1.0.0": + version: 1.0.0 + resolution: "time-zone@npm:1.0.0" + checksum: 10c0/d00ebd885039109011b6e2423ebbf225160927333c2ade6d833e9cc4676db20759f1f3855fafde00d1bd668c243a6aa68938ce71fe58aab0d514e820d59c1d81 + languageName: node + linkType: hard + +"to-regex-range@npm:^5.0.1": + version: 5.0.1 + resolution: "to-regex-range@npm:5.0.1" + dependencies: + is-number: "npm:^7.0.0" + checksum: 10c0/487988b0a19c654ff3e1961b87f471702e708fa8a8dd02a298ef16da7206692e8552a0250e8b3e8759270f62e9d8314616f6da274734d3b558b1fc7b7724e892 + languageName: node + linkType: hard + +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11 + languageName: node + linkType: hard + +"type-fest@npm:^0.13.1": + version: 0.13.1 + resolution: "type-fest@npm:0.13.1" + checksum: 10c0/0c0fa07ae53d4e776cf4dac30d25ad799443e9eef9226f9fddbb69242db86b08584084a99885cfa5a9dfe4c063ebdc9aa7b69da348e735baede8d43f1aeae93b + languageName: node + linkType: hard + +"unicorn-magic@npm:^0.3.0": + version: 0.3.0 + resolution: "unicorn-magic@npm:0.3.0" + checksum: 10c0/0a32a997d6c15f1c2a077a15b1c4ca6f268d574cf5b8975e778bb98e6f8db4ef4e86dfcae4e158cd4c7e38fb4dd383b93b13eefddc7f178dea13d3ac8a603271 + languageName: node + linkType: hard + +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db + languageName: node + linkType: hard + +"well-known-symbols@npm:^2.0.0": + version: 2.0.0 + resolution: "well-known-symbols@npm:2.0.0" + checksum: 10c0/cb6c12e98877e8952ec28d13ae6f4fdb54ae1cb49b16a728720276dadd76c930e6cb0e174af3a4620054dd2752546f842540122920c6e31410208abd4958ee6b + languageName: node + linkType: hard + +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: "npm:~0.0.3" + webidl-conversions: "npm:^3.0.0" + checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5 + languageName: node + linkType: hard + +"wrap-ansi@npm:^7.0.0": + version: 7.0.0 + resolution: "wrap-ansi@npm:7.0.0" + dependencies: + ansi-styles: "npm:^4.0.0" + string-width: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + checksum: 10c0/d15fc12c11e4cbc4044a552129ebc75ee3f57aa9c1958373a4db0292d72282f54373b536103987a4a7594db1ef6a4f10acf92978f79b98c49306a4b58c77d4da + languageName: node + linkType: hard + +"wrappy@npm:1": + version: 1.0.2 + resolution: "wrappy@npm:1.0.2" + checksum: 10c0/56fece1a4018c6a6c8e28fbc88c87e0fbf4ea8fd64fc6c63b18f4acc4bd13e0ad2515189786dd2c30d3eec9663d70f4ecf699330002f8ccb547e4a18231fc9f0 + languageName: node + linkType: hard + +"write-file-atomic@npm:^6.0.0": + version: 6.0.0 + resolution: "write-file-atomic@npm:6.0.0" + dependencies: + imurmurhash: "npm:^0.1.4" + signal-exit: "npm:^4.0.1" + checksum: 10c0/ae2f1c27474758a9aca92037df6c1dd9cb94c4e4983451210bd686bfe341f142662f6aa5913095e572ab037df66b1bfe661ed4ce4c0369ed0e8219e28e141786 + languageName: node + linkType: hard + +"y18n@npm:^5.0.5": + version: 5.0.8 + resolution: "y18n@npm:5.0.8" + checksum: 10c0/4df2842c36e468590c3691c894bc9cdbac41f520566e76e24f59401ba7d8b4811eb1e34524d57e54bc6d864bcb66baab7ffd9ca42bf1eda596618f9162b91249 + languageName: node + linkType: hard + +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 + languageName: node + linkType: hard + +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 + languageName: node + linkType: hard + +"yargs@npm:^17.7.2": + version: 17.7.2 + resolution: "yargs@npm:17.7.2" + dependencies: + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05 + languageName: node + linkType: hard From 53c2709d1597399fae31ae26af5d281c3646ea95 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 21 Apr 2025 17:39:02 +0100 Subject: [PATCH 087/206] Remove and gitignore the generated index.js and index.d.ts files --- nodejs/.gitignore | 5 +- nodejs/index.d.ts | 6 - nodejs/index.js | 315 ---------------------------------------------- 3 files changed, 4 insertions(+), 322 deletions(-) delete mode 100644 nodejs/index.d.ts delete mode 100644 nodejs/index.js diff --git a/nodejs/.gitignore b/nodejs/.gitignore index 5e2577b3..f301f5ab 100644 --- a/nodejs/.gitignore +++ b/nodejs/.gitignore @@ -121,7 +121,7 @@ dist .AppleDouble .LSOverride -# Icon must end with two +# Icon must end with two Icon @@ -195,3 +195,6 @@ Cargo.lock !.yarn/versions *.node + +/index.d.ts +/index.js diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts deleted file mode 100644 index a43fa370..00000000 --- a/nodejs/index.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ - -/* auto-generated by NAPI-RS */ - -export declare function sum(a: number, b: number): number diff --git a/nodejs/index.js b/nodejs/index.js deleted file mode 100644 index 47dbe20c..00000000 --- a/nodejs/index.js +++ /dev/null @@ -1,315 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/* prettier-ignore */ - -/* auto-generated by NAPI-RS */ - -const { existsSync, readFileSync } = require('fs') -const { join } = require('path') - -const { platform, arch } = process - -let nativeBinding = null -let localFileExisted = false -let loadError = null - -function isMusl() { - // For Node 10 - if (!process.report || typeof process.report.getReport !== 'function') { - try { - const lddPath = require('child_process').execSync('which ldd').toString().trim() - return readFileSync(lddPath, 'utf8').includes('musl') - } catch (e) { - return true - } - } else { - const { glibcVersionRuntime } = process.report.getReport().header - return !glibcVersionRuntime - } -} - -switch (platform) { - case 'android': - switch (arch) { - case 'arm64': - localFileExisted = existsSync(join(__dirname, 'libloot-nodejs.android-arm64.node')) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.android-arm64.node') - } else { - nativeBinding = require('libloot-nodejs-android-arm64') - } - } catch (e) { - loadError = e - } - break - case 'arm': - localFileExisted = existsSync(join(__dirname, 'libloot-nodejs.android-arm-eabi.node')) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.android-arm-eabi.node') - } else { - nativeBinding = require('libloot-nodejs-android-arm-eabi') - } - } catch (e) { - loadError = e - } - break - default: - throw new Error(`Unsupported architecture on Android ${arch}`) - } - break - case 'win32': - switch (arch) { - case 'x64': - localFileExisted = existsSync( - join(__dirname, 'libloot-nodejs.win32-x64-msvc.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.win32-x64-msvc.node') - } else { - nativeBinding = require('libloot-nodejs-win32-x64-msvc') - } - } catch (e) { - loadError = e - } - break - case 'ia32': - localFileExisted = existsSync( - join(__dirname, 'libloot-nodejs.win32-ia32-msvc.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.win32-ia32-msvc.node') - } else { - nativeBinding = require('libloot-nodejs-win32-ia32-msvc') - } - } catch (e) { - loadError = e - } - break - case 'arm64': - localFileExisted = existsSync( - join(__dirname, 'libloot-nodejs.win32-arm64-msvc.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.win32-arm64-msvc.node') - } else { - nativeBinding = require('libloot-nodejs-win32-arm64-msvc') - } - } catch (e) { - loadError = e - } - break - default: - throw new Error(`Unsupported architecture on Windows: ${arch}`) - } - break - case 'darwin': - localFileExisted = existsSync(join(__dirname, 'libloot-nodejs.darwin-universal.node')) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.darwin-universal.node') - } else { - nativeBinding = require('libloot-nodejs-darwin-universal') - } - break - } catch {} - switch (arch) { - case 'x64': - localFileExisted = existsSync(join(__dirname, 'libloot-nodejs.darwin-x64.node')) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.darwin-x64.node') - } else { - nativeBinding = require('libloot-nodejs-darwin-x64') - } - } catch (e) { - loadError = e - } - break - case 'arm64': - localFileExisted = existsSync( - join(__dirname, 'libloot-nodejs.darwin-arm64.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.darwin-arm64.node') - } else { - nativeBinding = require('libloot-nodejs-darwin-arm64') - } - } catch (e) { - loadError = e - } - break - default: - throw new Error(`Unsupported architecture on macOS: ${arch}`) - } - break - case 'freebsd': - if (arch !== 'x64') { - throw new Error(`Unsupported architecture on FreeBSD: ${arch}`) - } - localFileExisted = existsSync(join(__dirname, 'libloot-nodejs.freebsd-x64.node')) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.freebsd-x64.node') - } else { - nativeBinding = require('libloot-nodejs-freebsd-x64') - } - } catch (e) { - loadError = e - } - break - case 'linux': - switch (arch) { - case 'x64': - if (isMusl()) { - localFileExisted = existsSync( - join(__dirname, 'libloot-nodejs.linux-x64-musl.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.linux-x64-musl.node') - } else { - nativeBinding = require('libloot-nodejs-linux-x64-musl') - } - } catch (e) { - loadError = e - } - } else { - localFileExisted = existsSync( - join(__dirname, 'libloot-nodejs.linux-x64-gnu.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.linux-x64-gnu.node') - } else { - nativeBinding = require('libloot-nodejs-linux-x64-gnu') - } - } catch (e) { - loadError = e - } - } - break - case 'arm64': - if (isMusl()) { - localFileExisted = existsSync( - join(__dirname, 'libloot-nodejs.linux-arm64-musl.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.linux-arm64-musl.node') - } else { - nativeBinding = require('libloot-nodejs-linux-arm64-musl') - } - } catch (e) { - loadError = e - } - } else { - localFileExisted = existsSync( - join(__dirname, 'libloot-nodejs.linux-arm64-gnu.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.linux-arm64-gnu.node') - } else { - nativeBinding = require('libloot-nodejs-linux-arm64-gnu') - } - } catch (e) { - loadError = e - } - } - break - case 'arm': - if (isMusl()) { - localFileExisted = existsSync( - join(__dirname, 'libloot-nodejs.linux-arm-musleabihf.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.linux-arm-musleabihf.node') - } else { - nativeBinding = require('libloot-nodejs-linux-arm-musleabihf') - } - } catch (e) { - loadError = e - } - } else { - localFileExisted = existsSync( - join(__dirname, 'libloot-nodejs.linux-arm-gnueabihf.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.linux-arm-gnueabihf.node') - } else { - nativeBinding = require('libloot-nodejs-linux-arm-gnueabihf') - } - } catch (e) { - loadError = e - } - } - break - case 'riscv64': - if (isMusl()) { - localFileExisted = existsSync( - join(__dirname, 'libloot-nodejs.linux-riscv64-musl.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.linux-riscv64-musl.node') - } else { - nativeBinding = require('libloot-nodejs-linux-riscv64-musl') - } - } catch (e) { - loadError = e - } - } else { - localFileExisted = existsSync( - join(__dirname, 'libloot-nodejs.linux-riscv64-gnu.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.linux-riscv64-gnu.node') - } else { - nativeBinding = require('libloot-nodejs-linux-riscv64-gnu') - } - } catch (e) { - loadError = e - } - } - break - case 's390x': - localFileExisted = existsSync( - join(__dirname, 'libloot-nodejs.linux-s390x-gnu.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./libloot-nodejs.linux-s390x-gnu.node') - } else { - nativeBinding = require('libloot-nodejs-linux-s390x-gnu') - } - } catch (e) { - loadError = e - } - break - default: - throw new Error(`Unsupported architecture on Linux: ${arch}`) - } - break - default: - throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`) -} - -if (!nativeBinding) { - if (loadError) { - throw loadError - } - throw new Error(`Failed to load native binding`) -} - -const { sum } = nativeBinding - -module.exports.sum = sum From cb4686b0b946746f0604e2a6b73560cc167a779a Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 21 Apr 2025 22:04:31 +0100 Subject: [PATCH 088/206] Remove generated rustfmt config --- nodejs/rustfmt.toml | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 nodejs/rustfmt.toml diff --git a/nodejs/rustfmt.toml b/nodejs/rustfmt.toml deleted file mode 100644 index cab5731e..00000000 --- a/nodejs/rustfmt.toml +++ /dev/null @@ -1,2 +0,0 @@ -tab_spaces = 2 -edition = "2021" From 5a33e16eb32d1194e2c233bea5fd8836e2874c96 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 21 Apr 2025 17:41:49 +0100 Subject: [PATCH 089/206] Add a Node.js wrapper library The implementation is very similar to the Python wrapper. --- nodejs/Cargo.toml | 3 + nodejs/README.md | 22 + nodejs/__test__/index.spec.mjs | 24 +- nodejs/build.rs | 2 +- nodejs/package-lock.json | 2087 ++++++++++++++++++++++++++++++++ nodejs/src/database.rs | 339 ++++++ nodejs/src/error.rs | 63 + nodejs/src/game.rs | 197 +++ nodejs/src/lib.rs | 104 +- nodejs/src/metadata.rs | 739 +++++++++++ nodejs/src/plugin.rs | 104 ++ nodejs/yarn.lock | 2003 +++++++++++++----------------- 12 files changed, 4504 insertions(+), 1183 deletions(-) create mode 100644 nodejs/README.md create mode 100644 nodejs/package-lock.json create mode 100644 nodejs/src/database.rs create mode 100644 nodejs/src/error.rs create mode 100644 nodejs/src/game.rs create mode 100644 nodejs/src/metadata.rs create mode 100644 nodejs/src/plugin.rs diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml index 0c132558..0955c784 100644 --- a/nodejs/Cargo.toml +++ b/nodejs/Cargo.toml @@ -7,6 +7,9 @@ version = "0.0.0" crate-type = ["cdylib"] [dependencies] +libloot = { path = ".." } +libloot-ffi-errors = { path = "../ffi-errors" } + # Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix napi = { version = "2.12.2", default-features = false, features = ["napi4"] } napi-derive = "2.12.2" diff --git a/nodejs/README.md b/nodejs/README.md new file mode 100644 index 00000000..f9359e0c --- /dev/null +++ b/nodejs/README.md @@ -0,0 +1,22 @@ +# libloot-nodejs + +An **experimental** Node.js wrapper around the libloot Rust implementation, built using [NAPI-RS](https://napi.rs/docs/concepts/class). + +## Build + +To build, first install Rust and Node.js, then run: + +``` +npm install +npm run build +``` + +The tests can be run using: + +``` +npm test +``` + +## Usage notes + +- All errors are thrown as JavaScript `Error` values. The error message is the concatenated display text of all the recursive Rust errors from the libloot crate, with the exception of `PluginDataError` errors, which are turned into messages of the form `esplugin error, code : `, where `` is one of the esplugin error codes currently exposed by the C++ implementation of libloot. diff --git a/nodejs/__test__/index.spec.mjs b/nodejs/__test__/index.spec.mjs index 1ade4caf..79b5d2c3 100644 --- a/nodejs/__test__/index.spec.mjs +++ b/nodejs/__test__/index.spec.mjs @@ -1,7 +1,25 @@ import test from 'ava' -import { sum } from '../index.js' +import { liblootVersion, isCompatible, Group } from '../index.js' -test('sum from native', (t) => { - t.is(sum(1, 2), 3) +test('liblootVersion', t => { + t.is(liblootVersion(), "0.26.0") +}); + +test('isCompatible', t => { + t.is(isCompatible(0, 26, 0), true) +}); + +test('Group equality', t => { + const group1 = new Group("A"); + const group2 = new Group("A"); + const group3 = new Group("B"); + + t.deepEqual(group1.name, group2.name); + t.notDeepEqual(group1.name, group3.name); + + t.deepEqual(group1, group2); + + // The objects have no public fields so appear to be equal. + t.deepEqual(group1, group3); }) diff --git a/nodejs/build.rs b/nodejs/build.rs index 1f866b6a..9fc23678 100644 --- a/nodejs/build.rs +++ b/nodejs/build.rs @@ -1,5 +1,5 @@ extern crate napi_build; fn main() { - napi_build::setup(); + napi_build::setup(); } diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json new file mode 100644 index 00000000..bf19f777 --- /dev/null +++ b/nodejs/package-lock.json @@ -0,0 +1,2087 @@ +{ + "name": "libloot-nodejs", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "libloot-nodejs", + "version": "0.0.0", + "license": "MIT", + "devDependencies": { + "@napi-rs/cli": "^2.18.4", + "ava": "^6.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.0.tgz", + "integrity": "sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "consola": "^3.2.3", + "detect-libc": "^2.0.0", + "https-proxy-agent": "^7.0.5", + "node-fetch": "^2.6.7", + "nopt": "^8.0.0", + "semver": "^7.5.3", + "tar": "^7.4.0" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/cli": { + "version": "2.18.4", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", + "dev": true, + "license": "MIT", + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/nft": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-0.27.10.tgz", + "integrity": "sha512-zbaF9Wp/NsZtKLE4uVmL3FyfFwlpDyuymQM1kPbeT0mVOHKDQQNjnnfslB3REg3oZprmNFJuh3pkHBk2qAaizg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^2.0.0-rc.0", + "@rollup/pluginutils": "^5.1.3", + "acorn": "^8.6.0", + "acorn-import-attributes": "^1.9.5", + "async-sema": "^3.1.1", + "bindings": "^1.4.0", + "estree-walker": "2.0.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "node-gyp-build": "^4.2.2", + "picomatch": "^4.0.2", + "resolve-from": "^5.0.0" + }, + "bin": { + "nft": "out/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arrgv": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arrgv/-/arrgv-1.0.2.tgz", + "integrity": "sha512-a4eg4yhp7mmruZDQFqVMlxNRFGi/i1r87pt8SDHy0/I8PqSXoUTlWZRdAZo0VXgvEARcujbtTk8kiZRi1uDGRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/arrify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-3.0.0.tgz", + "integrity": "sha512-tLkvA81vQG/XqE2mjDkGQHoOINtMHtysSnemrmoGe6PydDPMRbVugqyk4A6V/WDWEfm3l+0d8anA9r8cv/5Jaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/async-sema": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", + "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ava": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ava/-/ava-6.2.0.tgz", + "integrity": "sha512-+GZk5PbyepjiO/68hzCZCUepQOQauKfNnI7sA4JukBTg97jD7E+tDKEA7OhGOGr6EorNNMM9+jqvgHVOTOzG4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vercel/nft": "^0.27.5", + "acorn": "^8.13.0", + "acorn-walk": "^8.3.4", + "ansi-styles": "^6.2.1", + "arrgv": "^1.0.2", + "arrify": "^3.0.0", + "callsites": "^4.2.0", + "cbor": "^9.0.2", + "chalk": "^5.3.0", + "chunkd": "^2.0.1", + "ci-info": "^4.0.0", + "ci-parallel-vars": "^1.0.1", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "common-path-prefix": "^3.0.0", + "concordance": "^5.0.4", + "currently-unhandled": "^0.4.1", + "debug": "^4.3.7", + "emittery": "^1.0.3", + "figures": "^6.1.0", + "globby": "^14.0.2", + "ignore-by-default": "^2.1.0", + "indent-string": "^5.0.0", + "is-plain-object": "^5.0.0", + "is-promise": "^4.0.0", + "matcher": "^5.0.0", + "memoize": "^10.0.0", + "ms": "^2.1.3", + "p-map": "^7.0.2", + "package-config": "^5.0.0", + "picomatch": "^4.0.2", + "plur": "^5.1.0", + "pretty-ms": "^9.1.0", + "resolve-cwd": "^3.0.0", + "stack-utils": "^2.0.6", + "strip-ansi": "^7.1.0", + "supertap": "^3.0.1", + "temp-dir": "^3.0.0", + "write-file-atomic": "^6.0.0", + "yargs": "^17.7.2" + }, + "bin": { + "ava": "entrypoints/cli.mjs" + }, + "engines": { + "node": "^18.18 || ^20.8 || ^22 || >=23" + }, + "peerDependencies": { + "@ava/typescript": "*" + }, + "peerDependenciesMeta": { + "@ava/typescript": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/blueimp-md5": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", + "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-4.2.0.tgz", + "integrity": "sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cbor": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cbor/-/cbor-9.0.2.tgz", + "integrity": "sha512-JPypkxsB10s9QOWwa6zwPzqE1Md3vqpPc+cai4sAecuCsRyAtAl/pMyhPlMbT/xtPnm2dznJZYRLui57qiRhaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "nofilter": "^3.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chunkd": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/chunkd/-/chunkd-2.0.1.tgz", + "integrity": "sha512-7d58XsFmOq0j6el67Ug9mHf9ELUXsQXYJBkyxhH/k+6Ke0qXRnv0kbemx+Twc6fRJ07C49lcbdgm9FL1Ei/6SQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", + "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ci-parallel-vars": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ci-parallel-vars/-/ci-parallel-vars-1.0.1.tgz", + "integrity": "sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true, + "license": "ISC" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concordance": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/concordance/-/concordance-5.0.4.tgz", + "integrity": "sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "date-time": "^3.1.0", + "esutils": "^2.0.3", + "fast-diff": "^1.2.0", + "js-string-escape": "^1.0.1", + "lodash": "^4.17.15", + "md5-hex": "^3.0.1", + "semver": "^7.3.2", + "well-known-symbols": "^2.0.0" + }, + "engines": { + "node": ">=10.18.0 <11 || >=12.14.0 <13 || >=14" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-find-index": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/date-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/date-time/-/date-time-3.1.0.tgz", + "integrity": "sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "time-zone": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emittery": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-1.1.0.tgz", + "integrity": "sha512-rsX7ktqARv/6UQDgMaLfIqUWAEzzbCQiVh7V9rhDXp6c37yoJcks12NVD+XPkgl4AEavmNhVfrhGoqYwIsMYYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz", + "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-2.1.0.tgz", + "integrity": "sha512-yiWd4GVmJp0Q6ghmM2B/V3oZGRmjrKLXvHR3TE1nfoXsmoggllfZUQe74EN0fJdPFZu2NIvNdrMMLm3OsV7Ohw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10 <11 || >=12 <13 || >=14" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/irregular-plurals": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", + "integrity": "sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-string-escape": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", + "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/load-json-file": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-7.0.1.tgz", + "integrity": "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/matcher": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-5.0.0.tgz", + "integrity": "sha512-s2EMBOWtXFc8dgqvoAzKJXxNHibcdJMV0gwqKUaw9E2JBJuGUK7DrNKrA6g/i+v72TT16+6sVm5mS3thaMLQUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/md5-hex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz", + "integrity": "sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==", + "dev": true, + "license": "MIT", + "dependencies": { + "blueimp-md5": "^2.10.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/memoize": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/memoize/-/memoize-10.1.0.tgz", + "integrity": "sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/memoize?sponsor=1" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nofilter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz", + "integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.19" + } + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-config": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/package-config/-/package-config-5.0.0.tgz", + "integrity": "sha512-GYTTew2slBcYdvRHqjhwaaydVMvn/qrGC323+nKclYioNSLTDUM/lGgtGTgyHVtYcozb+XkE8CNhwcraOmZ9Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0", + "load-json-file": "^7.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plur": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/plur/-/plur-5.1.0.tgz", + "integrity": "sha512-VP/72JeXqak2KiOzjgKtQen5y3IZHn+9GOuLDafPv0eXa47xq0At93XahYBs26MsifCQ4enGKwbjBTKgb9QJXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "irregular-plurals": "^3.3.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-ms": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", + "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supertap": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/supertap/-/supertap-3.0.1.tgz", + "integrity": "sha512-u1ZpIBCawJnO+0QePsEiOknOfCRq0yERxiAchT0i4li0WHNUJbf0evXXSXOcCAR4M8iMDoajXYmstm/qO81Isw==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^5.0.0", + "js-yaml": "^3.14.1", + "serialize-error": "^7.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/time-zone": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/time-zone/-/time-zone-1.0.0.tgz", + "integrity": "sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/well-known-symbols": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/well-known-symbols/-/well-known-symbols-2.0.0.tgz", + "integrity": "sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", + "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/nodejs/src/database.rs b/nodejs/src/database.rs new file mode 100644 index 00000000..43355b10 --- /dev/null +++ b/nodejs/src/database.rs @@ -0,0 +1,339 @@ +use std::{ + path::Path, + sync::{Arc, RwLock}, +}; + +use libloot::{error::DatabaseLockPoisonError, WriteMode}; +use libloot_ffi_errors::UnsupportedEnumValueError; +use napi_derive::napi; + +use crate::{ + error::VerboseError, + metadata::{Group, Message, PluginMetadata}, +}; + +#[napi] +#[derive(Clone, Debug)] +pub struct Database(Arc>); + +#[napi] +impl Database { + #[napi] + pub fn load_masterlist(&self, path: String) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .load_masterlist(Path::new(&path)) + .map_err(Into::into) + } + + #[napi] + pub fn load_masterlist_with_prelude( + &self, + masterlist_path: String, + prelude_path: String, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .load_masterlist_with_prelude(Path::new(&masterlist_path), Path::new(&prelude_path)) + .map_err(Into::into) + } + + #[napi] + pub fn load_userlist(&self, path: String) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .load_userlist(Path::new(&path)) + .map_err(Into::into) + } + + #[napi] + pub fn write_user_metadata( + &self, + output_path: String, + overwrite: bool, + ) -> Result<(), VerboseError> { + let write_mode = if overwrite { + WriteMode::CreateOrTruncate + } else { + WriteMode::Create + }; + + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .write_user_metadata(Path::new(&output_path), write_mode) + .map_err(Into::into) + } + + #[napi] + pub fn write_minimal_list( + &self, + output_path: String, + overwrite: bool, + ) -> Result<(), VerboseError> { + let write_mode = if overwrite { + WriteMode::CreateOrTruncate + } else { + WriteMode::Create + }; + + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .write_minimal_list(Path::new(&output_path), write_mode) + .map_err(Into::into) + } + + #[napi] + pub fn evaluate(&self, condition: String) -> Result { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .evaluate(&condition) + .map_err(Into::into) + } + + #[napi] + pub fn known_bash_tags(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .known_bash_tags()) + } + + #[napi] + pub fn general_messages( + &self, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .general_messages(evaluate_conditions) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + #[napi] + pub fn groups(&self, include_user_metadata: bool) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .groups(include_user_metadata) + .into_iter() + .map(Into::into) + .collect()) + } + + #[napi] + pub fn user_groups(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .user_groups() + .iter() + .cloned() + .map(Into::into) + .collect()) + } + + #[napi] + pub fn set_user_groups(&self, groups: Vec<&Group>) -> Result<(), VerboseError> { + let groups = groups.into_iter().cloned().map(Into::into).collect(); + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .set_user_groups(groups); + Ok(()) + } + + #[napi] + pub fn groups_path( + &self, + from_group_name: String, + to_group_name: String, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .groups_path(&from_group_name, &to_group_name) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + #[napi] + pub fn plugin_metadata( + &self, + plugin_name: String, + include_user_metadata: bool, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .plugin_metadata(&plugin_name, include_user_metadata, evaluate_conditions) + .map(|p| p.map(Into::into)) + .map_err(Into::into) + } + + #[napi] + pub fn plugin_user_metadata( + &self, + plugin_name: String, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .plugin_user_metadata(&plugin_name, evaluate_conditions) + .map(|p| p.map(Into::into)) + .map_err(Into::into) + } + + #[napi] + pub fn set_plugin_user_metadata( + &mut self, + plugin_metadata: &PluginMetadata, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .set_plugin_user_metadata(plugin_metadata.clone().into()); + Ok(()) + } + + #[napi] + pub fn discard_plugin_user_metadata(&self, plugin: String) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .discard_plugin_user_metadata(&plugin); + Ok(()) + } + + #[napi] + pub fn discard_all_user_metadata(&self) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .discard_all_user_metadata(); + Ok(()) + } +} + +impl From>> for Database { + fn from(value: Arc>) -> Self { + Self(value) + } +} + +#[napi] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Vertex(libloot::Vertex); + +#[napi] +impl Vertex { + #[napi(constructor)] + pub fn new(name: String) -> Self { + Self(libloot::Vertex::new(name)) + } + + #[napi(getter)] + pub fn name(&self) -> &str { + self.0.name() + } + + #[napi(getter)] + pub fn out_edge_type(&self) -> Result, VerboseError> { + self.0 + .out_edge_type() + .map(|e| e.try_into().map_err(Into::into)) + .transpose() + } + + #[napi(setter)] + pub fn set_out_edge_type(&mut self, out_edge_type: EdgeType) { + let out_edge_type = out_edge_type.into(); + self.0.set_out_edge_type(out_edge_type); + } +} + +impl From for Vertex { + fn from(value: libloot::Vertex) -> Self { + Self(value) + } +} + +impl From for libloot::Vertex { + fn from(value: Vertex) -> Self { + value.0 + } +} + +#[napi] +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum EdgeType { + Hardcoded, + MasterFlag, + Master, + MasterlistRequirement, + UserRequirement, + MasterlistLoadAfter, + UserLoadAfter, + MasterlistGroup, + UserGroup, + RecordOverlap, + AssetOverlap, + TieBreak, + BlueprintMaster, +} + +impl TryFrom for EdgeType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::EdgeType) -> Result { + match value { + libloot::EdgeType::Hardcoded => Ok(EdgeType::Hardcoded), + libloot::EdgeType::MasterFlag => Ok(EdgeType::MasterFlag), + libloot::EdgeType::Master => Ok(EdgeType::Master), + libloot::EdgeType::MasterlistRequirement => Ok(EdgeType::MasterlistRequirement), + libloot::EdgeType::UserRequirement => Ok(EdgeType::UserRequirement), + libloot::EdgeType::MasterlistLoadAfter => Ok(EdgeType::MasterlistLoadAfter), + libloot::EdgeType::UserLoadAfter => Ok(EdgeType::UserLoadAfter), + libloot::EdgeType::MasterlistGroup => Ok(EdgeType::MasterlistGroup), + libloot::EdgeType::UserGroup => Ok(EdgeType::UserGroup), + libloot::EdgeType::RecordOverlap => Ok(EdgeType::RecordOverlap), + libloot::EdgeType::AssetOverlap => Ok(EdgeType::AssetOverlap), + libloot::EdgeType::TieBreak => Ok(EdgeType::TieBreak), + libloot::EdgeType::BlueprintMaster => Ok(EdgeType::BlueprintMaster), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl From for libloot::EdgeType { + fn from(value: EdgeType) -> Self { + match value { + EdgeType::Hardcoded => libloot::EdgeType::Hardcoded, + EdgeType::MasterFlag => libloot::EdgeType::MasterFlag, + EdgeType::Master => libloot::EdgeType::Master, + EdgeType::MasterlistRequirement => libloot::EdgeType::MasterlistRequirement, + EdgeType::UserRequirement => libloot::EdgeType::UserRequirement, + EdgeType::MasterlistLoadAfter => libloot::EdgeType::MasterlistLoadAfter, + EdgeType::UserLoadAfter => libloot::EdgeType::UserLoadAfter, + EdgeType::MasterlistGroup => libloot::EdgeType::MasterlistGroup, + EdgeType::UserGroup => libloot::EdgeType::UserGroup, + EdgeType::RecordOverlap => libloot::EdgeType::RecordOverlap, + EdgeType::AssetOverlap => libloot::EdgeType::AssetOverlap, + EdgeType::TieBreak => libloot::EdgeType::TieBreak, + EdgeType::BlueprintMaster => libloot::EdgeType::BlueprintMaster, + } + } +} diff --git a/nodejs/src/error.rs b/nodejs/src/error.rs new file mode 100644 index 00000000..da0ba8aa --- /dev/null +++ b/nodejs/src/error.rs @@ -0,0 +1,63 @@ +use libloot::{ + error::{ + ConditionEvaluationError, DatabaseLockPoisonError, GameHandleCreationError, + GroupsPathError, LoadOrderError, LoadOrderStateError, LoadPluginsError, + MetadataRetrievalError, PluginDataError, SortPluginsError, + }, + metadata::error::{ + LoadMetadataError, MultilingualMessageContentsError, RegexError, WriteMetadataError, + }, +}; +use libloot_ffi_errors::{fmt_error_chain, SystemError, UnsupportedEnumValueError}; + +#[derive(Debug)] +pub struct VerboseError(Box); + +impl std::fmt::Display for VerboseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fmt_error_chain(self.0.as_ref(), f) + } +} + +macro_rules! box_from_error { + ( $from_type:ident, $to_type:ident ) => { + impl From<$from_type> for $to_type { + fn from(value: $from_type) -> Self { + Self(Box::new(value)) + } + } + }; +} + +box_from_error!(GameHandleCreationError, VerboseError); +box_from_error!(UnsupportedEnumValueError, VerboseError); +box_from_error!(DatabaseLockPoisonError, VerboseError); +box_from_error!(LoadPluginsError, VerboseError); +box_from_error!(SortPluginsError, VerboseError); +box_from_error!(LoadOrderStateError, VerboseError); +box_from_error!(LoadOrderError, VerboseError); +box_from_error!(LoadMetadataError, VerboseError); +box_from_error!(WriteMetadataError, VerboseError); +box_from_error!(ConditionEvaluationError, VerboseError); +box_from_error!(GroupsPathError, VerboseError); +box_from_error!(MetadataRetrievalError, VerboseError); +box_from_error!(MultilingualMessageContentsError, VerboseError); +box_from_error!(RegexError, VerboseError); + +impl From for VerboseError { + fn from(value: PluginDataError) -> Self { + Self(Box::new(SystemError::from(value))) + } +} + +impl From for napi::Error { + fn from(value: VerboseError) -> Self { + napi::Error::from_reason(value.to_string()) + } +} + +impl From for napi::JsError { + fn from(value: VerboseError) -> Self { + napi::JsError::from(napi::Error::from(value)) + } +} diff --git a/nodejs/src/game.rs b/nodejs/src/game.rs new file mode 100644 index 00000000..7d15891b --- /dev/null +++ b/nodejs/src/game.rs @@ -0,0 +1,197 @@ +use std::path::Path; + +use libloot_ffi_errors::UnsupportedEnumValueError; +use napi_derive::napi; + +use crate::{database::Database, error::VerboseError, plugin::Plugin}; + +#[allow(non_camel_case_types)] +#[napi] +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum GameType { + tes4, + tes5, + fo3, + fonv, + fo4, + tes5se, + fo4vr, + tes5vr, + tes3, + starfield, + openmw, +} + +impl TryFrom for GameType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::GameType) -> Result { + match value { + libloot::GameType::TES4 => Ok(GameType::tes4), + libloot::GameType::TES5 => Ok(GameType::tes5), + libloot::GameType::FO3 => Ok(GameType::fo3), + libloot::GameType::FONV => Ok(GameType::fonv), + libloot::GameType::FO4 => Ok(GameType::fo4), + libloot::GameType::TES5SE => Ok(GameType::tes5se), + libloot::GameType::FO4VR => Ok(GameType::fo4vr), + libloot::GameType::TES5VR => Ok(GameType::tes5vr), + libloot::GameType::TES3 => Ok(GameType::tes3), + libloot::GameType::Starfield => Ok(GameType::starfield), + libloot::GameType::OpenMW => Ok(GameType::openmw), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl From for libloot::GameType { + fn from(value: GameType) -> Self { + match value { + GameType::tes4 => libloot::GameType::TES4, + GameType::tes5 => libloot::GameType::TES5, + GameType::fo3 => libloot::GameType::FO3, + GameType::fonv => libloot::GameType::FONV, + GameType::fo4 => libloot::GameType::FO4, + GameType::tes5se => libloot::GameType::TES5SE, + GameType::fo4vr => libloot::GameType::FO4VR, + GameType::tes5vr => libloot::GameType::TES5VR, + GameType::tes3 => libloot::GameType::TES3, + GameType::starfield => libloot::GameType::Starfield, + GameType::openmw => libloot::GameType::OpenMW, + } + } +} + +#[napi] +#[derive(Debug)] +pub struct Game(libloot::Game); + +#[napi] +impl Game { + #[napi(constructor)] + pub fn new( + game_type: GameType, + game_path: String, + local_path: Option, + ) -> Result { + match local_path { + Some(local_path) => Ok(Game(libloot::Game::with_local_path( + game_type.into(), + Path::new(&game_path), + Path::new(&local_path), + )?)), + None => Ok(Game(libloot::Game::new( + game_type.into(), + Path::new(&game_path), + )?)), + } + } + + #[napi] + pub fn game_type(&self) -> Result { + self.0.game_type().try_into().map_err(Into::into) + } + + #[napi] + pub fn additional_data_paths(&self) -> Vec { + self.0 + .additional_data_paths() + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + } + + #[napi] + pub fn set_additional_data_paths(&mut self, paths: Vec) -> Result<(), VerboseError> { + self.0.set_additional_data_paths(&as_paths(&paths))?; + Ok(()) + } + + #[napi] + pub fn database(&self) -> Database { + self.0.database().into() + } + + #[napi] + pub fn is_valid_plugin(&self, plugin_path: String) -> bool { + self.0.is_valid_plugin(Path::new(&plugin_path)) + } + + #[napi] + pub fn load_plugins(&mut self, plugin_paths: Vec) -> Result<(), VerboseError> { + self.0.load_plugins(&as_paths(&plugin_paths))?; + Ok(()) + } + + #[napi] + pub fn load_plugin_headers(&mut self, plugin_paths: Vec) -> Result<(), VerboseError> { + self.0.load_plugin_headers(&as_paths(&plugin_paths))?; + Ok(()) + } + + #[napi] + pub fn clear_loaded_plugins(&mut self) { + self.0.clear_loaded_plugins(); + } + + #[napi] + pub fn plugin(&self, plugin_name: String) -> Option { + self.0.plugin(&plugin_name).map(Into::into) + } + + #[napi] + pub fn loaded_plugins(&self) -> Vec { + self.0 + .loaded_plugins() + .into_iter() + .map(Into::into) + .collect() + } + + #[napi] + pub fn sort_plugins(&self, plugin_names: Vec) -> Result, VerboseError> { + Ok(self.0.sort_plugins(&as_strs(&plugin_names))?) + } + + #[napi] + pub fn load_current_load_order_state(&mut self) -> Result<(), VerboseError> { + self.0.load_current_load_order_state()?; + Ok(()) + } + + #[napi] + pub fn is_load_order_ambiguous(&self) -> Result { + Ok(self.0.is_load_order_ambiguous()?) + } + + #[napi] + pub fn active_plugins_file_path(&self) -> String { + self.0 + .active_plugins_file_path() + .to_string_lossy() + .to_string() + } + + #[napi] + pub fn is_plugin_active(&self, plugin_name: String) -> bool { + self.0.is_plugin_active(&plugin_name) + } + + #[napi] + pub fn load_order(&self) -> Vec<&str> { + self.0.load_order() + } + + #[napi] + pub fn set_load_order(&mut self, load_order: Vec) -> Result<(), VerboseError> { + self.0.set_load_order(&as_strs(&load_order))?; + Ok(()) + } +} + +fn as_paths(pathbufs: &[String]) -> Vec<&Path> { + pathbufs.iter().map(Path::new).collect() +} + +fn as_strs(strings: &[String]) -> Vec<&str> { + strings.iter().map(String::as_ref).collect() +} diff --git a/nodejs/src/lib.rs b/nodejs/src/lib.rs index b479ed71..42fdb5fb 100644 --- a/nodejs/src/lib.rs +++ b/nodejs/src/lib.rs @@ -1,9 +1,105 @@ #![deny(clippy::all)] -#[macro_use] -extern crate napi_derive; +mod database; +mod error; +mod game; +mod metadata; +mod plugin; + +use napi::{ + threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode}, + Either, +}; +use napi_derive::napi; + +pub use metadata::select_message_content; #[napi] -pub fn sum(a: i32, b: i32) -> i32 { - a + b +pub fn is_compatible(major: u32, minor: u32, patch: u32) -> bool { + libloot::is_compatible(major, minor, patch) +} + +#[napi] +pub fn libloot_revision() -> String { + libloot::libloot_revision() +} + +#[napi] +pub fn libloot_version() -> String { + libloot::libloot_version() +} + +#[napi] +pub const LIBLOOT_VERSION_MAJOR: u32 = libloot::LIBLOOT_VERSION_MAJOR; + +#[napi] +pub const LIBLOOT_VERSION_MINOR: u32 = libloot::LIBLOOT_VERSION_MINOR; + +#[napi] +pub const LIBLOOT_VERSION_PATCH: u32 = libloot::LIBLOOT_VERSION_PATCH; + +#[napi] +pub enum LogLevel { + Trace, + Debug, + Info, + Warning, + Error, + Fatal, +} + +impl From for libloot::LogLevel { + fn from(value: LogLevel) -> Self { + match value { + LogLevel::Trace => libloot::LogLevel::Trace, + LogLevel::Debug => libloot::LogLevel::Debug, + LogLevel::Info => libloot::LogLevel::Info, + LogLevel::Warning => libloot::LogLevel::Warning, + LogLevel::Error => libloot::LogLevel::Error, + LogLevel::Fatal => libloot::LogLevel::Fatal, + } + } +} + +impl From for LogLevel { + fn from(value: libloot::LogLevel) -> Self { + match value { + libloot::LogLevel::Trace => LogLevel::Trace, + libloot::LogLevel::Debug => LogLevel::Debug, + libloot::LogLevel::Info => LogLevel::Info, + libloot::LogLevel::Warning => LogLevel::Warning, + libloot::LogLevel::Error => LogLevel::Error, + libloot::LogLevel::Fatal => LogLevel::Fatal, + } + } +} + +#[napi] +pub fn set_log_level(level: LogLevel) { + libloot::set_log_level(level.into()); +} + +#[napi(ts_args_type = "callback: (logLevel: LogLevel, message: string) => void")] +pub fn set_logging_callback(callback: napi::JsFunction) -> napi::Result<()> { + let thread_safe_callback: ThreadsafeFunction< + (libloot::LogLevel, String), + ErrorStrategy::Fatal, + > = callback.create_threadsafe_function(0, |ctx| { + let (level, message): (libloot::LogLevel, String) = ctx.value; + + Ok(vec![ + Either::A::(level.into()), + Either::B(message), + ]) + })?; + + let rust_callback = move |level: libloot::LogLevel, message: &str| { + thread_safe_callback.call( + (level, message.to_owned()), + ThreadsafeFunctionCallMode::Blocking, + ); + }; + + libloot::set_logging_callback(rust_callback); + Ok(()) } diff --git a/nodejs/src/metadata.rs b/nodejs/src/metadata.rs new file mode 100644 index 00000000..2dd46df8 --- /dev/null +++ b/nodejs/src/metadata.rs @@ -0,0 +1,739 @@ +use napi::Either; +use napi_derive::napi; + +use crate::error::VerboseError; + +#[napi] +#[derive(Clone)] +#[repr(transparent)] +pub struct Group(libloot::metadata::Group); + +#[napi] +impl Group { + #[napi] + pub fn default_name() -> &'static str { + libloot::metadata::Group::DEFAULT_NAME + } + + #[napi(constructor)] + pub fn new( + name: String, + description: Option, + after_groups: Option>, + ) -> Self { + let mut group = libloot::metadata::Group::new(name); + + if let Some(description) = description { + group.set_description(description); + } + + if let Some(after_groups) = after_groups { + group.set_after_groups(after_groups); + } + + Self(group) + } + + #[napi(getter)] + pub fn name(&self) -> &str { + self.0.name() + } + + #[napi(getter)] + pub fn description(&self) -> Option<&str> { + self.0.description() + } + + #[napi(setter)] + pub fn set_description(&mut self, description: String) { + self.0.set_description(description); + } + + #[napi(getter)] + pub fn after_groups(&self) -> Vec { + self.0.after_groups().to_vec() + } + + #[napi(setter)] + pub fn set_after_groups(&mut self, after_groups: Vec) { + self.0.set_after_groups(after_groups); + } +} + +impl From for Group { + fn from(value: libloot::metadata::Group) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Group { + fn from(value: Group) -> Self { + value.0 + } +} + +#[napi] +#[derive(Clone)] +#[repr(transparent)] +pub struct MessageContent(libloot::metadata::MessageContent); + +#[napi] +impl MessageContent { + #[napi] + pub fn default_language() -> &'static str { + libloot::metadata::MessageContent::DEFAULT_LANGUAGE + } + + #[napi(constructor)] + pub fn new(text: String, language: Option) -> Self { + let mut content = libloot::metadata::MessageContent::new(text); + + if let Some(language) = language { + content.set_language(language); + } + + Self(content) + } + + #[napi(getter)] + pub fn text(&self) -> &str { + self.0.text() + } + + #[napi(getter)] + pub fn language(&self) -> &str { + self.0.language() + } + + #[napi(setter)] + pub fn set_language(&mut self, language: String) { + self.0.set_language(language); + } +} + +impl From for MessageContent { + fn from(value: libloot::metadata::MessageContent) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::MessageContent { + fn from(value: MessageContent) -> Self { + value.0 + } +} + +#[napi] +pub fn select_message_content( + content: Vec<&MessageContent>, + language: String, +) -> Option { + let content: Vec<_> = content.into_iter().cloned().map(Into::into).collect(); + libloot::metadata::select_message_content(&content, &language) + .cloned() + .map(Into::into) +} + +#[napi] +pub enum MessageType { + Say, + Warn, + Error, +} + +impl From for MessageType { + fn from(value: libloot::metadata::MessageType) -> Self { + match value { + libloot::metadata::MessageType::Say => MessageType::Say, + libloot::metadata::MessageType::Warn => MessageType::Warn, + libloot::metadata::MessageType::Error => MessageType::Error, + } + } +} + +impl From for libloot::metadata::MessageType { + fn from(value: MessageType) -> Self { + match value { + MessageType::Say => libloot::metadata::MessageType::Say, + MessageType::Warn => libloot::metadata::MessageType::Warn, + MessageType::Error => libloot::metadata::MessageType::Error, + } + } +} + +#[napi] +#[derive(Clone)] +#[repr(transparent)] +pub struct Message(libloot::metadata::Message); + +#[napi] +impl Message { + #[napi(constructor)] + pub fn new( + message_type: MessageType, + contents: Either>, + condition: Option, + ) -> Result { + let mut message = match contents { + Either::A(c) => libloot::metadata::Message::new(message_type.into(), c), + Either::B(c) => { + let c = c.into_iter().cloned().map(Into::into).collect(); + libloot::metadata::Message::multilingual(message_type.into(), c)? + } + }; + + if let Some(condition) = condition { + message.set_condition(condition); + } + + Ok(Self(message)) + } + + #[napi(getter)] + pub fn message_type(&self) -> MessageType { + self.0.message_type().into() + } + + #[napi(getter)] + pub fn content(&self) -> Vec { + self.0.content().iter().cloned().map(Into::into).collect() + } + + #[napi(getter)] + pub fn condition(&self) -> Option<&str> { + self.0.condition() + } + + #[napi(setter)] + pub fn set_condition(&mut self, condition: String) { + self.0.set_condition(condition); + } +} + +impl From for Message { + fn from(value: libloot::metadata::Message) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Message { + fn from(value: Message) -> Self { + value.0 + } +} + +#[napi] +#[derive(Clone)] +#[repr(transparent)] +pub struct File(libloot::metadata::File); + +#[napi] +impl File { + #[napi(constructor)] + pub fn new( + name: String, + display_name: Option, + detail: Option>, + condition: Option, + constraint: Option, + ) -> Result { + let mut file = libloot::metadata::File::new(name); + + if let Some(display_name) = display_name { + file.set_display_name(display_name); + } + + if let Some(detail) = detail { + let detail = detail.into_iter().cloned().map(Into::into).collect(); + file.set_detail(detail)?; + } + + if let Some(condition) = condition { + file.set_condition(condition); + } + + if let Some(constraint) = constraint { + file.set_constraint(constraint); + } + + Ok(Self(file)) + } + + #[napi(getter)] + pub fn name(&self) -> Filename { + self.0.name().clone().into() + } + + #[napi(getter)] + pub fn display_name(&self) -> Option<&str> { + self.0.display_name() + } + + #[napi(setter)] + pub fn set_display_name(&mut self, description: String) { + self.0.set_display_name(description); + } + + #[napi(getter)] + pub fn detail(&self) -> Vec { + self.0.detail().iter().cloned().map(Into::into).collect() + } + + #[napi(setter)] + pub fn set_detail(&mut self, detail: Vec<&MessageContent>) -> Result<(), VerboseError> { + let detail = detail.into_iter().cloned().map(Into::into).collect(); + self.0.set_detail(detail)?; + Ok(()) + } + + #[napi(getter)] + pub fn condition(&self) -> Option<&str> { + self.0.condition() + } + + #[napi(setter)] + pub fn set_condition(&mut self, condition: String) { + self.0.set_condition(condition); + } + + #[napi(getter)] + pub fn constraint(&self) -> Option<&str> { + self.0.constraint() + } + + #[napi(setter)] + pub fn set_constraint(&mut self, constraint: String) { + self.0.set_constraint(constraint); + } +} + +impl From for File { + fn from(value: libloot::metadata::File) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::File { + fn from(value: File) -> Self { + value.0 + } +} + +#[napi] +#[repr(transparent)] +pub struct Filename(libloot::metadata::Filename); + +#[napi] +impl Filename { + #[napi(constructor)] + pub fn new(name: String) -> Self { + Self(libloot::metadata::Filename::new(name)) + } + + #[napi] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl From for Filename { + fn from(value: libloot::metadata::Filename) -> Self { + Self(value) + } +} + +#[napi] +#[derive(Clone)] +#[repr(transparent)] +pub struct PluginCleaningData(libloot::metadata::PluginCleaningData); + +#[napi] +impl PluginCleaningData { + #[napi(constructor)] + pub fn new( + crc: u32, + cleaning_utility: String, + itm_count: Option, + deleted_reference_count: Option, + deleted_navmesh_count: Option, + detail: Option>, + ) -> Result { + let mut data = libloot::metadata::PluginCleaningData::new(crc, cleaning_utility); + + if let Some(count) = itm_count { + data.set_itm_count(count); + } + + if let Some(count) = deleted_reference_count { + data.set_deleted_reference_count(count); + } + + if let Some(count) = deleted_navmesh_count { + data.set_deleted_navmesh_count(count); + } + + if let Some(detail) = detail { + let detail = detail.into_iter().cloned().map(Into::into).collect(); + data.set_detail(detail)?; + } + + Ok(Self(data)) + } + + #[napi(getter)] + pub fn crc(&self) -> u32 { + self.0.crc() + } + + #[napi(getter)] + pub fn itm_count(&self) -> u32 { + self.0.itm_count() + } + + #[napi(setter)] + pub fn set_itm_count(&mut self, count: u32) { + self.0.set_itm_count(count); + } + + #[napi(getter)] + pub fn deleted_reference_count(&self) -> u32 { + self.0.deleted_reference_count() + } + + #[napi(setter)] + pub fn set_deleted_reference_count(&mut self, count: u32) { + self.0.set_deleted_reference_count(count); + } + + #[napi(getter)] + pub fn deleted_navmesh_count(&self) -> u32 { + self.0.deleted_navmesh_count() + } + + #[napi(setter)] + pub fn set_deleted_navmesh_count(&mut self, count: u32) { + self.0.set_deleted_navmesh_count(count); + } + + #[napi(getter)] + pub fn cleaning_utility(&self) -> &str { + self.0.cleaning_utility() + } + + #[napi(getter)] + pub fn detail(&self) -> Vec { + self.0.detail().iter().cloned().map(Into::into).collect() + } + + #[napi(setter)] + pub fn set_detail(&mut self, detail: Vec<&MessageContent>) -> Result<(), VerboseError> { + let detail = detail.into_iter().cloned().map(Into::into).collect(); + self.0.set_detail(detail)?; + Ok(()) + } +} + +impl From for PluginCleaningData { + fn from(value: libloot::metadata::PluginCleaningData) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::PluginCleaningData { + fn from(value: PluginCleaningData) -> Self { + value.0 + } +} + +#[napi] +pub enum TagSuggestion { + Addition, + Removal, +} + +impl From for libloot::metadata::TagSuggestion { + fn from(value: TagSuggestion) -> Self { + match value { + TagSuggestion::Addition => libloot::metadata::TagSuggestion::Addition, + TagSuggestion::Removal => libloot::metadata::TagSuggestion::Removal, + } + } +} + +#[napi] +#[derive(Clone)] +#[repr(transparent)] +pub struct Tag(libloot::metadata::Tag); + +#[napi] +impl Tag { + #[napi(constructor)] + pub fn new(name: String, suggestion: TagSuggestion, condition: Option) -> Self { + let mut tag = libloot::metadata::Tag::new(name, suggestion.into()); + + if let Some(condition) = condition { + tag.set_condition(condition); + } + + Self(tag) + } + + #[napi(getter)] + pub fn name(&self) -> &str { + self.0.name() + } + + #[napi(getter)] + pub fn is_addition(&self) -> bool { + self.0.is_addition() + } + + #[napi(getter)] + pub fn condition(&self) -> Option<&str> { + self.0.condition() + } + + #[napi(setter)] + pub fn set_condition(&mut self, condition: String) { + self.0.set_condition(condition); + } +} + +impl From for Tag { + fn from(value: libloot::metadata::Tag) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Tag { + fn from(value: Tag) -> Self { + value.0 + } +} + +#[napi] +#[derive(Clone)] +#[repr(transparent)] +pub struct Location(libloot::metadata::Location); + +#[napi] +impl Location { + #[napi(constructor)] + pub fn new(url: String, name: Option) -> Self { + let mut location = libloot::metadata::Location::new(url); + + if let Some(name) = name { + location.set_name(name); + } + + Self(location) + } + + #[napi(getter)] + pub fn url(&self) -> &str { + self.0.url() + } + + #[napi(getter)] + pub fn name(&self) -> Option<&str> { + self.0.name() + } + + #[napi(setter)] + pub fn set_name(&mut self, name: String) { + self.0.set_name(name); + } +} + +impl From for Location { + fn from(value: libloot::metadata::Location) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Location { + fn from(value: Location) -> Self { + value.0 + } +} + +#[napi] +#[derive(Clone)] +#[repr(transparent)] +pub struct PluginMetadata(libloot::metadata::PluginMetadata); + +#[napi] +impl PluginMetadata { + #[napi(constructor)] + pub fn new(name: String) -> Result { + Ok(Self(libloot::metadata::PluginMetadata::new(&name)?)) + } + + #[napi(getter)] + pub fn name(&self) -> &str { + self.0.name() + } + + #[napi(getter)] + pub fn group(&self) -> Option<&str> { + self.0.group() + } + + #[napi(getter)] + pub fn load_after_files(&self) -> Vec { + self.0 + .load_after_files() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[napi(getter)] + pub fn requirements(&self) -> Vec { + self.0 + .requirements() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[napi(getter)] + pub fn incompatibilities(&self) -> Vec { + self.0 + .incompatibilities() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[napi(getter)] + pub fn messages(&self) -> Vec { + self.0.messages().iter().cloned().map(Into::into).collect() + } + + #[napi(getter)] + pub fn tags(&self) -> Vec { + self.0.tags().iter().cloned().map(Into::into).collect() + } + + #[napi(getter)] + pub fn dirty_info(&self) -> Vec { + self.0 + .dirty_info() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[napi(getter)] + pub fn clean_info(&self) -> Vec { + self.0 + .clean_info() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[napi(getter)] + pub fn locations(&self) -> Vec { + self.0.locations().iter().cloned().map(Into::into).collect() + } + + #[napi(setter)] + pub fn set_group(&mut self, group: Option) { + match group { + Some(g) => self.0.set_group(g), + None => self.0.unset_group(), + } + } + + #[napi(setter)] + pub fn set_load_after_files(&mut self, value: Vec<&File>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_load_after_files(value); + } + + #[napi(setter)] + pub fn set_requirements(&mut self, value: Vec<&File>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_requirements(value); + } + + #[napi(setter)] + pub fn set_incompatibilities(&mut self, value: Vec<&File>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_incompatibilities(value); + } + + #[napi(setter)] + pub fn set_messages(&mut self, value: Vec<&Message>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_messages(value); + } + + #[napi(setter)] + pub fn set_tags(&mut self, value: Vec<&Tag>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_tags(value); + } + + #[napi(setter)] + pub fn set_dirty_info(&mut self, value: Vec<&PluginCleaningData>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_dirty_info(value); + } + + #[napi(setter)] + pub fn set_clean_info(&mut self, value: Vec<&PluginCleaningData>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_clean_info(value); + } + + #[napi(setter)] + pub fn set_locations(&mut self, value: Vec<&Location>) { + let value = value.into_iter().cloned().map(Into::into).collect(); + self.0.set_locations(value); + } + + #[napi] + pub fn merge_metadata(&mut self, other: &PluginMetadata) { + self.0.merge_metadata(&other.0); + } + + #[napi] + pub fn has_name_only(&self) -> bool { + self.0.has_name_only() + } + + #[napi] + pub fn is_regex_plugin(&self) -> bool { + self.0.is_regex_plugin() + } + + #[napi] + pub fn name_matches(&self, other_name: String) -> bool { + self.0.name_matches(&other_name) + } + + #[napi] + pub fn as_yaml(&self) -> String { + self.0.as_yaml() + } +} + +impl From for PluginMetadata { + fn from(value: libloot::metadata::PluginMetadata) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::PluginMetadata { + fn from(value: PluginMetadata) -> Self { + value.0 + } +} diff --git a/nodejs/src/plugin.rs b/nodejs/src/plugin.rs new file mode 100644 index 00000000..876e0b68 --- /dev/null +++ b/nodejs/src/plugin.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; + +use napi_derive::napi; + +use crate::error::VerboseError; + +#[napi] +#[derive(Clone, Debug, Eq, PartialEq)] +#[repr(transparent)] +pub struct Plugin(Arc); + +#[napi] +impl Plugin { + #[napi] + pub fn name(&self) -> &str { + self.0.name() + } + + #[napi] + pub fn header_version(&self) -> Option { + self.0.header_version() + } + + #[napi] + pub fn version(&self) -> Option<&str> { + self.0.version() + } + + #[napi] + pub fn masters(&self) -> Result, VerboseError> { + Ok(self.0.masters()?) + } + + #[napi] + pub fn bash_tags(&self) -> Vec { + self.0.bash_tags().to_vec() + } + + #[napi] + pub fn crc(&self) -> Option { + self.0.crc() + } + + #[napi] + pub fn is_master(&self) -> bool { + self.0.is_master() + } + + #[napi] + pub fn is_light_plugin(&self) -> bool { + self.0.is_light_plugin() + } + + #[napi] + pub fn is_medium_plugin(&self) -> bool { + self.0.is_medium_plugin() + } + + #[napi] + pub fn is_update_plugin(&self) -> bool { + self.0.is_update_plugin() + } + + #[napi] + pub fn is_blueprint_plugin(&self) -> bool { + self.0.is_blueprint_plugin() + } + + #[napi] + pub fn is_valid_as_light_plugin(&self) -> Result { + Ok(self.0.is_valid_as_light_plugin()?) + } + + #[napi] + pub fn is_valid_as_medium_plugin(&self) -> Result { + Ok(self.0.is_valid_as_medium_plugin()?) + } + + #[napi] + pub fn is_valid_as_update_plugin(&self) -> Result { + Ok(self.0.is_valid_as_update_plugin()?) + } + + #[napi] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + #[napi] + pub fn loads_archive(&self) -> bool { + self.0.loads_archive() + } + + #[napi] + pub fn do_records_overlap(&self, plugin: &Plugin) -> Result { + Ok(self.0.do_records_overlap(&plugin.0)?) + } +} + +impl From> for Plugin { + fn from(value: Arc) -> Self { + Self(value) + } +} diff --git a/nodejs/yarn.lock b/nodejs/yarn.lock index edd0375e..74679bca 100644 --- a/nodejs/yarn.lock +++ b/nodejs/yarn.lock @@ -1,1373 +1,1026 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 -__metadata: - version: 8 - cacheKey: 10c0 -"@isaacs/fs-minipass@npm:^4.0.0": - version: 4.0.1 - resolution: "@isaacs/fs-minipass@npm:4.0.1" +"@isaacs/fs-minipass@^4.0.0": + version "4.0.1" + resolved "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz" + integrity sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w== dependencies: - minipass: "npm:^7.0.4" - checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 - languageName: node - linkType: hard + minipass "^7.0.4" -"@mapbox/node-pre-gyp@npm:^2.0.0-rc.0": - version: 2.0.0 - resolution: "@mapbox/node-pre-gyp@npm:2.0.0" +"@mapbox/node-pre-gyp@^2.0.0-rc.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.0.tgz" + integrity sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg== dependencies: - consola: "npm:^3.2.3" - detect-libc: "npm:^2.0.0" - https-proxy-agent: "npm:^7.0.5" - node-fetch: "npm:^2.6.7" - nopt: "npm:^8.0.0" - semver: "npm:^7.5.3" - tar: "npm:^7.4.0" - bin: - node-pre-gyp: bin/node-pre-gyp - checksum: 10c0/7d874c7f6f5560a87be7207f28d9a4e53b750085a82167608fd573aab8073645e95b3608f69e244df0e1d24e90a66525aeae708aba82ca73ff668ed0ab6abda6 - languageName: node - linkType: hard + consola "^3.2.3" + detect-libc "^2.0.0" + https-proxy-agent "^7.0.5" + node-fetch "^2.6.7" + nopt "^8.0.0" + semver "^7.5.3" + tar "^7.4.0" -"@napi-rs/cli@npm:^2.18.4": - version: 2.18.4 - resolution: "@napi-rs/cli@npm:2.18.4" - bin: - napi: scripts/index.js - checksum: 10c0/cb80bc86687ba01077a0faa8d38198bc632b7caacf3a42ec42c3ec04700084ca26be9eec13bf7ac7da90e6afb46c65a39de8a86825a53b74dec2f2f459c0a7ea - languageName: node - linkType: hard +"@napi-rs/cli@^2.18.4": + version "2.18.4" + resolved "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz" + integrity sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg== -"@nodelib/fs.scandir@npm:2.1.5": - version: 2.1.5 - resolution: "@nodelib/fs.scandir@npm:2.1.5" +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== dependencies: - "@nodelib/fs.stat": "npm:2.0.5" - run-parallel: "npm:^1.1.9" - checksum: 10c0/732c3b6d1b1e967440e65f284bd06e5821fedf10a1bea9ed2bb75956ea1f30e08c44d3def9d6a230666574edbaf136f8cfd319c14fd1f87c66e6a44449afb2eb - languageName: node - linkType: hard + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" -"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2": - version: 2.0.5 - resolution: "@nodelib/fs.stat@npm:2.0.5" - checksum: 10c0/88dafe5e3e29a388b07264680dc996c17f4bda48d163a9d4f5c1112979f0ce8ec72aa7116122c350b4e7976bc5566dc3ddb579be1ceaacc727872eb4ed93926d - languageName: node - linkType: hard +"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": + version "2.0.5" + resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== -"@nodelib/fs.walk@npm:^1.2.3": - version: 1.2.8 - resolution: "@nodelib/fs.walk@npm:1.2.8" +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== dependencies: - "@nodelib/fs.scandir": "npm:2.1.5" - fastq: "npm:^1.6.0" - checksum: 10c0/db9de047c3bb9b51f9335a7bb46f4fcfb6829fb628318c12115fbaf7d369bfce71c15b103d1fc3b464812d936220ee9bc1c8f762d032c9f6be9acc99249095b1 - languageName: node - linkType: hard + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" -"@rollup/pluginutils@npm:^5.1.3": - version: 5.1.4 - resolution: "@rollup/pluginutils@npm:5.1.4" +"@rollup/pluginutils@^5.1.3": + version "5.1.4" + resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz" + integrity sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ== dependencies: - "@types/estree": "npm:^1.0.0" - estree-walker: "npm:^2.0.2" - picomatch: "npm:^4.0.2" - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - checksum: 10c0/6d58fbc6f1024eb4b087bc9bf59a1d655a8056a60c0b4021d3beaeec3f0743503f52467fd89d2cf0e7eccf2831feb40a05ad541a17637ea21ba10b21c2004deb - languageName: node - linkType: hard + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^4.0.2" -"@sindresorhus/merge-streams@npm:^2.1.0": - version: 2.3.0 - resolution: "@sindresorhus/merge-streams@npm:2.3.0" - checksum: 10c0/69ee906f3125fb2c6bb6ec5cdd84e8827d93b49b3892bce8b62267116cc7e197b5cccf20c160a1d32c26014ecd14470a72a5e3ee37a58f1d6dadc0db1ccf3894 - languageName: node - linkType: hard +"@sindresorhus/merge-streams@^2.1.0": + version "2.3.0" + resolved "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz" + integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg== -"@types/estree@npm:^1.0.0": - version: 1.0.7 - resolution: "@types/estree@npm:1.0.7" - checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c - languageName: node - linkType: hard +"@types/estree@^1.0.0": + version "1.0.7" + resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz" + integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== -"@vercel/nft@npm:^0.27.5": - version: 0.27.10 - resolution: "@vercel/nft@npm:0.27.10" +"@vercel/nft@^0.27.5": + version "0.27.10" + resolved "https://registry.npmjs.org/@vercel/nft/-/nft-0.27.10.tgz" + integrity sha512-zbaF9Wp/NsZtKLE4uVmL3FyfFwlpDyuymQM1kPbeT0mVOHKDQQNjnnfslB3REg3oZprmNFJuh3pkHBk2qAaizg== dependencies: - "@mapbox/node-pre-gyp": "npm:^2.0.0-rc.0" - "@rollup/pluginutils": "npm:^5.1.3" - acorn: "npm:^8.6.0" - acorn-import-attributes: "npm:^1.9.5" - async-sema: "npm:^3.1.1" - bindings: "npm:^1.4.0" - estree-walker: "npm:2.0.2" - glob: "npm:^7.1.3" - graceful-fs: "npm:^4.2.9" - node-gyp-build: "npm:^4.2.2" - picomatch: "npm:^4.0.2" - resolve-from: "npm:^5.0.0" - bin: - nft: out/cli.js - checksum: 10c0/8fec187317d8e390e00f5cacc07839add40a67977dfce2edab0844b51821d012bb807d692c08b0ede03e767e735fb592e6c71d6dd2379ba4dc298a96df09e3d6 - languageName: node - linkType: hard + "@mapbox/node-pre-gyp" "^2.0.0-rc.0" + "@rollup/pluginutils" "^5.1.3" + acorn "^8.6.0" + acorn-import-attributes "^1.9.5" + async-sema "^3.1.1" + bindings "^1.4.0" + estree-walker "2.0.2" + glob "^7.1.3" + graceful-fs "^4.2.9" + node-gyp-build "^4.2.2" + picomatch "^4.0.2" + resolve-from "^5.0.0" -"abbrev@npm:^3.0.0": - version: 3.0.1 - resolution: "abbrev@npm:3.0.1" - checksum: 10c0/21ba8f574ea57a3106d6d35623f2c4a9111d9ee3e9a5be47baed46ec2457d2eac46e07a5c4a60186f88cb98abbe3e24f2d4cca70bc2b12f1692523e2209a9ccf - languageName: node - linkType: hard +abbrev@^3.0.0: + version "3.0.1" + resolved "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz" + integrity sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg== -"acorn-import-attributes@npm:^1.9.5": - version: 1.9.5 - resolution: "acorn-import-attributes@npm:1.9.5" - peerDependencies: - acorn: ^8 - checksum: 10c0/5926eaaead2326d5a86f322ff1b617b0f698aa61dc719a5baa0e9d955c9885cc71febac3fb5bacff71bbf2c4f9c12db2056883c68c53eb962c048b952e1e013d - languageName: node - linkType: hard +acorn-import-attributes@^1.9.5: + version "1.9.5" + resolved "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz" + integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== -"acorn-walk@npm:^8.3.4": - version: 8.3.4 - resolution: "acorn-walk@npm:8.3.4" +acorn-walk@^8.3.4: + version "8.3.4" + resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz" + integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== dependencies: - acorn: "npm:^8.11.0" - checksum: 10c0/76537ac5fb2c37a64560feaf3342023dadc086c46da57da363e64c6148dc21b57d49ace26f949e225063acb6fb441eabffd89f7a3066de5ad37ab3e328927c62 - languageName: node - linkType: hard + acorn "^8.11.0" -"acorn@npm:^8.11.0, acorn@npm:^8.13.0, acorn@npm:^8.6.0": - version: 8.14.1 - resolution: "acorn@npm:8.14.1" - bin: - acorn: bin/acorn - checksum: 10c0/dbd36c1ed1d2fa3550140000371fcf721578095b18777b85a79df231ca093b08edc6858d75d6e48c73e431c174dcf9214edbd7e6fa5911b93bd8abfa54e47123 - languageName: node - linkType: hard +acorn@^8, acorn@^8.11.0, acorn@^8.13.0, acorn@^8.6.0: + version "8.14.1" + resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz" + integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== -"agent-base@npm:^7.1.2": - version: 7.1.3 - resolution: "agent-base@npm:7.1.3" - checksum: 10c0/6192b580c5b1d8fb399b9c62bf8343d76654c2dd62afcb9a52b2cf44a8b6ace1e3b704d3fe3547d91555c857d3df02603341ff2cb961b9cfe2b12f9f3c38ee11 - languageName: node - linkType: hard +agent-base@^7.1.2: + version "7.1.3" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz" + integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw== -"ansi-regex@npm:^5.0.1": - version: 5.0.1 - resolution: "ansi-regex@npm:5.0.1" - checksum: 10c0/9a64bb8627b434ba9327b60c027742e5d17ac69277960d041898596271d992d4d52ba7267a63ca10232e29f6107fc8a835f6ce8d719b88c5f8493f8254813737 - languageName: node - linkType: hard +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -"ansi-regex@npm:^6.0.1": - version: 6.1.0 - resolution: "ansi-regex@npm:6.1.0" - checksum: 10c0/a91daeddd54746338478eef88af3439a7edf30f8e23196e2d6ed182da9add559c601266dbef01c2efa46a958ad6f1f8b176799657616c702b5b02e799e7fd8dc - languageName: node - linkType: hard +ansi-regex@^6.0.1: + version "6.1.0" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz" + integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== -"ansi-styles@npm:^4.0.0": - version: 4.3.0 - resolution: "ansi-styles@npm:4.3.0" +ansi-styles@^4.0.0: + version "4.3.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== dependencies: - color-convert: "npm:^2.0.1" - checksum: 10c0/895a23929da416f2bd3de7e9cb4eabd340949328ab85ddd6e484a637d8f6820d485f53933446f5291c3b760cbc488beb8e88573dd0f9c7daf83dccc8fe81b041 - languageName: node - linkType: hard + color-convert "^2.0.1" -"ansi-styles@npm:^6.0.0, ansi-styles@npm:^6.2.1": - version: 6.2.1 - resolution: "ansi-styles@npm:6.2.1" - checksum: 10c0/5d1ec38c123984bcedd996eac680d548f31828bd679a66db2bdf11844634dde55fec3efa9c6bb1d89056a5e79c1ac540c4c784d592ea1d25028a92227d2f2d5c - languageName: node - linkType: hard +ansi-styles@^6.0.0, ansi-styles@^6.2.1: + version "6.2.1" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== -"argparse@npm:^1.0.7": - version: 1.0.10 - resolution: "argparse@npm:1.0.10" +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== dependencies: - sprintf-js: "npm:~1.0.2" - checksum: 10c0/b2972c5c23c63df66bca144dbc65d180efa74f25f8fd9b7d9a0a6c88ae839db32df3d54770dcb6460cf840d232b60695d1a6b1053f599d84e73f7437087712de - languageName: node - linkType: hard + sprintf-js "~1.0.2" -"array-find-index@npm:^1.0.1": - version: 1.0.2 - resolution: "array-find-index@npm:1.0.2" - checksum: 10c0/86b9485c74ddd324feab807e10a6de3f9c1683856267236fac4bb4d4667ada6463e106db3f6c540ae6b720e0442b590ec701d13676df4c6af30ebf4da09b4f57 - languageName: node - linkType: hard +array-find-index@^1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz" + integrity sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw== -"arrgv@npm:^1.0.2": - version: 1.0.2 - resolution: "arrgv@npm:1.0.2" - checksum: 10c0/7e6e782e6b749923ac7cbc4048ef6fe0844c4a59bfc8932fcd4c44566ba25eed46501f94dd7cf3c7297da88f3f599ca056bfb77d0c2484aebc92f04239f69124 - languageName: node - linkType: hard +arrgv@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/arrgv/-/arrgv-1.0.2.tgz" + integrity sha512-a4eg4yhp7mmruZDQFqVMlxNRFGi/i1r87pt8SDHy0/I8PqSXoUTlWZRdAZo0VXgvEARcujbtTk8kiZRi1uDGRw== -"arrify@npm:^3.0.0": - version: 3.0.0 - resolution: "arrify@npm:3.0.0" - checksum: 10c0/2e26601b8486f29780f1f70f7ac05a226755814c2a3ab42e196748f650af1dc310cd575a11dd4b9841c70fd7460b2dd2b8fe6fb7a3375878e2660706efafa58e - languageName: node - linkType: hard +arrify@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/arrify/-/arrify-3.0.0.tgz" + integrity sha512-tLkvA81vQG/XqE2mjDkGQHoOINtMHtysSnemrmoGe6PydDPMRbVugqyk4A6V/WDWEfm3l+0d8anA9r8cv/5Jaw== -"async-sema@npm:^3.1.1": - version: 3.1.1 - resolution: "async-sema@npm:3.1.1" - checksum: 10c0/a16da9f7f2dbdd00a969bf264b7ad331b59df3eac2b38f529b881c5cc8662594e68ed096d927ec2aabdc13454379cdc6d677bcdb0a3d2db338fb4be17957832b - languageName: node - linkType: hard +async-sema@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz" + integrity sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg== -"ava@npm:^6.0.1": - version: 6.2.0 - resolution: "ava@npm:6.2.0" +ava@^6.0.1: + version "6.2.0" + resolved "https://registry.npmjs.org/ava/-/ava-6.2.0.tgz" + integrity sha512-+GZk5PbyepjiO/68hzCZCUepQOQauKfNnI7sA4JukBTg97jD7E+tDKEA7OhGOGr6EorNNMM9+jqvgHVOTOzG4w== dependencies: - "@vercel/nft": "npm:^0.27.5" - acorn: "npm:^8.13.0" - acorn-walk: "npm:^8.3.4" - ansi-styles: "npm:^6.2.1" - arrgv: "npm:^1.0.2" - arrify: "npm:^3.0.0" - callsites: "npm:^4.2.0" - cbor: "npm:^9.0.2" - chalk: "npm:^5.3.0" - chunkd: "npm:^2.0.1" - ci-info: "npm:^4.0.0" - ci-parallel-vars: "npm:^1.0.1" - cli-truncate: "npm:^4.0.0" - code-excerpt: "npm:^4.0.0" - common-path-prefix: "npm:^3.0.0" - concordance: "npm:^5.0.4" - currently-unhandled: "npm:^0.4.1" - debug: "npm:^4.3.7" - emittery: "npm:^1.0.3" - figures: "npm:^6.1.0" - globby: "npm:^14.0.2" - ignore-by-default: "npm:^2.1.0" - indent-string: "npm:^5.0.0" - is-plain-object: "npm:^5.0.0" - is-promise: "npm:^4.0.0" - matcher: "npm:^5.0.0" - memoize: "npm:^10.0.0" - ms: "npm:^2.1.3" - p-map: "npm:^7.0.2" - package-config: "npm:^5.0.0" - picomatch: "npm:^4.0.2" - plur: "npm:^5.1.0" - pretty-ms: "npm:^9.1.0" - resolve-cwd: "npm:^3.0.0" - stack-utils: "npm:^2.0.6" - strip-ansi: "npm:^7.1.0" - supertap: "npm:^3.0.1" - temp-dir: "npm:^3.0.0" - write-file-atomic: "npm:^6.0.0" - yargs: "npm:^17.7.2" - peerDependencies: - "@ava/typescript": "*" - peerDependenciesMeta: - "@ava/typescript": - optional: true - bin: - ava: entrypoints/cli.mjs - checksum: 10c0/25a37413c9ee1b5322dc5a266f546236ea4b52e5c04ae4b52a7b26db9263eebe2dbcda687bf4d464867e558e9148e4567aa09a7ec91d46e3218ab93204e3c653 - languageName: node - linkType: hard + "@vercel/nft" "^0.27.5" + acorn "^8.13.0" + acorn-walk "^8.3.4" + ansi-styles "^6.2.1" + arrgv "^1.0.2" + arrify "^3.0.0" + callsites "^4.2.0" + cbor "^9.0.2" + chalk "^5.3.0" + chunkd "^2.0.1" + ci-info "^4.0.0" + ci-parallel-vars "^1.0.1" + cli-truncate "^4.0.0" + code-excerpt "^4.0.0" + common-path-prefix "^3.0.0" + concordance "^5.0.4" + currently-unhandled "^0.4.1" + debug "^4.3.7" + emittery "^1.0.3" + figures "^6.1.0" + globby "^14.0.2" + ignore-by-default "^2.1.0" + indent-string "^5.0.0" + is-plain-object "^5.0.0" + is-promise "^4.0.0" + matcher "^5.0.0" + memoize "^10.0.0" + ms "^2.1.3" + p-map "^7.0.2" + package-config "^5.0.0" + picomatch "^4.0.2" + plur "^5.1.0" + pretty-ms "^9.1.0" + resolve-cwd "^3.0.0" + stack-utils "^2.0.6" + strip-ansi "^7.1.0" + supertap "^3.0.1" + temp-dir "^3.0.0" + write-file-atomic "^6.0.0" + yargs "^17.7.2" -"balanced-match@npm:^1.0.0": - version: 1.0.2 - resolution: "balanced-match@npm:1.0.2" - checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee - languageName: node - linkType: hard +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -"bindings@npm:^1.4.0": - version: 1.5.0 - resolution: "bindings@npm:1.5.0" +bindings@^1.4.0: + version "1.5.0" + resolved "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== dependencies: - file-uri-to-path: "npm:1.0.0" - checksum: 10c0/3dab2491b4bb24124252a91e656803eac24292473e56554e35bbfe3cc1875332cfa77600c3bac7564049dc95075bf6fcc63a4609920ff2d64d0fe405fcf0d4ba - languageName: node - linkType: hard + file-uri-to-path "1.0.0" -"blueimp-md5@npm:^2.10.0": - version: 2.19.0 - resolution: "blueimp-md5@npm:2.19.0" - checksum: 10c0/85d04343537dd99a288c62450341dcce7380d3454c81f8e5a971ddd80307d6f9ef51b5b92ad7d48aaaa92fd6d3a1f6b2f4fada068faae646887f7bfabc17a346 - languageName: node - linkType: hard +blueimp-md5@^2.10.0: + version "2.19.0" + resolved "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz" + integrity sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w== -"brace-expansion@npm:^1.1.7": - version: 1.1.11 - resolution: "brace-expansion@npm:1.1.11" +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== dependencies: - balanced-match: "npm:^1.0.0" - concat-map: "npm:0.0.1" - checksum: 10c0/695a56cd058096a7cb71fb09d9d6a7070113c7be516699ed361317aca2ec169f618e28b8af352e02ab4233fb54eb0168460a40dc320bab0034b36ab59aaad668 - languageName: node - linkType: hard + balanced-match "^1.0.0" + concat-map "0.0.1" -"braces@npm:^3.0.3": - version: 3.0.3 - resolution: "braces@npm:3.0.3" +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== dependencies: - fill-range: "npm:^7.1.1" - checksum: 10c0/7c6dfd30c338d2997ba77500539227b9d1f85e388a5f43220865201e407e076783d0881f2d297b9f80951b4c957fcf0b51c1d2d24227631643c3f7c284b0aa04 - languageName: node - linkType: hard + fill-range "^7.1.1" -"callsites@npm:^4.2.0": - version: 4.2.0 - resolution: "callsites@npm:4.2.0" - checksum: 10c0/8f7e269ec09fc0946bb22d838a8bc7932e1909ab4a833b964749f4d0e8bdeaa1f253287c4f911f61781f09620b6925ccd19a5ea4897489c4e59442c660c312a3 - languageName: node - linkType: hard +callsites@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/callsites/-/callsites-4.2.0.tgz" + integrity sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ== -"cbor@npm:^9.0.2": - version: 9.0.2 - resolution: "cbor@npm:9.0.2" +cbor@^9.0.2: + version "9.0.2" + resolved "https://registry.npmjs.org/cbor/-/cbor-9.0.2.tgz" + integrity sha512-JPypkxsB10s9QOWwa6zwPzqE1Md3vqpPc+cai4sAecuCsRyAtAl/pMyhPlMbT/xtPnm2dznJZYRLui57qiRhaQ== dependencies: - nofilter: "npm:^3.1.0" - checksum: 10c0/709d4378067e663107b3d63a02d123a7b33e28946b4c5cc40c102f2f0ba13b072a79adc4369bb87a4e743399fce45deec30463fc84d363ab7cb39192d0fe5f30 - languageName: node - linkType: hard + nofilter "^3.1.0" -"chalk@npm:^5.3.0": - version: 5.4.1 - resolution: "chalk@npm:5.4.1" - checksum: 10c0/b23e88132c702f4855ca6d25cb5538b1114343e41472d5263ee8a37cccfccd9c4216d111e1097c6a27830407a1dc81fecdf2a56f2c63033d4dbbd88c10b0dcef - languageName: node - linkType: hard +chalk@^5.3.0: + version "5.4.1" + resolved "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz" + integrity sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w== -"chownr@npm:^3.0.0": - version: 3.0.0 - resolution: "chownr@npm:3.0.0" - checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 - languageName: node - linkType: hard +chownr@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz" + integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g== -"chunkd@npm:^2.0.1": - version: 2.0.1 - resolution: "chunkd@npm:2.0.1" - checksum: 10c0/4e0c5aac6048ecedfa4cd0a5f6c4f010c70a7b7645aeca7bfeb47cb0733c3463054f0ced3f2667b2e0e67edd75d68a8e05481b01115ba3f8a952a93026254504 - languageName: node - linkType: hard +chunkd@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/chunkd/-/chunkd-2.0.1.tgz" + integrity sha512-7d58XsFmOq0j6el67Ug9mHf9ELUXsQXYJBkyxhH/k+6Ke0qXRnv0kbemx+Twc6fRJ07C49lcbdgm9FL1Ei/6SQ== -"ci-info@npm:^4.0.0": - version: 4.2.0 - resolution: "ci-info@npm:4.2.0" - checksum: 10c0/37a2f4b6a213a5cf835890eb0241f0d5b022f6cfefde58a69e9af8e3a0e71e06d6ad7754b0d4efb9cd2613e58a7a33996d71b56b0d04242722e86666f3f3d058 - languageName: node - linkType: hard +ci-info@^4.0.0: + version "4.2.0" + resolved "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz" + integrity sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg== -"ci-parallel-vars@npm:^1.0.1": - version: 1.0.1 - resolution: "ci-parallel-vars@npm:1.0.1" - checksum: 10c0/80952f699cbbc146092b077b4f3e28d085620eb4e6be37f069b4dbb3db0ee70e8eec3beef4ebe70ff60631e9fc743b9d0869678489f167442cac08b260e5ac08 - languageName: node - linkType: hard +ci-parallel-vars@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/ci-parallel-vars/-/ci-parallel-vars-1.0.1.tgz" + integrity sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg== -"cli-truncate@npm:^4.0.0": - version: 4.0.0 - resolution: "cli-truncate@npm:4.0.0" +cli-truncate@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz" + integrity sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA== dependencies: - slice-ansi: "npm:^5.0.0" - string-width: "npm:^7.0.0" - checksum: 10c0/d7f0b73e3d9b88cb496e6c086df7410b541b56a43d18ade6a573c9c18bd001b1c3fba1ad578f741a4218fdc794d042385f8ac02c25e1c295a2d8b9f3cb86eb4c - languageName: node - linkType: hard + slice-ansi "^5.0.0" + string-width "^7.0.0" -"cliui@npm:^8.0.1": - version: 8.0.1 - resolution: "cliui@npm:8.0.1" +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== dependencies: - string-width: "npm:^4.2.0" - strip-ansi: "npm:^6.0.1" - wrap-ansi: "npm:^7.0.0" - checksum: 10c0/4bda0f09c340cbb6dfdc1ed508b3ca080f12992c18d68c6be4d9cf51756033d5266e61ec57529e610dacbf4da1c634423b0c1b11037709cc6b09045cbd815df5 - languageName: node - linkType: hard + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" -"code-excerpt@npm:^4.0.0": - version: 4.0.0 - resolution: "code-excerpt@npm:4.0.0" +code-excerpt@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz" + integrity sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA== dependencies: - convert-to-spaces: "npm:^2.0.1" - checksum: 10c0/b6c5a06e039cecd2ab6a0e10ee0831de8362107d1f298ca3558b5f9004cb8e0260b02dd6c07f57b9a0e346c76864d2873311ee1989809fdeb05bd5fbbadde773 - languageName: node - linkType: hard + convert-to-spaces "^2.0.1" -"color-convert@npm:^2.0.1": - version: 2.0.1 - resolution: "color-convert@npm:2.0.1" +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== dependencies: - color-name: "npm:~1.1.4" - checksum: 10c0/37e1150172f2e311fe1b2df62c6293a342ee7380da7b9cfdba67ea539909afbd74da27033208d01d6d5cfc65ee7868a22e18d7e7648e004425441c0f8a15a7d7 - languageName: node - linkType: hard + color-name "~1.1.4" -"color-name@npm:~1.1.4": - version: 1.1.4 - resolution: "color-name@npm:1.1.4" - checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95 - languageName: node - linkType: hard +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -"common-path-prefix@npm:^3.0.0": - version: 3.0.0 - resolution: "common-path-prefix@npm:3.0.0" - checksum: 10c0/c4a74294e1b1570f4a8ab435285d185a03976c323caa16359053e749db4fde44e3e6586c29cd051100335e11895767cbbd27ea389108e327d62f38daf4548fdb - languageName: node - linkType: hard +common-path-prefix@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz" + integrity sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w== -"concat-map@npm:0.0.1": - version: 0.0.1 - resolution: "concat-map@npm:0.0.1" - checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f - languageName: node - linkType: hard +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== -"concordance@npm:^5.0.4": - version: 5.0.4 - resolution: "concordance@npm:5.0.4" +concordance@^5.0.4: + version "5.0.4" + resolved "https://registry.npmjs.org/concordance/-/concordance-5.0.4.tgz" + integrity sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw== dependencies: - date-time: "npm:^3.1.0" - esutils: "npm:^2.0.3" - fast-diff: "npm:^1.2.0" - js-string-escape: "npm:^1.0.1" - lodash: "npm:^4.17.15" - md5-hex: "npm:^3.0.1" - semver: "npm:^7.3.2" - well-known-symbols: "npm:^2.0.0" - checksum: 10c0/59b440f330df3a7c9aa148ba588b3e99aed86acab225b4f01ffcea34ace4cf11f817e31153254e8f38ed48508998dad40b9106951a743c334d751f7ab21afb8a - languageName: node - linkType: hard + date-time "^3.1.0" + esutils "^2.0.3" + fast-diff "^1.2.0" + js-string-escape "^1.0.1" + lodash "^4.17.15" + md5-hex "^3.0.1" + semver "^7.3.2" + well-known-symbols "^2.0.0" -"consola@npm:^3.2.3": - version: 3.4.2 - resolution: "consola@npm:3.4.2" - checksum: 10c0/7cebe57ecf646ba74b300bcce23bff43034ed6fbec9f7e39c27cee1dc00df8a21cd336b466ad32e304ea70fba04ec9e890c200270de9a526ce021ba8a7e4c11a - languageName: node - linkType: hard +consola@^3.2.3: + version "3.4.2" + resolved "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz" + integrity sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA== -"convert-to-spaces@npm:^2.0.1": - version: 2.0.1 - resolution: "convert-to-spaces@npm:2.0.1" - checksum: 10c0/d90aa0e3b6a27f9d5265a8d32def3c5c855b3e823a9db1f26d772f8146d6b91020a2fdfd905ce8048a73fad3aaf836fef8188c67602c374405e2ae8396c4ac46 - languageName: node - linkType: hard +convert-to-spaces@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz" + integrity sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ== -"currently-unhandled@npm:^0.4.1": - version: 0.4.1 - resolution: "currently-unhandled@npm:0.4.1" +currently-unhandled@^0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz" + integrity sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng== dependencies: - array-find-index: "npm:^1.0.1" - checksum: 10c0/32d197689ec32f035910202c1abb0dc6424dce01d7b51779c685119b380d98535c110ffff67a262fc7e367612a7dfd30d3d3055f9a6634b5a9dd1302de7ef11c - languageName: node - linkType: hard + array-find-index "^1.0.1" -"date-time@npm:^3.1.0": - version: 3.1.0 - resolution: "date-time@npm:3.1.0" +date-time@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/date-time/-/date-time-3.1.0.tgz" + integrity sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg== dependencies: - time-zone: "npm:^1.0.0" - checksum: 10c0/aa3e2e930d74b0b9e90f69de7a16d3376e30f21f1f4ce9a2311d8fec32d760e776efea752dafad0ce188187265235229013036202be053fc2d7979813bfb6ded - languageName: node - linkType: hard + time-zone "^1.0.0" -"debug@npm:4, debug@npm:^4.3.7": - version: 4.4.0 - resolution: "debug@npm:4.4.0" +debug@^4.3.7, debug@4: + version "4.4.0" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz" + integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== dependencies: - ms: "npm:^2.1.3" - peerDependenciesMeta: - supports-color: - optional: true - checksum: 10c0/db94f1a182bf886f57b4755f85b3a74c39b5114b9377b7ab375dc2cfa3454f09490cc6c30f829df3fc8042bc8b8995f6567ce5cd96f3bc3688bd24027197d9de - languageName: node - linkType: hard + ms "^2.1.3" -"detect-libc@npm:^2.0.0": - version: 2.0.3 - resolution: "detect-libc@npm:2.0.3" - checksum: 10c0/88095bda8f90220c95f162bf92cad70bd0e424913e655c20578600e35b91edc261af27531cf160a331e185c0ced93944bc7e09939143225f56312d7fd800fdb7 - languageName: node - linkType: hard +detect-libc@^2.0.0: + version "2.0.3" + resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz" + integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== -"emittery@npm:^1.0.3": - version: 1.1.0 - resolution: "emittery@npm:1.1.0" - checksum: 10c0/645d4d7307b52c81bb2d2f9f320aa6a3c0225f53a4bfef2d337be8086df975746f7dd619f1dd7b2ffd4f2288103f28019e7b8567718677600e47507496d3af5f - languageName: node - linkType: hard +emittery@^1.0.3: + version "1.1.0" + resolved "https://registry.npmjs.org/emittery/-/emittery-1.1.0.tgz" + integrity sha512-rsX7ktqARv/6UQDgMaLfIqUWAEzzbCQiVh7V9rhDXp6c37yoJcks12NVD+XPkgl4AEavmNhVfrhGoqYwIsMYYA== -"emoji-regex@npm:^10.3.0": - version: 10.4.0 - resolution: "emoji-regex@npm:10.4.0" - checksum: 10c0/a3fcedfc58bfcce21a05a5f36a529d81e88d602100145fcca3dc6f795e3c8acc4fc18fe773fbf9b6d6e9371205edb3afa2668ec3473fa2aa7fd47d2a9d46482d - languageName: node - linkType: hard +emoji-regex@^10.3.0: + version "10.4.0" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz" + integrity sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw== -"emoji-regex@npm:^8.0.0": - version: 8.0.0 - resolution: "emoji-regex@npm:8.0.0" - checksum: 10c0/b6053ad39951c4cf338f9092d7bfba448cdfd46fe6a2a034700b149ac9ffbc137e361cbd3c442297f86bed2e5f7576c1b54cc0a6bf8ef5106cc62f496af35010 - languageName: node - linkType: hard +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== -"escalade@npm:^3.1.1": - version: 3.2.0 - resolution: "escalade@npm:3.2.0" - checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 - languageName: node - linkType: hard +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== -"escape-string-regexp@npm:^2.0.0": - version: 2.0.0 - resolution: "escape-string-regexp@npm:2.0.0" - checksum: 10c0/2530479fe8db57eace5e8646c9c2a9c80fa279614986d16dcc6bcaceb63ae77f05a851ba6c43756d816c61d7f4534baf56e3c705e3e0d884818a46808811c507 - languageName: node - linkType: hard +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== -"escape-string-regexp@npm:^5.0.0": - version: 5.0.0 - resolution: "escape-string-regexp@npm:5.0.0" - checksum: 10c0/6366f474c6f37a802800a435232395e04e9885919873e382b157ab7e8f0feb8fed71497f84a6f6a81a49aab41815522f5839112bd38026d203aea0c91622df95 - languageName: node - linkType: hard +escape-string-regexp@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz" + integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== -"esprima@npm:^4.0.0": - version: 4.0.1 - resolution: "esprima@npm:4.0.1" - bin: - esparse: ./bin/esparse.js - esvalidate: ./bin/esvalidate.js - checksum: 10c0/ad4bab9ead0808cf56501750fd9d3fb276f6b105f987707d059005d57e182d18a7c9ec7f3a01794ebddcca676773e42ca48a32d67a250c9d35e009ca613caba3 - languageName: node - linkType: hard +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -"estree-walker@npm:2.0.2, estree-walker@npm:^2.0.2": - version: 2.0.2 - resolution: "estree-walker@npm:2.0.2" - checksum: 10c0/53a6c54e2019b8c914dc395890153ffdc2322781acf4bd7d1a32d7aedc1710807bdcd866ac133903d5629ec601fbb50abe8c2e5553c7f5a0afdd9b6af6c945af - languageName: node - linkType: hard +estree-walker@^2.0.2, estree-walker@2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== -"esutils@npm:^2.0.3": - version: 2.0.3 - resolution: "esutils@npm:2.0.3" - checksum: 10c0/9a2fe69a41bfdade834ba7c42de4723c97ec776e40656919c62cbd13607c45e127a003f05f724a1ea55e5029a4cf2de444b13009f2af71271e42d93a637137c7 - languageName: node - linkType: hard +esutils@^2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -"fast-diff@npm:^1.2.0": - version: 1.3.0 - resolution: "fast-diff@npm:1.3.0" - checksum: 10c0/5c19af237edb5d5effda008c891a18a585f74bf12953be57923f17a3a4d0979565fc64dbc73b9e20926b9d895f5b690c618cbb969af0cf022e3222471220ad29 - languageName: node - linkType: hard +fast-diff@^1.2.0: + version "1.3.0" + resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz" + integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== -"fast-glob@npm:^3.3.3": - version: 3.3.3 - resolution: "fast-glob@npm:3.3.3" +fast-glob@^3.3.3: + version "3.3.3" + resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== dependencies: - "@nodelib/fs.stat": "npm:^2.0.2" - "@nodelib/fs.walk": "npm:^1.2.3" - glob-parent: "npm:^5.1.2" - merge2: "npm:^1.3.0" - micromatch: "npm:^4.0.8" - checksum: 10c0/f6aaa141d0d3384cf73cbcdfc52f475ed293f6d5b65bfc5def368b09163a9f7e5ec2b3014d80f733c405f58e470ee0cc451c2937685045cddcdeaa24199c43fe - languageName: node - linkType: hard + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" -"fastq@npm:^1.6.0": - version: 1.19.1 - resolution: "fastq@npm:1.19.1" +fastq@^1.6.0: + version "1.19.1" + resolved "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz" + integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== dependencies: - reusify: "npm:^1.0.4" - checksum: 10c0/ebc6e50ac7048daaeb8e64522a1ea7a26e92b3cee5cd1c7f2316cdca81ba543aa40a136b53891446ea5c3a67ec215fbaca87ad405f102dd97012f62916905630 - languageName: node - linkType: hard + reusify "^1.0.4" -"figures@npm:^6.1.0": - version: 6.1.0 - resolution: "figures@npm:6.1.0" +figures@^6.1.0: + version "6.1.0" + resolved "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz" + integrity sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg== dependencies: - is-unicode-supported: "npm:^2.0.0" - checksum: 10c0/9159df4264d62ef447a3931537de92f5012210cf5135c35c010df50a2169377581378149abfe1eb238bd6acbba1c0d547b1f18e0af6eee49e30363cedaffcfe4 - languageName: node - linkType: hard + is-unicode-supported "^2.0.0" -"file-uri-to-path@npm:1.0.0": - version: 1.0.0 - resolution: "file-uri-to-path@npm:1.0.0" - checksum: 10c0/3b545e3a341d322d368e880e1c204ef55f1d45cdea65f7efc6c6ce9e0c4d22d802d5629320eb779d006fe59624ac17b0e848d83cc5af7cd101f206cb704f5519 - languageName: node - linkType: hard +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== -"fill-range@npm:^7.1.1": - version: 7.1.1 - resolution: "fill-range@npm:7.1.1" +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== dependencies: - to-regex-range: "npm:^5.0.1" - checksum: 10c0/b75b691bbe065472f38824f694c2f7449d7f5004aa950426a2c28f0306c60db9b880c0b0e4ed819997ffb882d1da02cfcfc819bddc94d71627f5269682edf018 - languageName: node - linkType: hard + to-regex-range "^5.0.1" -"find-up-simple@npm:^1.0.0": - version: 1.0.1 - resolution: "find-up-simple@npm:1.0.1" - checksum: 10c0/ad34de157b7db925d50ff78302fefb28e309f3bc947c93ffca0f9b0bccf9cf1a2dc57d805d5c94ec9fc60f4838f5dbdfd2a48ecd77c23015fa44c6dd5f60bc40 - languageName: node - linkType: hard +find-up-simple@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz" + integrity sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ== -"fs.realpath@npm:^1.0.0": - version: 1.0.0 - resolution: "fs.realpath@npm:1.0.0" - checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948 - languageName: node - linkType: hard +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -"get-caller-file@npm:^2.0.5": - version: 2.0.5 - resolution: "get-caller-file@npm:2.0.5" - checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde - languageName: node - linkType: hard +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -"get-east-asian-width@npm:^1.0.0": - version: 1.3.0 - resolution: "get-east-asian-width@npm:1.3.0" - checksum: 10c0/1a049ba697e0f9a4d5514c4623781c5246982bdb61082da6b5ae6c33d838e52ce6726407df285cdbb27ec1908b333cf2820989bd3e986e37bb20979437fdf34b - languageName: node - linkType: hard +get-east-asian-width@^1.0.0: + version "1.3.0" + resolved "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz" + integrity sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ== -"glob-parent@npm:^5.1.2": - version: 5.1.2 - resolution: "glob-parent@npm:5.1.2" +glob-parent@^5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== dependencies: - is-glob: "npm:^4.0.1" - checksum: 10c0/cab87638e2112bee3f839ef5f6e0765057163d39c66be8ec1602f3823da4692297ad4e972de876ea17c44d652978638d2fd583c6713d0eb6591706825020c9ee - languageName: node - linkType: hard + is-glob "^4.0.1" -"glob@npm:^7.1.3": - version: 7.2.3 - resolution: "glob@npm:7.2.3" +glob@^7.1.3: + version "7.2.3" + resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== dependencies: - fs.realpath: "npm:^1.0.0" - inflight: "npm:^1.0.4" - inherits: "npm:2" - minimatch: "npm:^3.1.1" - once: "npm:^1.3.0" - path-is-absolute: "npm:^1.0.0" - checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe - languageName: node - linkType: hard + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" -"globby@npm:^14.0.2": - version: 14.1.0 - resolution: "globby@npm:14.1.0" +globby@^14.0.2: + version "14.1.0" + resolved "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz" + integrity sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA== dependencies: - "@sindresorhus/merge-streams": "npm:^2.1.0" - fast-glob: "npm:^3.3.3" - ignore: "npm:^7.0.3" - path-type: "npm:^6.0.0" - slash: "npm:^5.1.0" - unicorn-magic: "npm:^0.3.0" - checksum: 10c0/527a1063c5958255969620c6fa4444a2b2e9278caddd571d46dfbfa307cb15977afb746e84d682ba5b6c94fc081e8997f80ff05dd235441ba1cb16f86153e58e - languageName: node - linkType: hard + "@sindresorhus/merge-streams" "^2.1.0" + fast-glob "^3.3.3" + ignore "^7.0.3" + path-type "^6.0.0" + slash "^5.1.0" + unicorn-magic "^0.3.0" -"graceful-fs@npm:^4.2.9": - version: 4.2.11 - resolution: "graceful-fs@npm:4.2.11" - checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 - languageName: node - linkType: hard +graceful-fs@^4.2.9: + version "4.2.11" + resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== -"https-proxy-agent@npm:^7.0.5": - version: 7.0.6 - resolution: "https-proxy-agent@npm:7.0.6" +https-proxy-agent@^7.0.5: + version "7.0.6" + resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== dependencies: - agent-base: "npm:^7.1.2" - debug: "npm:4" - checksum: 10c0/f729219bc735edb621fa30e6e84e60ee5d00802b8247aac0d7b79b0bd6d4b3294737a337b93b86a0bd9e68099d031858a39260c976dc14cdbba238ba1f8779ac - languageName: node - linkType: hard + agent-base "^7.1.2" + debug "4" -"ignore-by-default@npm:^2.1.0": - version: 2.1.0 - resolution: "ignore-by-default@npm:2.1.0" - checksum: 10c0/3a6040dac25ed9da39dee73bf1634fdd1e15b0eb7cf52a6bdec81c310565782d8811c104ce40acb3d690d61c5fc38a91c78e6baee830a8a2232424dbc6b66981 - languageName: node - linkType: hard +ignore-by-default@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-2.1.0.tgz" + integrity sha512-yiWd4GVmJp0Q6ghmM2B/V3oZGRmjrKLXvHR3TE1nfoXsmoggllfZUQe74EN0fJdPFZu2NIvNdrMMLm3OsV7Ohw== -"ignore@npm:^7.0.3": - version: 7.0.3 - resolution: "ignore@npm:7.0.3" - checksum: 10c0/8e21637513cbcd888a4873d34d5c651a2e24b3c4c9a6b159335a26bed348c3c386c51d6fab23577f59140e1b226323138fbd50e63882d4568fd12aa6c822029e - languageName: node - linkType: hard +ignore@^7.0.3: + version "7.0.3" + resolved "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz" + integrity sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA== -"imurmurhash@npm:^0.1.4": - version: 0.1.4 - resolution: "imurmurhash@npm:0.1.4" - checksum: 10c0/8b51313850dd33605c6c9d3fd9638b714f4c4c40250cff658209f30d40da60f78992fb2df5dabee4acf589a6a82bbc79ad5486550754bd9ec4e3fc0d4a57d6a6 - languageName: node - linkType: hard +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== -"indent-string@npm:^5.0.0": - version: 5.0.0 - resolution: "indent-string@npm:5.0.0" - checksum: 10c0/8ee77b57d92e71745e133f6f444d6fa3ed503ad0e1bcd7e80c8da08b42375c07117128d670589725ed07b1978065803fa86318c309ba45415b7fe13e7f170220 - languageName: node - linkType: hard +indent-string@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz" + integrity sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg== -"inflight@npm:^1.0.4": - version: 1.0.6 - resolution: "inflight@npm:1.0.6" +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== dependencies: - once: "npm:^1.3.0" - wrappy: "npm:1" - checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2 - languageName: node - linkType: hard + once "^1.3.0" + wrappy "1" -"inherits@npm:2": - version: 2.0.4 - resolution: "inherits@npm:2.0.4" - checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 - languageName: node - linkType: hard +inherits@2: + version "2.0.4" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -"irregular-plurals@npm:^3.3.0": - version: 3.5.0 - resolution: "irregular-plurals@npm:3.5.0" - checksum: 10c0/7c033bbe7325e5a6e0a26949cc6863b6ce273403d4cd5b93bd99b33fecb6605b0884097c4259c23ed0c52c2133bf7d1cdcdd7a0630e8c325161fe269b3447918 - languageName: node - linkType: hard +irregular-plurals@^3.3.0: + version "3.5.0" + resolved "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz" + integrity sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ== -"is-extglob@npm:^2.1.1": - version: 2.1.1 - resolution: "is-extglob@npm:2.1.1" - checksum: 10c0/5487da35691fbc339700bbb2730430b07777a3c21b9ebaecb3072512dfd7b4ba78ac2381a87e8d78d20ea08affb3f1971b4af629173a6bf435ff8a4c47747912 - languageName: node - linkType: hard +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== -"is-fullwidth-code-point@npm:^3.0.0": - version: 3.0.0 - resolution: "is-fullwidth-code-point@npm:3.0.0" - checksum: 10c0/bb11d825e049f38e04c06373a8d72782eee0205bda9d908cc550ccb3c59b99d750ff9537982e01733c1c94a58e35400661f57042158ff5e8f3e90cf936daf0fc - languageName: node - linkType: hard +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== -"is-fullwidth-code-point@npm:^4.0.0": - version: 4.0.0 - resolution: "is-fullwidth-code-point@npm:4.0.0" - checksum: 10c0/df2a717e813567db0f659c306d61f2f804d480752526886954a2a3e2246c7745fd07a52b5fecf2b68caf0a6c79dcdace6166fdf29cc76ed9975cc334f0a018b8 - languageName: node - linkType: hard +is-fullwidth-code-point@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz" + integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== -"is-glob@npm:^4.0.1": - version: 4.0.3 - resolution: "is-glob@npm:4.0.3" +is-glob@^4.0.1: + version "4.0.3" + resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== dependencies: - is-extglob: "npm:^2.1.1" - checksum: 10c0/17fb4014e22be3bbecea9b2e3a76e9e34ff645466be702f1693e8f1ee1adac84710d0be0bd9f967d6354036fd51ab7c2741d954d6e91dae6bb69714de92c197a - languageName: node - linkType: hard + is-extglob "^2.1.1" -"is-number@npm:^7.0.0": - version: 7.0.0 - resolution: "is-number@npm:7.0.0" - checksum: 10c0/b4686d0d3053146095ccd45346461bc8e53b80aeb7671cc52a4de02dbbf7dc0d1d2a986e2fe4ae206984b4d34ef37e8b795ebc4f4295c978373e6575e295d811 - languageName: node - linkType: hard +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -"is-plain-object@npm:^5.0.0": - version: 5.0.0 - resolution: "is-plain-object@npm:5.0.0" - checksum: 10c0/893e42bad832aae3511c71fd61c0bf61aa3a6d853061c62a307261842727d0d25f761ce9379f7ba7226d6179db2a3157efa918e7fe26360f3bf0842d9f28942c - languageName: node - linkType: hard +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== -"is-promise@npm:^4.0.0": - version: 4.0.0 - resolution: "is-promise@npm:4.0.0" - checksum: 10c0/ebd5c672d73db781ab33ccb155fb9969d6028e37414d609b115cc534654c91ccd061821d5b987eefaa97cf4c62f0b909bb2f04db88306de26e91bfe8ddc01503 - languageName: node - linkType: hard +is-promise@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz" + integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== -"is-unicode-supported@npm:^2.0.0": - version: 2.1.0 - resolution: "is-unicode-supported@npm:2.1.0" - checksum: 10c0/a0f53e9a7c1fdbcf2d2ef6e40d4736fdffff1c9f8944c75e15425118ff3610172c87bf7bc6c34d3903b04be59790bb2212ddbe21ee65b5a97030fc50370545a5 - languageName: node - linkType: hard +is-unicode-supported@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz" + integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== -"js-string-escape@npm:^1.0.1": - version: 1.0.1 - resolution: "js-string-escape@npm:1.0.1" - checksum: 10c0/2c33b9ff1ba6b84681c51ca0997e7d5a1639813c95d5b61cb7ad47e55cc28fa4a0b1935c3d218710d8e6bcee5d0cd8c44755231e3a4e45fc604534d9595a3628 - languageName: node - linkType: hard +js-string-escape@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz" + integrity sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg== -"js-yaml@npm:^3.14.1": - version: 3.14.1 - resolution: "js-yaml@npm:3.14.1" +js-yaml@^3.14.1: + version "3.14.1" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== dependencies: - argparse: "npm:^1.0.7" - esprima: "npm:^4.0.0" - bin: - js-yaml: bin/js-yaml.js - checksum: 10c0/6746baaaeac312c4db8e75fa22331d9a04cccb7792d126ed8ce6a0bbcfef0cedaddd0c5098fade53db067c09fe00aa1c957674b4765610a8b06a5a189e46433b - languageName: node - linkType: hard + argparse "^1.0.7" + esprima "^4.0.0" -"libloot-nodejs@workspace:.": - version: 0.0.0-use.local - resolution: "libloot-nodejs@workspace:." +load-json-file@^7.0.1: + version "7.0.1" + resolved "https://registry.npmjs.org/load-json-file/-/load-json-file-7.0.1.tgz" + integrity sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ== + +lodash@^4.17.15: + version "4.17.21" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +matcher@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/matcher/-/matcher-5.0.0.tgz" + integrity sha512-s2EMBOWtXFc8dgqvoAzKJXxNHibcdJMV0gwqKUaw9E2JBJuGUK7DrNKrA6g/i+v72TT16+6sVm5mS3thaMLQUw== dependencies: - "@napi-rs/cli": "npm:^2.18.4" - ava: "npm:^6.0.1" - languageName: unknown - linkType: soft + escape-string-regexp "^5.0.0" -"load-json-file@npm:^7.0.1": - version: 7.0.1 - resolution: "load-json-file@npm:7.0.1" - checksum: 10c0/7117459608a0b6329c7f78e6e1f541b3162dd901c29dd5af721fec8b270177d2e3d7999c971f344fff04daac368d052732e2c7146014bc84d15e0b636975e19a - languageName: node - linkType: hard - -"lodash@npm:^4.17.15": - version: 4.17.21 - resolution: "lodash@npm:4.17.21" - checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c - languageName: node - linkType: hard - -"matcher@npm:^5.0.0": - version: 5.0.0 - resolution: "matcher@npm:5.0.0" +md5-hex@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz" + integrity sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw== dependencies: - escape-string-regexp: "npm:^5.0.0" - checksum: 10c0/eda5471fc9d5b7264d63c81727824adc3585ddb5cfdc5fce5a9b7c86f946ff181610735d330b1c37a84811df872d1290bf4e9401d2be2a414204343701144b18 - languageName: node - linkType: hard + blueimp-md5 "^2.10.0" -"md5-hex@npm:^3.0.1": - version: 3.0.1 - resolution: "md5-hex@npm:3.0.1" +memoize@^10.0.0: + version "10.1.0" + resolved "https://registry.npmjs.org/memoize/-/memoize-10.1.0.tgz" + integrity sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg== dependencies: - blueimp-md5: "npm:^2.10.0" - checksum: 10c0/ee2b4d8da16b527b3a3fe4d7a96720f43afd07b46a82d49421208b5a126235fb75cfb30b80d4029514772c8844273f940bddfbf4155c787f968f3be4060d01e4 - languageName: node - linkType: hard + mimic-function "^5.0.1" -"memoize@npm:^10.0.0": - version: 10.1.0 - resolution: "memoize@npm:10.1.0" +merge2@^1.3.0: + version "1.4.1" + resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== dependencies: - mimic-function: "npm:^5.0.1" - checksum: 10c0/6cf71f673b89778b05cd1131f573ba858627daa8fec60f2197328386acf7ab184a89e52527abbd5a605b5ccf5ee12dc0cb96efb651d9a30dcfcc89e9baacc84d - languageName: node - linkType: hard + braces "^3.0.3" + picomatch "^2.3.1" -"merge2@npm:^1.3.0": - version: 1.4.1 - resolution: "merge2@npm:1.4.1" - checksum: 10c0/254a8a4605b58f450308fc474c82ac9a094848081bf4c06778200207820e5193726dc563a0d2c16468810516a5c97d9d3ea0ca6585d23c58ccfff2403e8dbbeb - languageName: node - linkType: hard +mimic-function@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz" + integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== -"micromatch@npm:^4.0.8": - version: 4.0.8 - resolution: "micromatch@npm:4.0.8" +minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== dependencies: - braces: "npm:^3.0.3" - picomatch: "npm:^2.3.1" - checksum: 10c0/166fa6eb926b9553f32ef81f5f531d27b4ce7da60e5baf8c021d043b27a388fb95e46a8038d5045877881e673f8134122b59624d5cecbd16eb50a42e7a6b5ca8 - languageName: node - linkType: hard + brace-expansion "^1.1.7" -"mimic-function@npm:^5.0.1": - version: 5.0.1 - resolution: "mimic-function@npm:5.0.1" - checksum: 10c0/f3d9464dd1816ecf6bdf2aec6ba32c0728022039d992f178237d8e289b48764fee4131319e72eedd4f7f094e22ded0af836c3187a7edc4595d28dd74368fd81d - languageName: node - linkType: hard +minipass@^7.0.4, minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== -"minimatch@npm:^3.1.1": - version: 3.1.2 - resolution: "minimatch@npm:3.1.2" +minizlib@^3.0.1: + version "3.0.2" + resolved "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz" + integrity sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA== dependencies: - brace-expansion: "npm:^1.1.7" - checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 - languageName: node - linkType: hard + minipass "^7.1.2" -"minipass@npm:^7.0.4, minipass@npm:^7.1.2": - version: 7.1.2 - resolution: "minipass@npm:7.1.2" - checksum: 10c0/b0fd20bb9fb56e5fa9a8bfac539e8915ae07430a619e4b86ff71f5fc757ef3924b23b2c4230393af1eda647ed3d75739e4e0acb250a6b1eb277cf7f8fe449557 - languageName: node - linkType: hard +mkdirp@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz" + integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== -"minizlib@npm:^3.0.1": - version: 3.0.2 - resolution: "minizlib@npm:3.0.2" +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +node-fetch@^2.6.7: + version "2.7.0" + resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== dependencies: - minipass: "npm:^7.1.2" - checksum: 10c0/9f3bd35e41d40d02469cb30470c55ccc21cae0db40e08d1d0b1dff01cc8cc89a6f78e9c5d2b7c844e485ec0a8abc2238111213fdc5b2038e6d1012eacf316f78 - languageName: node - linkType: hard + whatwg-url "^5.0.0" -"mkdirp@npm:^3.0.1": - version: 3.0.1 - resolution: "mkdirp@npm:3.0.1" - bin: - mkdirp: dist/cjs/src/bin.js - checksum: 10c0/9f2b975e9246351f5e3a40dcfac99fcd0baa31fbfab615fe059fb11e51f10e4803c63de1f384c54d656e4db31d000e4767e9ef076a22e12a641357602e31d57d - languageName: node - linkType: hard +node-gyp-build@^4.2.2: + version "4.8.4" + resolved "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz" + integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== -"ms@npm:^2.1.3": - version: 2.1.3 - resolution: "ms@npm:2.1.3" - checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 - languageName: node - linkType: hard +nofilter@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz" + integrity sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g== -"node-fetch@npm:^2.6.7": - version: 2.7.0 - resolution: "node-fetch@npm:2.7.0" +nopt@^8.0.0: + version "8.1.0" + resolved "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz" + integrity sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A== dependencies: - whatwg-url: "npm:^5.0.0" - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8 - languageName: node - linkType: hard + abbrev "^3.0.0" -"node-gyp-build@npm:^4.2.2": - version: 4.8.4 - resolution: "node-gyp-build@npm:4.8.4" - bin: - node-gyp-build: bin.js - node-gyp-build-optional: optional.js - node-gyp-build-test: build-test.js - checksum: 10c0/444e189907ece2081fe60e75368784f7782cfddb554b60123743dfb89509df89f1f29c03bbfa16b3a3e0be3f48799a4783f487da6203245fa5bed239ba7407e1 - languageName: node - linkType: hard - -"nofilter@npm:^3.1.0": - version: 3.1.0 - resolution: "nofilter@npm:3.1.0" - checksum: 10c0/92459f3864a067b347032263f0b536223cbfc98153913b5dce350cb39c8470bc1813366e41993f22c33cc6400c0f392aa324a4b51e24c22040635c1cdb046499 - languageName: node - linkType: hard - -"nopt@npm:^8.0.0": - version: 8.1.0 - resolution: "nopt@npm:8.1.0" +once@^1.3.0: + version "1.4.0" + resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: - abbrev: "npm:^3.0.0" - bin: - nopt: bin/nopt.js - checksum: 10c0/62e9ea70c7a3eb91d162d2c706b6606c041e4e7b547cbbb48f8b3695af457dd6479904d7ace600856bf923dd8d1ed0696f06195c8c20f02ac87c1da0e1d315ef - languageName: node - linkType: hard + wrappy "1" -"once@npm:^1.3.0": - version: 1.4.0 - resolution: "once@npm:1.4.0" +p-map@^7.0.2: + version "7.0.3" + resolved "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz" + integrity sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA== + +package-config@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/package-config/-/package-config-5.0.0.tgz" + integrity sha512-GYTTew2slBcYdvRHqjhwaaydVMvn/qrGC323+nKclYioNSLTDUM/lGgtGTgyHVtYcozb+XkE8CNhwcraOmZ9Mg== dependencies: - wrappy: "npm:1" - checksum: 10c0/5d48aca287dfefabd756621c5dfce5c91a549a93e9fdb7b8246bc4c4790aa2ec17b34a260530474635147aeb631a2dcc8b32c613df0675f96041cbb8244517d0 - languageName: node - linkType: hard + find-up-simple "^1.0.0" + load-json-file "^7.0.1" -"p-map@npm:^7.0.2": - version: 7.0.3 - resolution: "p-map@npm:7.0.3" - checksum: 10c0/46091610da2b38ce47bcd1d8b4835a6fa4e832848a6682cf1652bc93915770f4617afc844c10a77d1b3e56d2472bb2d5622353fa3ead01a7f42b04fc8e744a5c - languageName: node - linkType: hard +parse-ms@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz" + integrity sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw== -"package-config@npm:^5.0.0": - version: 5.0.0 - resolution: "package-config@npm:5.0.0" +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-type@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz" + integrity sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ== + +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +picomatch@^4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz" + integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== + +plur@^5.1.0: + version "5.1.0" + resolved "https://registry.npmjs.org/plur/-/plur-5.1.0.tgz" + integrity sha512-VP/72JeXqak2KiOzjgKtQen5y3IZHn+9GOuLDafPv0eXa47xq0At93XahYBs26MsifCQ4enGKwbjBTKgb9QJXg== dependencies: - find-up-simple: "npm:^1.0.0" - load-json-file: "npm:^7.0.1" - checksum: 10c0/f6c48930700b73a41d839bf2898b628d23665827488a4f34aed2d05e4a99d7a70a70ada115c3546765947fbc8accff94c0779da21ea084b25df47cb774531eeb - languageName: node - linkType: hard + irregular-plurals "^3.3.0" -"parse-ms@npm:^4.0.0": - version: 4.0.0 - resolution: "parse-ms@npm:4.0.0" - checksum: 10c0/a7900f4f1ebac24cbf5e9708c16fb2fd482517fad353aecd7aefb8c2ba2f85ce017913ccb8925d231770404780df46244ea6fec598b3bde6490882358b4d2d16 - languageName: node - linkType: hard - -"path-is-absolute@npm:^1.0.0": - version: 1.0.1 - resolution: "path-is-absolute@npm:1.0.1" - checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078 - languageName: node - linkType: hard - -"path-type@npm:^6.0.0": - version: 6.0.0 - resolution: "path-type@npm:6.0.0" - checksum: 10c0/55baa8b1187d6dc683d5a9cfcc866168d6adff58e5db91126795376d818eee46391e00b2a4d53e44d844c7524a7d96aa68cc68f4f3e500d3d069a39e6535481c - languageName: node - linkType: hard - -"picomatch@npm:^2.3.1": - version: 2.3.1 - resolution: "picomatch@npm:2.3.1" - checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be - languageName: node - linkType: hard - -"picomatch@npm:^4.0.2": - version: 4.0.2 - resolution: "picomatch@npm:4.0.2" - checksum: 10c0/7c51f3ad2bb42c776f49ebf964c644958158be30d0a510efd5a395e8d49cb5acfed5b82c0c5b365523ce18e6ab85013c9ebe574f60305892ec3fa8eee8304ccc - languageName: node - linkType: hard - -"plur@npm:^5.1.0": - version: 5.1.0 - resolution: "plur@npm:5.1.0" +pretty-ms@^9.1.0: + version "9.2.0" + resolved "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz" + integrity sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg== dependencies: - irregular-plurals: "npm:^3.3.0" - checksum: 10c0/26bb622b8545fcfd47bbf56fbcca66c08693708a232e403fa3589e00003c56c14231ac57c7588ca5db83ef4be1f61383402c4ea954000768f779f8aef6eb6da8 - languageName: node - linkType: hard + parse-ms "^4.0.0" -"pretty-ms@npm:^9.1.0": - version: 9.2.0 - resolution: "pretty-ms@npm:9.2.0" +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz" + integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== dependencies: - parse-ms: "npm:^4.0.0" - checksum: 10c0/ab6d066f90e9f77020426986e1b018369f41575674544c539aabec2e63a20fec01166d8cf6571d0e165ad11cfe5a8134a2a48a36d42ab291c59c6deca5264cbb - languageName: node - linkType: hard + resolve-from "^5.0.0" -"queue-microtask@npm:^1.2.2": - version: 1.2.3 - resolution: "queue-microtask@npm:1.2.3" - checksum: 10c0/900a93d3cdae3acd7d16f642c29a642aea32c2026446151f0778c62ac089d4b8e6c986811076e1ae180a694cedf077d453a11b58ff0a865629a4f82ab558e102 - languageName: node - linkType: hard +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -"require-directory@npm:^2.1.1": - version: 2.1.1 - resolution: "require-directory@npm:2.1.1" - checksum: 10c0/83aa76a7bc1531f68d92c75a2ca2f54f1b01463cb566cf3fbc787d0de8be30c9dbc211d1d46be3497dac5785fe296f2dd11d531945ac29730643357978966e99 - languageName: node - linkType: hard +reusify@^1.0.4: + version "1.1.0" + resolved "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== -"resolve-cwd@npm:^3.0.0": - version: 3.0.0 - resolution: "resolve-cwd@npm:3.0.0" +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== dependencies: - resolve-from: "npm:^5.0.0" - checksum: 10c0/e608a3ebd15356264653c32d7ecbc8fd702f94c6703ea4ac2fb81d9c359180cba0ae2e6b71faa446631ed6145454d5a56b227efc33a2d40638ac13f8beb20ee4 - languageName: node - linkType: hard + queue-microtask "^1.2.2" -"resolve-from@npm:^5.0.0": - version: 5.0.0 - resolution: "resolve-from@npm:5.0.0" - checksum: 10c0/b21cb7f1fb746de8107b9febab60095187781137fd803e6a59a76d421444b1531b641bba5857f5dc011974d8a5c635d61cec49e6bd3b7fc20e01f0fafc4efbf2 - languageName: node - linkType: hard +semver@^7.3.2, semver@^7.5.3: + version "7.7.1" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz" + integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA== -"reusify@npm:^1.0.4": - version: 1.1.0 - resolution: "reusify@npm:1.1.0" - checksum: 10c0/4eff0d4a5f9383566c7d7ec437b671cc51b25963bd61bf127c3f3d3f68e44a026d99b8d2f1ad344afff8d278a8fe70a8ea092650a716d22287e8bef7126bb2fa - languageName: node - linkType: hard - -"run-parallel@npm:^1.1.9": - version: 1.2.0 - resolution: "run-parallel@npm:1.2.0" +serialize-error@^7.0.1: + version "7.0.1" + resolved "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz" + integrity sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw== dependencies: - queue-microtask: "npm:^1.2.2" - checksum: 10c0/200b5ab25b5b8b7113f9901bfe3afc347e19bb7475b267d55ad0eb86a62a46d77510cb0f232507c9e5d497ebda569a08a9867d0d14f57a82ad5564d991588b39 - languageName: node - linkType: hard + type-fest "^0.13.1" -"semver@npm:^7.3.2, semver@npm:^7.5.3": - version: 7.7.1 - resolution: "semver@npm:7.7.1" - bin: - semver: bin/semver.js - checksum: 10c0/fd603a6fb9c399c6054015433051bdbe7b99a940a8fb44b85c2b524c4004b023d7928d47cb22154f8d054ea7ee8597f586605e05b52047f048278e4ac56ae958 - languageName: node - linkType: hard +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== -"serialize-error@npm:^7.0.1": - version: 7.0.1 - resolution: "serialize-error@npm:7.0.1" +slash@^5.1.0: + version "5.1.0" + resolved "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz" + integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg== + +slice-ansi@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz" + integrity sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ== dependencies: - type-fest: "npm:^0.13.1" - checksum: 10c0/7982937d578cd901276c8ab3e2c6ed8a4c174137730f1fb0402d005af209a0e84d04acc874e317c936724c7b5b26c7a96ff7e4b8d11a469f4924a4b0ea814c05 - languageName: node - linkType: hard + ansi-styles "^6.0.0" + is-fullwidth-code-point "^4.0.0" -"signal-exit@npm:^4.0.1": - version: 4.1.0 - resolution: "signal-exit@npm:4.1.0" - checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 - languageName: node - linkType: hard +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -"slash@npm:^5.1.0": - version: 5.1.0 - resolution: "slash@npm:5.1.0" - checksum: 10c0/eb48b815caf0bdc390d0519d41b9e0556a14380f6799c72ba35caf03544d501d18befdeeef074bc9c052acf69654bc9e0d79d7f1de0866284137a40805299eb3 - languageName: node - linkType: hard - -"slice-ansi@npm:^5.0.0": - version: 5.0.0 - resolution: "slice-ansi@npm:5.0.0" +stack-utils@^2.0.6: + version "2.0.6" + resolved "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz" + integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== dependencies: - ansi-styles: "npm:^6.0.0" - is-fullwidth-code-point: "npm:^4.0.0" - checksum: 10c0/2d4d40b2a9d5cf4e8caae3f698fe24ae31a4d778701724f578e984dcb485ec8c49f0c04dab59c401821e80fcdfe89cace9c66693b0244e40ec485d72e543914f - languageName: node - linkType: hard + escape-string-regexp "^2.0.0" -"sprintf-js@npm:~1.0.2": - version: 1.0.3 - resolution: "sprintf-js@npm:1.0.3" - checksum: 10c0/ecadcfe4c771890140da5023d43e190b7566d9cf8b2d238600f31bec0fc653f328da4450eb04bd59a431771a8e9cc0e118f0aa3974b683a4981b4e07abc2a5bb - languageName: node - linkType: hard - -"stack-utils@npm:^2.0.6": - version: 2.0.6 - resolution: "stack-utils@npm:2.0.6" +string-width@^4.1.0: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== dependencies: - escape-string-regexp: "npm:^2.0.0" - checksum: 10c0/651c9f87667e077584bbe848acaecc6049bc71979f1e9a46c7b920cad4431c388df0f51b8ad7cfd6eed3db97a2878d0fc8b3122979439ea8bac29c61c95eec8a - languageName: node - linkType: hard + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" -"string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": - version: 4.2.3 - resolution: "string-width@npm:4.2.3" +string-width@^4.2.0: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== dependencies: - emoji-regex: "npm:^8.0.0" - is-fullwidth-code-point: "npm:^3.0.0" - strip-ansi: "npm:^6.0.1" - checksum: 10c0/1e525e92e5eae0afd7454086eed9c818ee84374bb80328fc41217ae72ff5f065ef1c9d7f72da41de40c75fa8bb3dee63d92373fd492c84260a552c636392a47b - languageName: node - linkType: hard + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" -"string-width@npm:^7.0.0": - version: 7.2.0 - resolution: "string-width@npm:7.2.0" +string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== dependencies: - emoji-regex: "npm:^10.3.0" - get-east-asian-width: "npm:^1.0.0" - strip-ansi: "npm:^7.1.0" - checksum: 10c0/eb0430dd43f3199c7a46dcbf7a0b34539c76fe3aa62763d0b0655acdcbdf360b3f66f3d58ca25ba0205f42ea3491fa00f09426d3b7d3040e506878fc7664c9b9 - languageName: node - linkType: hard + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" -"strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": - version: 6.0.1 - resolution: "strip-ansi@npm:6.0.1" +string-width@^7.0.0: + version "7.2.0" + resolved "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz" + integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== dependencies: - ansi-regex: "npm:^5.0.1" - checksum: 10c0/1ae5f212a126fe5b167707f716942490e3933085a5ff6c008ab97ab2f272c8025d3aa218b7bd6ab25729ca20cc81cddb252102f8751e13482a5199e873680952 - languageName: node - linkType: hard + emoji-regex "^10.3.0" + get-east-asian-width "^1.0.0" + strip-ansi "^7.1.0" -"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0": - version: 7.1.0 - resolution: "strip-ansi@npm:7.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: - ansi-regex: "npm:^6.0.1" - checksum: 10c0/a198c3762e8832505328cbf9e8c8381de14a4fa50a4f9b2160138158ea88c0f5549fb50cb13c651c3088f47e63a108b34622ec18c0499b6c8c3a5ddf6b305ac4 - languageName: node - linkType: hard + ansi-regex "^5.0.1" -"supertap@npm:^3.0.1": - version: 3.0.1 - resolution: "supertap@npm:3.0.1" +strip-ansi@^7.0.1, strip-ansi@^7.1.0: + version "7.1.0" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== dependencies: - indent-string: "npm:^5.0.0" - js-yaml: "npm:^3.14.1" - serialize-error: "npm:^7.0.1" - strip-ansi: "npm:^7.0.1" - checksum: 10c0/8164674f2e280cab875f0fef5bb36c15553c13e29697ff92f4e0d6bc62149f0303a89eee47535413ed145ea72e14a24d065bab233059d48a499ec5ebb4566b0f - languageName: node - linkType: hard + ansi-regex "^6.0.1" -"tar@npm:^7.4.0": - version: 7.4.3 - resolution: "tar@npm:7.4.3" +supertap@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/supertap/-/supertap-3.0.1.tgz" + integrity sha512-u1ZpIBCawJnO+0QePsEiOknOfCRq0yERxiAchT0i4li0WHNUJbf0evXXSXOcCAR4M8iMDoajXYmstm/qO81Isw== dependencies: - "@isaacs/fs-minipass": "npm:^4.0.0" - chownr: "npm:^3.0.0" - minipass: "npm:^7.1.2" - minizlib: "npm:^3.0.1" - mkdirp: "npm:^3.0.1" - yallist: "npm:^5.0.0" - checksum: 10c0/d4679609bb2a9b48eeaf84632b6d844128d2412b95b6de07d53d8ee8baf4ca0857c9331dfa510390a0727b550fd543d4d1a10995ad86cdf078423fbb8d99831d - languageName: node - linkType: hard + indent-string "^5.0.0" + js-yaml "^3.14.1" + serialize-error "^7.0.1" + strip-ansi "^7.0.1" -"temp-dir@npm:^3.0.0": - version: 3.0.0 - resolution: "temp-dir@npm:3.0.0" - checksum: 10c0/a86978a400984cd5f315b77ebf3fe53bb58c61f192278cafcb1f3fb32d584a21dc8e08b93171d7874b7cc972234d3455c467306cc1bfc4524b622e5ad3bfd671 - languageName: node - linkType: hard - -"time-zone@npm:^1.0.0": - version: 1.0.0 - resolution: "time-zone@npm:1.0.0" - checksum: 10c0/d00ebd885039109011b6e2423ebbf225160927333c2ade6d833e9cc4676db20759f1f3855fafde00d1bd668c243a6aa68938ce71fe58aab0d514e820d59c1d81 - languageName: node - linkType: hard - -"to-regex-range@npm:^5.0.1": - version: 5.0.1 - resolution: "to-regex-range@npm:5.0.1" +tar@^7.4.0: + version "7.4.3" + resolved "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz" + integrity sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw== dependencies: - is-number: "npm:^7.0.0" - checksum: 10c0/487988b0a19c654ff3e1961b87f471702e708fa8a8dd02a298ef16da7206692e8552a0250e8b3e8759270f62e9d8314616f6da274734d3b558b1fc7b7724e892 - languageName: node - linkType: hard + "@isaacs/fs-minipass" "^4.0.0" + chownr "^3.0.0" + minipass "^7.1.2" + minizlib "^3.0.1" + mkdirp "^3.0.1" + yallist "^5.0.0" -"tr46@npm:~0.0.3": - version: 0.0.3 - resolution: "tr46@npm:0.0.3" - checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11 - languageName: node - linkType: hard +temp-dir@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz" + integrity sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw== -"type-fest@npm:^0.13.1": - version: 0.13.1 - resolution: "type-fest@npm:0.13.1" - checksum: 10c0/0c0fa07ae53d4e776cf4dac30d25ad799443e9eef9226f9fddbb69242db86b08584084a99885cfa5a9dfe4c063ebdc9aa7b69da348e735baede8d43f1aeae93b - languageName: node - linkType: hard +time-zone@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/time-zone/-/time-zone-1.0.0.tgz" + integrity sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA== -"unicorn-magic@npm:^0.3.0": - version: 0.3.0 - resolution: "unicorn-magic@npm:0.3.0" - checksum: 10c0/0a32a997d6c15f1c2a077a15b1c4ca6f268d574cf5b8975e778bb98e6f8db4ef4e86dfcae4e158cd4c7e38fb4dd383b93b13eefddc7f178dea13d3ac8a603271 - languageName: node - linkType: hard - -"webidl-conversions@npm:^3.0.0": - version: 3.0.1 - resolution: "webidl-conversions@npm:3.0.1" - checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db - languageName: node - linkType: hard - -"well-known-symbols@npm:^2.0.0": - version: 2.0.0 - resolution: "well-known-symbols@npm:2.0.0" - checksum: 10c0/cb6c12e98877e8952ec28d13ae6f4fdb54ae1cb49b16a728720276dadd76c930e6cb0e174af3a4620054dd2752546f842540122920c6e31410208abd4958ee6b - languageName: node - linkType: hard - -"whatwg-url@npm:^5.0.0": - version: 5.0.0 - resolution: "whatwg-url@npm:5.0.0" +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== dependencies: - tr46: "npm:~0.0.3" - webidl-conversions: "npm:^3.0.0" - checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5 - languageName: node - linkType: hard + is-number "^7.0.0" -"wrap-ansi@npm:^7.0.0": - version: 7.0.0 - resolution: "wrap-ansi@npm:7.0.0" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +type-fest@^0.13.1: + version "0.13.1" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz" + integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg== + +unicorn-magic@^0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz" + integrity sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +well-known-symbols@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/well-known-symbols/-/well-known-symbols-2.0.0.tgz" + integrity sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== dependencies: - ansi-styles: "npm:^4.0.0" - string-width: "npm:^4.1.0" - strip-ansi: "npm:^6.0.0" - checksum: 10c0/d15fc12c11e4cbc4044a552129ebc75ee3f57aa9c1958373a4db0292d72282f54373b536103987a4a7594db1ef6a4f10acf92978f79b98c49306a4b58c77d4da - languageName: node - linkType: hard + tr46 "~0.0.3" + webidl-conversions "^3.0.0" -"wrappy@npm:1": - version: 1.0.2 - resolution: "wrappy@npm:1.0.2" - checksum: 10c0/56fece1a4018c6a6c8e28fbc88c87e0fbf4ea8fd64fc6c63b18f4acc4bd13e0ad2515189786dd2c30d3eec9663d70f4ecf699330002f8ccb547e4a18231fc9f0 - languageName: node - linkType: hard - -"write-file-atomic@npm:^6.0.0": - version: 6.0.0 - resolution: "write-file-atomic@npm:6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== dependencies: - imurmurhash: "npm:^0.1.4" - signal-exit: "npm:^4.0.1" - checksum: 10c0/ae2f1c27474758a9aca92037df6c1dd9cb94c4e4983451210bd686bfe341f142662f6aa5913095e572ab037df66b1bfe661ed4ce4c0369ed0e8219e28e141786 - languageName: node - linkType: hard + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" -"y18n@npm:^5.0.5": - version: 5.0.8 - resolution: "y18n@npm:5.0.8" - checksum: 10c0/4df2842c36e468590c3691c894bc9cdbac41f520566e76e24f59401ba7d8b4811eb1e34524d57e54bc6d864bcb66baab7ffd9ca42bf1eda596618f9162b91249 - languageName: node - linkType: hard +wrappy@1: + version "1.0.2" + resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== -"yallist@npm:^5.0.0": - version: 5.0.0 - resolution: "yallist@npm:5.0.0" - checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 - languageName: node - linkType: hard - -"yargs-parser@npm:^21.1.1": - version: 21.1.1 - resolution: "yargs-parser@npm:21.1.1" - checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 - languageName: node - linkType: hard - -"yargs@npm:^17.7.2": - version: 17.7.2 - resolution: "yargs@npm:17.7.2" +write-file-atomic@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz" + integrity sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ== dependencies: - cliui: "npm:^8.0.1" - escalade: "npm:^3.1.1" - get-caller-file: "npm:^2.0.5" - require-directory: "npm:^2.1.1" - string-width: "npm:^4.2.3" - y18n: "npm:^5.0.5" - yargs-parser: "npm:^21.1.1" - checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05 - languageName: node - linkType: hard + imurmurhash "^0.1.4" + signal-exit "^4.0.1" + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz" + integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" From 66cd9bfc61d6f5a0b6eab75982ca7805e7571c22 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 19:01:27 +0100 Subject: [PATCH 090/206] Build and test the Node.js wrapper in CI --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 854114f9..81b13039 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,16 @@ jobs: maturin build --target ${{ matrix.target.triple }} --release + - name: Build the Node.js wrapper + working-directory: nodejs + run: | + npm install + npm run build + + - name: Run the Node.js wrapper's tests + working-directory: nodejs + run: npm test + - name: Build the CXX wrapper layer working-directory: cxx run: cargo build --release --target ${{ matrix.target.triple }} From db8984096f4dfd4dd114e71fa22bbf05a0e51b23 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 19:30:15 +0100 Subject: [PATCH 091/206] Set nodejs wrapper version to 0.26.0 --- Cargo.lock | 2 +- nodejs/Cargo.toml | 2 +- nodejs/npm/linux-x64-gnu/package.json | 4 ++-- nodejs/npm/win32-x64-msvc/package.json | 4 ++-- nodejs/package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 758ad4bd..08562c35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -638,7 +638,7 @@ dependencies = [ [[package]] name = "libloot-nodejs" -version = "0.0.0" +version = "0.26.0" dependencies = [ "libloot", "libloot-ffi-errors", diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml index 0955c784..07e0ba7b 100644 --- a/nodejs/Cargo.toml +++ b/nodejs/Cargo.toml @@ -1,7 +1,7 @@ [package] edition = "2021" name = "libloot-nodejs" -version = "0.0.0" +version = "0.26.0" [lib] crate-type = ["cdylib"] diff --git a/nodejs/npm/linux-x64-gnu/package.json b/nodejs/npm/linux-x64-gnu/package.json index 9f590740..870e000e 100644 --- a/nodejs/npm/linux-x64-gnu/package.json +++ b/nodejs/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "libloot-nodejs-linux-x64-gnu", - "version": "0.0.0", + "version": "0.26.0", "os": [ "linux" ], @@ -18,4 +18,4 @@ "libc": [ "glibc" ] -} \ No newline at end of file +} diff --git a/nodejs/npm/win32-x64-msvc/package.json b/nodejs/npm/win32-x64-msvc/package.json index 73b1720c..8d98ecfe 100644 --- a/nodejs/npm/win32-x64-msvc/package.json +++ b/nodejs/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "libloot-nodejs-win32-x64-msvc", - "version": "0.0.0", + "version": "0.26.0", "os": [ "win32" ], @@ -15,4 +15,4 @@ "engines": { "node": ">= 10" } -} \ No newline at end of file +} diff --git a/nodejs/package.json b/nodejs/package.json index af87a237..5f534b89 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "libloot-nodejs", - "version": "0.0.0", + "version": "0.26.0", "main": "index.js", "types": "index.d.ts", "napi": { From 724b8b55c1a6f930d94705e9d8900ba74c864058 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 19 Apr 2025 20:26:12 +0100 Subject: [PATCH 092/206] Deny most of clippy's pedantic lints They suggested a lot of good code style improvements and spotted a few mistakes. --- src/archive/ba2.rs | 7 +-- src/archive/bsa.rs | 11 ++--- src/archive/error.rs | 17 +++---- src/archive/find.rs | 56 +++++++++------------ src/archive/mod.rs | 4 +- src/archive/parse.rs | 38 +++++++-------- src/database/conditions.rs | 13 ++--- src/database/mod.rs | 17 ++++--- src/error.rs | 6 +-- src/game.rs | 39 +++++++-------- src/lib.rs | 6 +++ src/logging.rs | 33 ++++++------- src/metadata/error.rs | 49 +++++++------------ src/metadata/group.rs | 6 +-- src/metadata/message.rs | 9 ++-- src/metadata/metadata_document.rs | 49 +++++++++---------- src/metadata/plugin_cleaning_data.rs | 29 +++++------ src/metadata/plugin_metadata.rs | 41 ++++++++-------- src/metadata/yaml/emit.rs | 6 +-- src/metadata/yaml/merge.rs | 4 +- src/plugin/mod.rs | 73 +++++++++++++--------------- src/sorting/error.rs | 15 +++--- src/sorting/groups.rs | 46 ++++++++---------- src/sorting/plugins.rs | 40 ++++++++------- src/tests.rs | 5 +- 25 files changed, 288 insertions(+), 331 deletions(-) diff --git a/src/archive/ba2.rs b/src/archive/ba2.rs index b8ea5d19..23cf8480 100644 --- a/src/archive/ba2.rs +++ b/src/archive/ba2.rs @@ -77,9 +77,10 @@ pub(super) fn read_assets( let file_path_bytes = trim_slashes(&file_path_bytes); - let (folder_hash, file_hash) = rsplit_on(file_path_bytes, b'\\') - .map(|(folder_path, file_path)| (hash(&folder_path), hash(&file_path))) - .unwrap_or_else(|| (0, hash(&file_path_bytes))); + let (folder_hash, file_hash) = rsplit_on(file_path_bytes, b'\\').map_or_else( + || (0, hash(&file_path_bytes)), + |(folder_path, file_path)| (hash(&folder_path), hash(&file_path)), + ); let file_hashes: &mut BTreeSet = assets.entry(folder_hash).or_default(); diff --git a/src/archive/bsa.rs b/src/archive/bsa.rs index b9f84020..60d328ee 100644 --- a/src/archive/bsa.rs +++ b/src/archive/bsa.rs @@ -173,13 +173,10 @@ fn read_assets_with_header( } }; - let file_records_buffer = match file_records_buffer.get(file_records_offset..) { - Some(s) => s, - None => { - return Err(ArchiveParsingError::InvalidFileRecordsOffset( - file_records_offset, - )); - } + let Some(file_records_buffer) = file_records_buffer.get(file_records_offset..) else { + return Err(ArchiveParsingError::InvalidFileRecordsOffset( + file_records_offset, + )); }; let file_hashes: &mut BTreeSet = entry.or_default(); diff --git a/src/archive/error.rs b/src/archive/error.rs index 8dafe31b..379f6628 100644 --- a/src/archive/error.rs +++ b/src/archive/error.rs @@ -53,27 +53,26 @@ impl std::fmt::Display for ArchiveParsingError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::IoError(_) => write!(f, "an I/O error occurred"), - Self::UnsupportedHeaderVersion(v) => write!(f, "unsupported archive version {}", v), - Self::UnsupportedHeaderArchiveType(a) => write!(f, "unsupported archive type {:?}", a), - Self::UnsupportedArchiveTypeId(t) => write!(f, "unsupported archive type ID {:?}", t), - Self::InvalidRecordsOffset(o) => write!(f, "invalid records offset {}", o), + Self::UnsupportedHeaderVersion(v) => write!(f, "unsupported archive version {v}"), + Self::UnsupportedHeaderArchiveType(a) => write!(f, "unsupported archive type {a:?}"), + Self::UnsupportedArchiveTypeId(t) => write!(f, "unsupported archive type ID {t:?}"), + Self::InvalidRecordsOffset(o) => write!(f, "invalid records offset {o}"), Self::InvalidFolderNameLengthOffset(o) => { - write!(f, "invalid folder name length offset {}", o) + write!(f, "invalid folder name length offset {o}") } - Self::InvalidFileRecordsOffset(o) => write!(f, "invalid file records offset {}", o), + Self::InvalidFileRecordsOffset(o) => write!(f, "invalid file records offset {o}"), Self::UsesBigEndianNumbers => { write!(f, "archive uses big-endian numbers, which is unsupported") } Self::FolderHashCollision(h) => { - write!(f, "unexpected collision for folder name hash {:x}", h) + write!(f, "unexpected collision for folder name hash {h:x}") } Self::HashCollision { folder_hash, file_hash, } => write!( f, - "unexpected collision for file name hash {:x} in set for folder name hash {:x}", - file_hash, folder_hash + "unexpected collision for file name hash {file_hash:x} in set for folder name hash {folder_hash:x}" ), } } diff --git a/src/archive/find.rs b/src/archive/find.rs index a6e87061..93f5785c 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -62,9 +62,8 @@ fn find_associated_archives_with_suffixes( archive_extension: &str, supported_suffixes: &[&str], ) -> Vec { - let file_stem = match plugin_path.file_stem() { - Some(s) => s, - None => return Vec::new(), + let Some(file_stem) = plugin_path.file_stem() else { + return Vec::new(); }; supported_suffixes @@ -89,9 +88,8 @@ fn find_associated_archives_with_arbitrary_suffixes( Some(s) => s.len(), None => return Vec::new(), }; - let plugin_extension = match plugin_path.extension() { - Some(e) => e, - None => return Vec::new(), + let Some(plugin_extension) = plugin_path.extension() else { + return Vec::new(); }; game_cache @@ -101,9 +99,8 @@ fn find_associated_archives_with_arbitrary_suffixes( // but case insensitively. This is hard to do accurately, so // instead check if the plugin with the same length basename and // and the given plugin's file extension is equivalent. - let archive_filename = match path.file_name().and_then(|s| s.to_str()) { - Some(f) => f, - None => return false, + let Some(archive_filename) = path.file_name().and_then(|s| s.to_str()) else { + return false; }; // Can't just slice the archive filename to the same length as the plugin file stem directly because that might not slice on a character boundary, so truncate the byte slice and then check it's still valid UTF-8. @@ -111,11 +108,10 @@ fn find_associated_archives_with_arbitrary_suffixes( return false; } - let filename = - match std::str::from_utf8(&archive_filename.as_bytes()[..plugin_stem_len]) { - Ok(f) => f, - Err(_) => return false, - }; + let Ok(filename) = std::str::from_utf8(&archive_filename.as_bytes()[..plugin_stem_len]) + else { + return false; + }; let archive_plugin_path = plugin_path .with_file_name(filename) @@ -129,10 +125,6 @@ fn find_associated_archives_with_arbitrary_suffixes( #[cfg(windows)] fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { - if lhs == rhs { - return true; - } - use std::fs::File; use std::os::windows::io::AsRawHandle; use windows::Win32::{ @@ -140,14 +132,16 @@ fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { Storage::FileSystem::{BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle}, }; - let lhs_file = match File::open(lhs) { - Ok(f) => f, - Err(_) => return false, + if lhs == rhs { + return true; + } + + let Ok(lhs_file) = File::open(lhs) else { + return false; }; - let rhs_file = match File::open(rhs) { - Ok(f) => f, - Err(_) => return false, + let Ok(rhs_file) = File::open(rhs) else { + return false; }; let mut lhs_info = BY_HANDLE_FILE_INFORMATION::default(); @@ -170,20 +164,18 @@ fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { #[cfg(not(windows))] fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + if lhs == rhs { return true; } - use std::os::unix::fs::MetadataExt; - - let lhs_metadata = match lhs.metadata() { - Ok(m) => m, - _ => return false, + let Ok(lhs_metadata) = lhs.metadata() else { + return false; }; - let rhs_metadata = match rhs.metadata() { - Ok(m) => m, - _ => return false, + let Ok(rhs_metadata) = rhs.metadata() else { + return false; }; lhs_metadata.dev() == rhs_metadata.dev() && lhs_metadata.ino() == rhs_metadata.ino() diff --git a/src/archive/mod.rs b/src/archive/mod.rs index 950fbeb9..936a3ae9 100644 --- a/src/archive/mod.rs +++ b/src/archive/mod.rs @@ -61,9 +61,9 @@ mod tests { let path = PathBuf::from("./testing-plugins/Skyrim/Data/Blank.bsa"); let assets2 = assets_in_archives(&[path]); - assert_eq!(assets1.get(&0), assets2.get(&0x2E01002E)); + assert_eq!(assets1.get(&0), assets2.get(&0x2E01_002E)); - assert!(!do_assets_overlap(&assets1, &assets2)) + assert!(!do_assets_overlap(&assets1, &assets2)); } } } diff --git a/src/archive/parse.rs b/src/archive/parse.rs index 6487b2dc..c89f0bd1 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -141,7 +141,7 @@ mod tests { let path = Path::new("./testing-plugins/Oblivion/Data/Blank.bsa"); let assets = get_assets_in_archive(path).unwrap(); - let files_count: usize = assets.values().map(|v| v.len()).sum(); + let files_count: usize = assets.values().map(BTreeSet::len).sum(); let expected_key = 0; assert_eq!(1, assets.len()); @@ -149,7 +149,7 @@ mod tests { assert_eq!(expected_key, *assets.first_key_value().unwrap().0); assert_eq!(1, assets.get(&expected_key).unwrap().len()); assert_eq!( - 0x4670B6836C077365, + 0x4670_B683_6C07_7365, *assets.get(&expected_key).unwrap().first().unwrap() ); } @@ -159,15 +159,15 @@ mod tests { let path = Path::new("./testing-plugins/Skyrim/Data/Blank.bsa"); let assets = get_assets_in_archive(path).unwrap(); - let files_count: usize = assets.values().map(|v| v.len()).sum(); + let files_count: usize = assets.values().map(BTreeSet::len).sum(); - let expected_key = 0x2E01002E; + let expected_key = 0x2E01_002E; assert_eq!(1, assets.len()); assert_eq!(1, files_count); assert_eq!(expected_key, *assets.first_key_value().unwrap().0); assert_eq!(1, assets.get(&expected_key).unwrap().len()); assert_eq!( - 0x4670B6836C077365, + 0x4670_B683_6C07_7365, *assets.get(&expected_key).unwrap().first().unwrap() ); } @@ -177,15 +177,15 @@ mod tests { let path = Path::new("./testing-plugins/SkyrimSE/Data/Blank.bsa"); let assets = get_assets_in_archive(path).unwrap(); - let files_count: usize = assets.values().map(|v| v.len()).sum(); + let files_count: usize = assets.values().map(BTreeSet::len).sum(); - let expected_key = 0xB68102C964176E73; + let expected_key = 0xB681_02C9_6417_6E73; assert_eq!(1, assets.len()); assert_eq!(1, files_count); assert_eq!(expected_key, *assets.first_key_value().unwrap().0); assert_eq!(1, assets.get(&expected_key).unwrap().len()); assert_eq!( - 0x4670B6836C077365, + 0x4670_B683_6C07_7365, *assets.get(&expected_key).unwrap().first().unwrap() ); } @@ -195,7 +195,7 @@ mod tests { let path = Path::new("./testing-plugins/Fallout 4/Data/Blank - Main.ba2"); let assets = get_assets_in_archive(path).unwrap(); - let files_count: usize = assets.values().map(|v| v.len()).sum(); + let files_count: usize = assets.values().map(BTreeSet::len).sum(); let expected_key = hash("dev\\git\\testing-plugins".as_bytes()); let expected_file_hash = hash("license.txt".as_bytes()); @@ -214,7 +214,7 @@ mod tests { let path = Path::new("./testing-plugins/Fallout 4/Data/Blank - Textures.ba2"); let assets = get_assets_in_archive(path).unwrap(); - let files_count: usize = assets.values().map(|v| v.len()).sum(); + let files_count: usize = assets.values().map(BTreeSet::len).sum(); let expected_key = hash("dev\\git\\testing-plugins".as_bytes()); let expected_file_hash = hash("blank.dds".as_bytes()); @@ -260,15 +260,15 @@ mod tests { let assets = assets_in_archives(&paths); - let files_count: usize = assets.values().map(|v| v.len()).sum(); + let files_count: usize = assets.values().map(BTreeSet::len).sum(); assert_eq!(1, assets.len()); assert_eq!(1, files_count); let (key, value) = assets.first_key_value().unwrap(); - assert_eq!(0x2E01002E, *key); + assert_eq!(0x2E01_002E, *key); assert_eq!(1, value.len()); - assert_eq!(0x4670B6836C077365, *value.first().unwrap()); + assert_eq!(0x4670_B683_6C07_7365, *value.first().unwrap()); } #[test] @@ -281,22 +281,22 @@ mod tests { let assets = assets_in_archives(&paths); - let files_count: usize = assets.values().map(|v| v.len()).sum(); + let files_count: usize = assets.values().map(BTreeSet::len).sum(); assert_eq!(3, assets.len()); assert_eq!(3, files_count); let value = assets.get(&0).unwrap(); assert_eq!(1, value.len()); - assert_eq!(0x4670B6836C077365, *value.first().unwrap()); + assert_eq!(0x4670_B683_6C07_7365, *value.first().unwrap()); - let value = assets.get(&0x2E01002E).unwrap(); + let value = assets.get(&0x2E01_002E).unwrap(); assert_eq!(1, value.len()); - assert_eq!(0x4670B6836C077365, *value.first().unwrap()); + assert_eq!(0x4670_B683_6C07_7365, *value.first().unwrap()); - let value = assets.get(&0xB68102C964176E73).unwrap(); + let value = assets.get(&0xB681_02C9_6417_6E73).unwrap(); assert_eq!(1, value.len()); - assert_eq!(0x4670B6836C077365, *value.first().unwrap()); + assert_eq!(0x4670_B683_6C07_7365, *value.first().unwrap()); } } } diff --git a/src/database/conditions.rs b/src/database/conditions.rs index d30a3e5a..3fd83081 100644 --- a/src/database/conditions.rs +++ b/src/database/conditions.rs @@ -132,9 +132,10 @@ mod tests { plugin.set_group("group1".into()); let condition = "file(\"missing.esp\")".to_string(); - let file1 = File::new(BLANK_ESP.into()); - let file2 = File::new(BLANK_DIFFERENT_ESM.into()).with_condition(condition.clone()); - let files = vec![file1.clone(), file2]; + let files = vec![ + File::new(BLANK_ESP.into()), + File::new(BLANK_DIFFERENT_ESM.into()).with_condition(condition.clone()), + ]; plugin.set_load_after_files(files.clone()); plugin.set_requirements(files.clone()); plugin.set_incompatibilities(files.clone()); @@ -149,8 +150,8 @@ mod tests { Tag::new("Relev".into(), TagSuggestion::Addition).with_condition(condition.clone()); plugin.set_tags(vec![tag1.clone(), tag2]); - let info1 = PluginCleaningData::new(0x374E2A6F, "utility1".into()); - let info2 = PluginCleaningData::new(0xDEADBEEF, "utility2".into()); + let info1 = PluginCleaningData::new(0x374E_2A6F, "utility1".into()); + let info2 = PluginCleaningData::new(0xDEAD_BEEF, "utility2".into()); plugin.set_dirty_info(vec![info1.clone(), info2.clone()]); plugin.set_clean_info(vec![info1.clone(), info2.clone()]); @@ -160,7 +161,7 @@ mod tests { ); let result = evaluate_all_conditions(plugin, &state).unwrap().unwrap(); - let expected_files = &[file1]; + let expected_files = &[files[0].clone()]; let expected_info = &[info1]; assert_eq!("group1", result.group().unwrap()); assert_eq!(expected_files, result.load_after_files()); diff --git a/src/database/mod.rs b/src/database/mod.rs index f08f78f2..8897b772 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -116,8 +116,11 @@ impl Database { let mut doc = MetadataDocument::default(); for plugin in self.masterlist.plugins_iter() { - let mut minimal_plugin = PluginMetadata::new(plugin.name()) - .expect("Regex plugin name from existing PluginMetadata object is valid"); + let Ok(mut minimal_plugin) = PluginMetadata::new(plugin.name()) else { + // This should never happen because the regex plugin name from + // an existing PluginMetadata object should be valid. + continue; + }; minimal_plugin.set_tags(plugin.tags().to_vec()); minimal_plugin.set_dirty_info(plugin.dirty_info().to_vec()); @@ -300,7 +303,7 @@ impl Database { } fn validate_write_path(output_path: &Path, mode: WriteMode) -> Result<(), WriteMetadataError> { - if !output_path.parent().map(|p| p.exists()).unwrap_or(false) { + if !output_path.parent().is_some_and(Path::exists) { Err(WriteMetadataError::new( output_path.into(), WriteMetadataErrorReason::ParentDirectoryNotFound, @@ -975,7 +978,7 @@ plugins: .plugin_metadata(BLANK_ESM, true, false) .unwrap() .is_none() - ) + ); } #[test] @@ -990,7 +993,7 @@ plugins: .plugin_metadata(BLANK_ESM, true, false) .unwrap() .is_none() - ) + ); } #[test] @@ -1079,7 +1082,7 @@ plugins: .plugin_user_metadata(BLANK_ESM, false) .unwrap() .is_none() - ) + ); } #[test] @@ -1094,7 +1097,7 @@ plugins: .plugin_user_metadata(BLANK_ESM, false) .unwrap() .is_none() - ) + ); } #[test] diff --git a/src/error.rs b/src/error.rs index fc9a4a71..28372120 100644 --- a/src/error.rs +++ b/src/error.rs @@ -209,10 +209,10 @@ impl std::fmt::Display for SortPluginsError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::DatabaseLockPoisoned => DatabaseLockPoisonError.fmt(f), - Self::PluginNotLoaded(n) => write!(f, "the plugin \"{}\" has not been loaded", n), - Self::UndefinedGroup(g) => write!(f, "the group \"{}\" does not exist", g), + Self::PluginNotLoaded(n) => write!(f, "the plugin \"{n}\" has not been loaded"), + Self::UndefinedGroup(g) => write!(f, "the group \"{g}\" does not exist"), Self::CycleFound(c) => write!(f, "found a cycle: {}", display_cycle(c)), - Self::CycleFoundInvolving(n) => write!(f, "found a cycle involving \"{}\"", n), + Self::CycleFoundInvolving(n) => write!(f, "found a cycle involving \"{n}\""), Self::PluginDataError(_) => write!(f, "failed to read loaded plugin data"), Self::MetadataRetrievalError(_) => write!(f, "failed to retrieve plugin metadata"), Self::PathfindingError(_) => write!(f, "failed to find a path in the plugins graph"), diff --git a/src/game.rs b/src/game.rs index 25c2333e..bc5f848b 100644 --- a/src/game.rs +++ b/src/game.rs @@ -446,7 +446,7 @@ impl Game { .map(|n| { self.cache .plugin(n) - .ok_or_else(|| SortPluginsError::PluginNotLoaded(n.to_string())) + .ok_or_else(|| SortPluginsError::PluginNotLoaded((*n).to_owned())) }) .collect::, _>>()?; @@ -646,7 +646,7 @@ fn find_archives_in_path( } let paths = std::fs::read_dir(parent_path)? - .filter_map(|e| e.ok()) + .filter_map(Result::ok) .filter(|e| { e.file_type().map(|f| f.is_file()).unwrap_or(false) && iends_with_ascii(&e.file_name().to_string_lossy(), archive_file_extension) @@ -838,8 +838,8 @@ mod tests { // The paths may contain forward slashes, which cmd doesn't accept, // so replace them. - let original = OsString::from(original.to_str().unwrap().replace("/", "\\")); - let link = OsString::from(link.to_str().unwrap().replace("/", "\\")); + let original = OsString::from(original.to_str().unwrap().replace('/', "\\")); + let link = OsString::from(link.to_str().unwrap().replace('/', "\\")); let status = std::process::Command::new("cmd") .args([ @@ -895,7 +895,7 @@ mod tests { relative_components .into_iter() - .map(|c| c.as_os_str()) + .map(Component::as_os_str) .collect() } @@ -966,9 +966,7 @@ mod tests { fn should_error_if_given_a_game_path_that_does_not_exist() { let game_path = Path::new("missing"); match Game::new(GameType::TES3, game_path) { - Err(GameHandleCreationError::NotADirectory(p)) => { - assert_eq!(game_path, p) - } + Err(GameHandleCreationError::NotADirectory(p)) => assert_eq!(game_path, p), _ => panic!("Expected a not-a-directory error"), } } @@ -1062,9 +1060,7 @@ mod tests { let game = Game::with_local_path(fixture.game_type, game_path, &fixture.local_path); match game { - Err(GameHandleCreationError::NotADirectory(p)) => { - assert_eq!(game_path, p) - } + Err(GameHandleCreationError::NotADirectory(p)) => assert_eq!(game_path, p), _ => panic!("Expected a not-a-directory error"), } } @@ -1089,9 +1085,7 @@ mod tests { let game = Game::with_local_path(fixture.game_type, &fixture.game_path, local_path); match game { - Err(GameHandleCreationError::NotADirectory(p)) => { - assert_eq!(local_path, p) - } + Err(GameHandleCreationError::NotADirectory(p)) => assert_eq!(local_path, p), _ => panic!("Expected a not-a-directory error"), } } @@ -1246,7 +1240,7 @@ mod tests { game.load_current_load_order_state().unwrap(); let mut load_order: Vec<_> = - game.load_order().iter().map(|s| s.to_string()).collect(); + game.load_order().iter().map(ToString::to_string).collect(); let filename = "plugin.esp"; let data_file_path = fixture @@ -1374,7 +1368,7 @@ mod tests { if game_type == GameType::OpenMW { std::fs::rename( &path, - path.with_file_name(format!("{}.ghost", BLANK_MASTER_DEPENDENT_ESM)), + path.with_file_name(format!("{BLANK_MASTER_DEPENDENT_ESM}.ghost")), ) .unwrap(); @@ -1625,10 +1619,10 @@ mod tests { match source.downcast_ref::().unwrap() { esplugin::Error::PluginMetadataNotFound(p) => { if game_type == GameType::Starfield { - assert_eq!(BLANK_FULL_ESM, p) + assert_eq!(BLANK_FULL_ESM, p); } else { - assert_eq!(BLANK_ESM, p) - }; + assert_eq!(BLANK_ESM, p); + } } _ => panic!("Unexpected esplugin error: {e}"), } @@ -1873,7 +1867,7 @@ mod tests { let path = fixture .data_path() - .join(format!("{}.ghost", BLANK_MASTER_DEPENDENT_ESM)); + .join(format!("{BLANK_MASTER_DEPENDENT_ESM}.ghost")); let plugins = game .load_plugins_common(&[&path], LoadScope::HeaderOnly) @@ -2007,9 +2001,10 @@ mod tests { game.load_current_load_order_state().unwrap(); - let mut load_order: Vec<_> = game.load_order().iter().map(|s| s.to_string()).collect(); + let mut load_order: Vec<_> = + game.load_order().iter().map(ToString::to_string).collect(); load_order.swap(7, 10); - let load_order: Vec<_> = load_order.iter().map(|s| s.as_str()).collect(); + let load_order: Vec<_> = load_order.iter().map(String::as_str).collect(); game.set_load_order(&load_order).unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 1b880ca6..2d019c77 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,9 @@ +#![deny(clippy::pedantic)] +// Allow a few clippy pedantic lints. +#![allow(clippy::doc_markdown)] +#![allow(clippy::must_use_candidate)] +#![allow(clippy::missing_errors_doc)] + mod archive; mod database; pub mod error; diff --git a/src/logging.rs b/src/logging.rs index 8b218dd8..86639da8 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -68,8 +68,7 @@ impl From for log::Level { LogLevel::Debug => log::Level::Debug, LogLevel::Info => log::Level::Info, LogLevel::Warning => log::Level::Warn, - LogLevel::Error => log::Level::Error, - LogLevel::Fatal => log::Level::Error, + LogLevel::Error | LogLevel::Fatal => log::Level::Error, } } } @@ -89,7 +88,7 @@ impl Logger { pub(crate) fn log(&self, level: LogLevel, message: &str) { if level >= self.level { - (self.callback)(level, message) + (self.callback)(level, message); } } @@ -166,7 +165,7 @@ pub(crate) fn format_details(error: &E) -> String { let mut details = error.to_string(); // The display string. if let Some(source) = error.source() { details += ": "; - details += &format_details(&source) + details += &format_details(&source); } details @@ -188,8 +187,6 @@ mod tests { #[test] fn should_support_a_function() { - let _lock = TEST_LOCK.lock().unwrap(); - static MESSAGES: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); @@ -199,6 +196,8 @@ mod tests { } } + let _lock = TEST_LOCK.lock().unwrap(); + set_logging_callback(callback); error!("Test message"); @@ -233,6 +232,15 @@ mod tests { #[test] fn set_logging_callback_should_be_callable_multiple_times() { + static MESSAGES: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + + fn callback_fn(level: LogLevel, message: &str) { + if let Ok(mut messages) = MESSAGES.lock() { + messages.push((level, message.to_string())); + } + } + let _lock = TEST_LOCK.lock().unwrap(); let callback = |_, _: &str| {}; @@ -255,15 +263,6 @@ mod tests { *messages.lock().unwrap() ); - static MESSAGES: LazyLock>> = - LazyLock::new(|| Mutex::new(Vec::new())); - - fn callback_fn(level: LogLevel, message: &str) { - if let Ok(mut messages) = MESSAGES.lock() { - messages.push((level, message.to_string())); - } - } - set_logging_callback(callback_fn); error!("Test message"); @@ -280,8 +279,6 @@ mod tests { #[test] fn should_set_the_level_used_to_filter_messages_passed_to_the_callback() { - let _lock = TEST_LOCK.lock().unwrap(); - static MESSAGES: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); @@ -291,6 +288,8 @@ mod tests { } } + let _lock = TEST_LOCK.lock().unwrap(); + set_logging_callback(callback); set_log_level(LogLevel::Warning); diff --git a/src/metadata/error.rs b/src/metadata/error.rs index e79a0f77..4d25761e 100644 --- a/src/metadata/error.rs +++ b/src/metadata/error.rs @@ -142,42 +142,34 @@ impl std::fmt::Display for MetadataParsingErrorReason { Self::InvalidCondition(b) => { write!(f, "the condition string \"{}\" is invalid", b.0) } - Self::MissingKey(key, yaml_object_type) => write!( - f, - "\"{}\" key in \"{}\" map is missing", - key, yaml_object_type - ), + Self::MissingKey(key, yaml_object_type) => { + write!(f, "\"{key}\" key in \"{yaml_object_type}\" map is missing") + } Self::InvalidRegex(_) => { write!(f, "invalid regex in \"name\" key") } Self::InvalidMultilingualMessageContents => MultilingualMessageContentsError.fmt(f), - Self::UnexpectedType(expected_type, yaml_object_type) => write!( - f, - "\"{}\" object must be {}", - yaml_object_type, expected_type - ), + Self::UnexpectedType(expected_type, yaml_object_type) => { + write!(f, "\"{yaml_object_type}\" object must be {expected_type}") + } Self::UnexpectedValueType(key, expected_type, yaml_object_type) => write!( f, - "\"{}\" key in \"{}\" map must be {}", - key, yaml_object_type, expected_type + "\"{key}\" key in \"{yaml_object_type}\" map must be {expected_type}" ), Self::MissingPlaceholder(sub, placeholder_index) => write!( f, - "failed to substitute \"{}\" into message, no placeholder {{{}}} was found", - sub, placeholder_index + "failed to substitute \"{sub}\" into message, no placeholder {{{placeholder_index}}} was found" ), Self::MissingSubstitution(placeholder) => write!( f, - "failed to substitute a value into message, no substitution was given for the placeholder \"{}\"", - placeholder + "failed to substitute a value into message, no substitution was given for the placeholder \"{placeholder}\"" ), Self::NonU32Number(i) => { - write!(f, "{} is not valid as a 32-bit unsigned integer", i) + write!(f, "{i} is not valid as a 32-bit unsigned integer") } Self::DuplicateEntry(id, yaml_object_type) => write!( f, - "more than one entry exists for {} \"{}\"", - yaml_object_type, id + "more than one entry exists for {yaml_object_type} \"{id}\"" ), Self::Other(m) => m.fmt(f), } @@ -239,15 +231,12 @@ pub struct LoadMetadataError { impl LoadMetadataError { pub(super) fn new(path: PathBuf, reason: MetadataDocumentParsingError) -> Self { - Self { - path: path.to_path_buf(), - reason, - } + Self { path, reason } } pub(super) fn from_io_error(path: PathBuf, error: std::io::Error) -> Self { Self { - path: path.to_path_buf(), + path, reason: MetadataDocumentParsingError::IoError(error), } } @@ -281,7 +270,7 @@ impl std::fmt::Display for MetadataDocumentParsingError { match self { Self::PathNotFound => write!(f, "path not found"), Self::NoDocuments => write!(f, "no YAML document found"), - Self::MoreThanOneDocument(n) => write!(f, "expected 1 YAML document, found {}", n), + Self::MoreThanOneDocument(n) => write!(f, "expected 1 YAML document, found {n}"), Self::IoError(_) => write!(f, "an I/O error occurred"), Self::MetadataParsingError(_) => write!(f, "a metadata parsing error occurred"), Self::YamlMergeKeyError(_) => { @@ -294,9 +283,7 @@ impl std::fmt::Display for MetadataDocumentParsingError { impl std::error::Error for MetadataDocumentParsingError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - Self::PathNotFound => None, - Self::NoDocuments => None, - Self::MoreThanOneDocument(_) => None, + Self::PathNotFound | Self::NoDocuments | Self::MoreThanOneDocument(_) => None, Self::IoError(e) => Some(e), Self::MetadataParsingError(e) => Some(e), Self::YamlMergeKeyError(e) => Some(e), @@ -335,10 +322,10 @@ pub(super) struct YamlMergeKeyError { } impl YamlMergeKeyError { - pub(super) fn new(value: saphyr::MarkedYaml) -> Self { + pub(super) fn new(value: &saphyr::MarkedYaml) -> Self { let mut yaml = String::new(); - let unmarked_yaml = to_unmarked_yaml(&value); + let unmarked_yaml = to_unmarked_yaml(value); if saphyr::YamlEmitter::new(&mut yaml) .dump(&unmarked_yaml) @@ -350,7 +337,7 @@ impl YamlMergeKeyError { yaml = yaml.split_off(index); } } else { - yaml = format!("{:?}", yaml); + yaml = format!("{yaml:?}"); } YamlMergeKeyError { diff --git a/src/metadata/group.rs b/src/metadata/group.rs index 89483b3f..14615acd 100644 --- a/src/metadata/group.rs +++ b/src/metadata/group.rs @@ -78,8 +78,8 @@ impl std::default::Default for Group { fn default() -> Self { Self { name: Group::DEFAULT_NAME.into(), - description: Default::default(), - after_groups: Default::default(), + description: Option::default(), + after_groups: Box::default(), } } } @@ -98,7 +98,7 @@ impl TryFromYaml for Group { Ok(Group { name: name.into(), description: description.map(|d| d.1.into()), - after_groups: after.iter().map(|a| a.to_string()).collect(), + after_groups: after.into_iter().map(str::to_owned).collect(), }) } } diff --git a/src/metadata/message.rs b/src/metadata/message.rs index cf5070e7..c976a758 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -91,7 +91,7 @@ impl std::default::Default for MessageContent { #[must_use] fn default() -> Self { Self { - text: Default::default(), + text: Box::default(), language: MessageContent::DEFAULT_LANGUAGE.into(), } } @@ -343,12 +343,15 @@ impl TryFromYaml for Message { } for (index, sub) in subs.iter().enumerate() { - let placeholder = format!("{{{}}}", index); + let placeholder = format!("{{{index}}}"); if !mc.text.contains(&placeholder) { return Err(ParseMetadataError::new( value.span.start, - MetadataParsingErrorReason::MissingPlaceholder(sub.to_string(), index), + MetadataParsingErrorReason::MissingPlaceholder( + (*sub).to_owned(), + index, + ), )); } diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index be168d9f..c418a6f4 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -104,16 +104,13 @@ impl MetadataDocument { } let doc = process_merge_keys(doc)?; - let doc = match doc.data { - YamlData::Mapping(h) => h, - _ => { - return Err(ParseMetadataError::unexpected_type( - doc.span.start, - YamlObjectType::MetadataDocument, - ExpectedType::Map, - ) - .into()); - } + let YamlData::Mapping(doc) = doc.data else { + return Err(ParseMetadataError::unexpected_type( + doc.span.start, + YamlObjectType::MetadataDocument, + ExpectedType::Map, + ) + .into()); }; let mut plugins: HashMap = HashMap::new(); @@ -292,12 +289,12 @@ impl MetadataDocument { // Ensure that the default group is present. let default_group_exists = groups.iter().any(|g| g.name() == Group::DEFAULT_NAME); - if !default_group_exists { + if default_group_exists { + self.groups = groups; + } else { self.groups.clear(); self.groups.push(Group::default()); self.groups.extend(groups); - } else { - self.groups = groups; } } @@ -328,11 +325,11 @@ impl MetadataDocument { impl std::default::Default for MetadataDocument { fn default() -> Self { Self { - bash_tags: Default::default(), + bash_tags: Vec::default(), groups: vec![Group::default()], - messages: Default::default(), - plugins: Default::default(), - regex_plugins: Default::default(), + messages: Vec::default(), + plugins: HashMap::default(), + regex_plugins: Vec::default(), } } } @@ -349,7 +346,7 @@ fn replace_prelude(masterlist: String, prelude: &str) -> String { } fn detect_line_ending(masterlist: &str) -> &'static str { - if let Some(pos) = masterlist.rfind("\n") { + if let Some(pos) = masterlist.rfind('\n') { if pos == 0 { "\n" } else if masterlist.as_bytes()[pos - 1] == b'\r' { @@ -393,8 +390,8 @@ fn find_prelude_bounds(masterlist: &str) -> Option<(usize, usize)> { } fn indent_prelude(prelude: &str, line_ending: &str) -> String { - let prelude = ("\n ".to_string() + &prelude.replace("\n", "\n ")) - .replace(&format!(" {}", line_ending), line_ending); + let prelude = ("\n ".to_string() + &prelude.replace('\n', "\n ")) + .replace(&format!(" {line_ending}"), line_ending); if prelude.ends_with("\n ") { prelude[..prelude.len() - 2].to_string() @@ -459,7 +456,7 @@ plugins: #[test] fn load_from_str_should_resolve_aliases() { - let yaml = r#" + let yaml = r" prelude: - &anchor type: say @@ -467,7 +464,7 @@ plugins: globals: - *anchor - "#; + "; let mut metadata_list = MetadataDocument::default(); metadata_list.load_from_str(yaml).unwrap(); @@ -492,7 +489,7 @@ plugins: #[test] fn load_from_str_should_error_if_a_plugin_has_two_exact_entries() { - let yaml = r#" + let yaml = r" plugins: - name: 'Blank.esm' msg: @@ -503,7 +500,7 @@ plugins: msg: - type: error content: 'This plugin entry will cause a failure, as it is not the first exact entry.' - "#; + "; let mut metadata_list = MetadataDocument::default(); assert!(metadata_list.load_from_str(yaml).is_err()); @@ -547,7 +544,7 @@ plugins: fn load_should_error_if_an_invalid_metadata_file_is_given() { let tmp_dir = tempdir().unwrap(); let path = tmp_dir.path().join("masterlist.yaml"); - let yaml = r#" + let yaml = r" - 'C.Climate' - 'Relev' @@ -559,7 +556,7 @@ plugins: - name: 'Blank.+\.esp' after: - 'Blank.esm' - "#; + "; std::fs::write(&path, yaml).unwrap(); diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index 6c090189..ee3f28ee 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -140,15 +140,12 @@ impl TryFromYaml for PluginCleaningData { fn try_from_yaml(value: &MarkedYaml) -> Result { let mapping = as_mapping(value, YamlObjectType::PluginCleaningData)?; - let crc = match get_u32_value(mapping, "crc", YamlObjectType::PluginCleaningData)? { - Some(n) => n, - None => { - return Err(ParseMetadataError::missing_key( - value.span.start, - "crc", - YamlObjectType::PluginCleaningData, - )); - } + let Some(crc) = get_u32_value(mapping, "crc", YamlObjectType::PluginCleaningData)? else { + return Err(ParseMetadataError::missing_key( + value.span.start, + "crc", + YamlObjectType::PluginCleaningData, + )); }; let util = get_required_string_value( @@ -285,7 +282,7 @@ mod tests { let data = PluginCleaningData::try_from_yaml(&yaml).unwrap(); - assert_eq!(0x12345678, data.crc()); + assert_eq!(0x1234_5678, data.crc()); assert_eq!("cleaner", data.cleaning_utility()); assert_eq!(&[MessageContent::new("info".into())], data.detail()); assert_eq!(2, data.itm_count()); @@ -299,7 +296,7 @@ mod tests { let data = PluginCleaningData::try_from_yaml(&yaml).unwrap(); - assert_eq!(0x12345678, data.crc()); + assert_eq!(0x1234_5678, data.crc()); assert_eq!("cleaner", data.cleaning_utility()); assert!(data.detail().is_empty()); assert_eq!(0, data.itm_count()); @@ -354,7 +351,7 @@ mod tests { #[test] fn should_omit_zero_counts() { - let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()); + let data = PluginCleaningData::new(0xDEAD_BEEF, "TES5Edit".into()); let yaml = emit(&data); assert_eq!("crc: 0xDEADBEEF\nutil: 'TES5Edit'", yaml); @@ -362,7 +359,7 @@ mod tests { #[test] fn should_emit_non_zero_counts() { - let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()) + let data = PluginCleaningData::new(0xDEAD_BEEF, "TES5Edit".into()) .with_itm_count(1) .with_deleted_reference_count(2) .with_deleted_navmesh_count(3); @@ -376,7 +373,7 @@ mod tests { #[test] fn should_emit_map_with_a_detail_string_if_detail_is_monolingual() { - let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()) + let data = PluginCleaningData::new(0xDEAD_BEEF, "TES5Edit".into()) .with_detail(vec![MessageContent::new("message".into())]) .unwrap(); let yaml = emit(&data); @@ -392,7 +389,7 @@ mod tests { #[test] fn should_emit_map_with_a_detail_array_if_detail_is_multilingual() { - let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()) + let data = PluginCleaningData::new(0xDEAD_BEEF, "TES5Edit".into()) .with_detail(vec![ MessageContent::new("english".into()).with_language("en".into()), MessageContent::new("french".into()).with_language("fr".into()), @@ -420,7 +417,7 @@ detail: #[test] fn should_emit_map_with_all_fields_set() { - let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()) + let data = PluginCleaningData::new(0xDEAD_BEEF, "TES5Edit".into()) .with_itm_count(1) .with_deleted_reference_count(2) .with_deleted_navmesh_count(3) diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 1f2bfd9d..f60265bd 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -99,13 +99,13 @@ impl PluginMetadata { /// Set the plugin's group. pub fn set_group(&mut self, group: String) { - self.group = Some(group.into_boxed_str()) + self.group = Some(group.into_boxed_str()); } /// Unsets the plugin's group, so that it is implicitly a member of the /// default group. pub fn unset_group(&mut self) { - self.group = None + self.group = None; } /// Get the plugins that the plugin must load after. @@ -159,7 +159,7 @@ impl PluginMetadata { } if self.group.is_none() && plugin.group.is_some() { - self.group = plugin.group.clone(); + self.group.clone_from(&plugin.group); } merge_slices(&mut self.load_after, &plugin.load_after); @@ -344,11 +344,11 @@ fn merge_slices(target: &mut Box<[T]>, source: &[T]) { let mut vec = target.to_vec(); for element in source { if !target.contains(element) { - vec.push(element.clone()) + vec.push(element.clone()); } } - *target = vec.into_boxed_slice() + *target = vec.into_boxed_slice(); } fn replace_capturing_groups(regex_string: &str) -> Cow<'_, str> { @@ -356,9 +356,8 @@ fn replace_capturing_groups(regex_string: &str) -> Cow<'_, str> { let mut prefix_length = 0; let mut remainder = regex_string; while let Some(pos) = remainder.find('(') { - let (before, after) = match remainder.split_at_checked(pos + 1) { - Some(t) => t, - None => break, + let Some((before, after)) = remainder.split_at_checked(pos + 1) else { + break; }; if after.starts_with('?') || (before.ends_with("\\(") && !before.ends_with("\\\\(")) { @@ -679,9 +678,9 @@ mod tests { let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); - let data1 = PluginCleaningData::new(0x12345678, "util1".into()); - let data2 = PluginCleaningData::new(0xDEADBEEF, "util2".into()); - let data3 = PluginCleaningData::new(0xFEEDCAFE, "util3".into()); + let data1 = PluginCleaningData::new(0x1234_5678, "util1".into()); + let data2 = PluginCleaningData::new(0xDEAD_BEEF, "util2".into()); + let data3 = PluginCleaningData::new(0xFEED_CAFE, "util3".into()); plugin1.set_dirty_info(vec![data1.clone(), data2.clone()]); plugin2.set_dirty_info(vec![data1.clone(), data3.clone()]); @@ -698,9 +697,9 @@ mod tests { let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); - let data1 = PluginCleaningData::new(0x12345678, "util1".into()); - let data2 = PluginCleaningData::new(0xDEADBEEF, "util2".into()); - let data3 = PluginCleaningData::new(0xFEEDCAFE, "util3".into()); + let data1 = PluginCleaningData::new(0x1234_5678, "util1".into()); + let data2 = PluginCleaningData::new(0xDEAD_BEEF, "util2".into()); + let data3 = PluginCleaningData::new(0xFEED_CAFE, "util3".into()); plugin1.set_clean_info(vec![data1.clone(), data2.clone()]); plugin2.set_clean_info(vec![data1.clone(), data3.clone()]); @@ -801,7 +800,7 @@ mod tests { #[test] fn should_be_false_if_dirty_info_is_set() { let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); - plugin.set_dirty_info(vec![PluginCleaningData::new(0x12345678, "util1".into())]); + plugin.set_dirty_info(vec![PluginCleaningData::new(0x1234_5678, "util1".into())]); assert!(!plugin.has_name_only()); } @@ -809,7 +808,7 @@ mod tests { #[test] fn should_be_false_if_clean_info_is_set() { let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); - plugin.set_clean_info(vec![PluginCleaningData::new(0x12345678, "util1".into())]); + plugin.set_clean_info(vec![PluginCleaningData::new(0x1234_5678, "util1".into())]); assert!(!plugin.has_name_only()); } @@ -1172,7 +1171,7 @@ mod tests { #[test] fn should_emit_dirty_info() { let mut plugin = PluginMetadata::new("test.esp").unwrap(); - plugin.set_dirty_info(vec![PluginCleaningData::new(0xDEADBEEF, "utility".into())]); + plugin.set_dirty_info(vec![PluginCleaningData::new(0xDEAD_BEEF, "utility".into())]); let yaml = emit(&plugin); assert_eq!( @@ -1189,7 +1188,7 @@ mod tests { #[test] fn should_emit_clean_info() { let mut plugin = PluginMetadata::new("test.esp").unwrap(); - plugin.set_clean_info(vec![PluginCleaningData::new(0xDEADBEEF, "utility".into())]); + plugin.set_clean_info(vec![PluginCleaningData::new(0xDEAD_BEEF, "utility".into())]); let yaml = emit(&plugin); assert_eq!( @@ -1238,7 +1237,7 @@ mod tests { match replace_capturing_groups(input) { Cow::Borrowed(output) => assert_eq!(input, output), - Cow::Owned(output) => panic!("Expected borrowed output, got {}", output), + Cow::Owned(output) => panic!("Expected borrowed output, got {output}"), } } @@ -1248,14 +1247,14 @@ mod tests { match replace_capturing_groups(input) { Cow::Borrowed(output) => assert_eq!(input, output), - Cow::Owned(output) => panic!("Expected borrowed output, got {}", output), + Cow::Owned(output) => panic!("Expected borrowed output, got {output}"), } let input = "no paren(?:th(?:e)s)es"; match replace_capturing_groups(input) { Cow::Borrowed(output) => assert_eq!(input, output), - Cow::Owned(output) => panic!("Expected borrowed output, got {}", output), + Cow::Owned(output) => panic!("Expected borrowed output, got {output}"), } } diff --git a/src/metadata/yaml/emit.rs b/src/metadata/yaml/emit.rs index 01f94dd0..5cbbafcb 100644 --- a/src/metadata/yaml/emit.rs +++ b/src/metadata/yaml/emit.rs @@ -106,7 +106,7 @@ impl YamlEmitter { _ => self.scope.push(YamlBlock::Map), } - self.write(&format!("{}:", key)); + self.write(&format!("{key}:")); } pub fn begin_array(&mut self) { @@ -298,7 +298,7 @@ fn double_quote(value: &str) -> String { }) .collect(); - format!("\"{}\"", escaped) + format!("\"{escaped}\"") } impl EmitYaml for &[T] { @@ -315,7 +315,7 @@ impl EmitYaml for &[T] { elements => { emitter.begin_array(); - for element in elements.iter() { + for element in *elements { element.emit_yaml(emitter); } diff --git a/src/metadata/yaml/merge.rs b/src/metadata/yaml/merge.rs index cbfdca4e..4322bfc3 100644 --- a/src/metadata/yaml/merge.rs +++ b/src/metadata/yaml/merge.rs @@ -55,11 +55,11 @@ fn merge_into_mapping<'a, 'b>( if let YamlData::Mapping(h) = e.data { Ok(merge_mappings(acc, h)) } else { - Err(YamlMergeKeyError::new(e)) + Err(YamlMergeKeyError::new(&e)) } }), YamlData::Mapping(h) => Ok(merge_mappings(mapping, h)), - _ => Err(YamlMergeKeyError::new(value)), + _ => Err(YamlMergeKeyError::new(&value)), } } diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 178f7619..6131ec6f 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -123,7 +123,9 @@ impl Plugin { /// and OpenMW) or if the `HEDR` subrecord could not be found, of if the /// version field's value was `NaN`. pub fn header_version(&self) -> Option { - self.plugin.as_ref().and_then(|p| p.header_version()) + self.plugin + .as_ref() + .and_then(esplugin::Plugin::header_version) } /// Get the plugin's version number from its description field. @@ -140,8 +142,7 @@ impl Plugin { pub fn masters(&self) -> Result, PluginDataError> { self.plugin .as_ref() - .map(|p| p.masters().map_err(Into::into)) - .unwrap_or_else(|| Ok(Vec::new())) + .map_or_else(|| Ok(Vec::new()), |p| p.masters().map_err(Into::into)) } /// Get any Bash Tags found in the plugin's description field. @@ -174,8 +175,7 @@ impl Plugin { } else { self.plugin .as_ref() - .map(|p| p.is_master_file()) - .unwrap_or(false) + .is_some_and(esplugin::Plugin::is_master_file) } } @@ -183,56 +183,49 @@ impl Plugin { pub fn is_light_plugin(&self) -> bool { self.plugin .as_ref() - .map(|p| p.is_light_plugin()) - .unwrap_or(false) + .is_some_and(esplugin::Plugin::is_light_plugin) } /// Check if the plugin is a medium plugin. pub fn is_medium_plugin(&self) -> bool { self.plugin .as_ref() - .map(|p| p.is_medium_plugin()) - .unwrap_or(false) + .is_some_and(esplugin::Plugin::is_medium_plugin) } /// Check if the plugin is an update plugin. pub fn is_update_plugin(&self) -> bool { self.plugin .as_ref() - .map(|p| p.is_update_plugin()) - .unwrap_or(false) + .is_some_and(esplugin::Plugin::is_update_plugin) } /// Check if the plugin is a blueprint plugin. pub fn is_blueprint_plugin(&self) -> bool { self.plugin .as_ref() - .map(|p| p.is_blueprint_plugin()) - .unwrap_or(false) + .is_some_and(esplugin::Plugin::is_blueprint_plugin) } /// Check if the plugin is or would be valid as a light plugin. pub fn is_valid_as_light_plugin(&self) -> Result { - self.plugin - .as_ref() - .map(|p| p.is_valid_as_light_plugin().map_err(Into::into)) - .unwrap_or(Ok(false)) + self.plugin.as_ref().map_or(Ok(false), |p| { + p.is_valid_as_light_plugin().map_err(Into::into) + }) } /// Check if the plugin is or would be valid as a medium plugin. pub fn is_valid_as_medium_plugin(&self) -> Result { - self.plugin - .as_ref() - .map(|p| p.is_valid_as_medium_plugin().map_err(Into::into)) - .unwrap_or(Ok(false)) + self.plugin.as_ref().map_or(Ok(false), |p| { + p.is_valid_as_medium_plugin().map_err(Into::into) + }) } /// Check if the plugin is or would be valid as an update plugin. pub fn is_valid_as_update_plugin(&self) -> Result { - self.plugin - .as_ref() - .map(|p| p.is_valid_as_update_plugin().map_err(Into::into)) - .unwrap_or(Ok(false)) + self.plugin.as_ref().map_or(Ok(false), |p| { + p.is_valid_as_update_plugin().map_err(Into::into) + }) } /// Check if the plugin contains any records other than its `TES3`/`TES4` @@ -240,7 +233,7 @@ impl Plugin { pub fn is_empty(&self) -> bool { self.plugin .as_ref() - .and_then(|p| p.record_and_group_count()) + .and_then(esplugin::Plugin::record_and_group_count) .unwrap_or(0) == 0 } @@ -265,8 +258,7 @@ impl Plugin { pub(crate) fn override_record_count(&self) -> Result { self.plugin .as_ref() - .map(|p| p.count_override_records().map_err(Into::into)) - .unwrap_or(Ok(0)) + .map_or(Ok(0), |p| p.count_override_records().map_err(Into::into)) } pub(crate) fn asset_count(&self) -> usize { @@ -353,8 +345,7 @@ fn has_plugin_file_extension(game_type: GameType, plugin_path: &Path) -> bool { pub(crate) fn has_ascii_extension(path: &Path, extension: &str) -> bool { path.extension() - .map(|e| e.eq_ignore_ascii_case(extension)) - .unwrap_or(false) + .is_some_and(|e| e.eq_ignore_ascii_case(extension)) } pub(crate) fn plugins_metadata( @@ -404,7 +395,7 @@ fn extract_bash_tags(description: &str) -> Vec { if let Some(end_pos) = description[start_pos..].find("}}") { return description[start_pos..start_pos + end_pos] - .split(",") + .split(',') .map(|s| s.trim().to_string()) .collect(); } @@ -430,9 +421,9 @@ fn extract_version(description: &str) -> Result, Box Result, Box { - assert_eq!(1.2, plugin.header_version().unwrap()) + assert_eq!(1.2, plugin.header_version().unwrap()); } GameType::TES4 => assert_eq!(0.8, plugin.header_version().unwrap()), GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), @@ -619,9 +611,10 @@ mod tests { assert!(!plugin.is_empty()); assert!(plugin.version().is_none()); + #[expect(clippy::float_cmp, reason = "float values should be exactly equal")] match game_type { GameType::TES3 | GameType::OpenMW => { - assert_eq!(1.2, plugin.header_version().unwrap()) + assert_eq!(1.2, plugin.header_version().unwrap()); } GameType::TES4 => assert_eq!(0.8, plugin.header_version().unwrap()), GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), @@ -629,10 +622,10 @@ mod tests { } let expected_crc = match game_type { - GameType::TES3 | GameType::OpenMW => 3317676987, - GameType::Starfield => 1422425298, - GameType::TES4 => 3759349588, - _ => 3000242590, + GameType::TES3 | GameType::OpenMW => 3_317_676_987, + GameType::Starfield => 1_422_425_298, + GameType::TES4 => 3_759_349_588, + _ => 3_000_242_590, }; assert_eq!(expected_crc, plugin.crc().unwrap()); diff --git a/src/sorting/error.rs b/src/sorting/error.rs index 2115c840..dafec336 100644 --- a/src/sorting/error.rs +++ b/src/sorting/error.rs @@ -43,7 +43,7 @@ impl CyclicInteractionError { impl Display for CyclicInteractionError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let cycle = display_cycle(&self.cycle); - write!(f, "cyclic interaction detected: {}", cycle) + write!(f, "cyclic interaction detected: {cycle}") } } @@ -75,7 +75,7 @@ pub enum GroupsPathError { impl Display for GroupsPathError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::UndefinedGroup(g) => write!(f, "the group \"{}\" does not exist", g), + Self::UndefinedGroup(g) => write!(f, "the group \"{g}\" does not exist"), Self::CycleFound(c) => write!(f, "found a cycle: {}", display_cycle(c)), Self::PathfindingError(_) => write!(f, "failed to find a path in the groups graph"), } @@ -168,21 +168,18 @@ impl Display for PathfindingError { ), Self::PrecedingNodeNotFound(n) => write!( f, - "unexpectedly could not find the node before \"{}\" in the path that was found", - n + "unexpectedly could not find the node before \"{n}\" in the path that was found", ), Self::FollowingNodeNotFound(n) => write!( f, - "unexpectedly could not find the node after \"{}\" in the path that was found", - n + "unexpectedly could not find the node after \"{n}\" in the path that was found", ), Self::EdgeNotFound { from_group, to_group, } => write!( f, - "unexpectedly could not find the edge going from \"{}\" to \"{}\"", - from_group, to_group + "unexpectedly could not find the edge going from \"{from_group}\" to \"{to_group}\"", ), } } @@ -242,7 +239,7 @@ impl Display for SortingError { Self::ValidationError(_) => write!(f, "plugin graph validation failed"), Self::UndefinedGroup(_) => write!(f, "found an undefined group"), Self::CycleFound(_) => write!(f, "found a cycle"), - Self::CycleInvolving(n) => write!(f, "found a cycle involving \"{}\"", n), + Self::CycleInvolving(n) => write!(f, "found a cycle involving \"{n}\""), Self::PluginDataError(_) => write!(f, "failed to read plugin data"), Self::PathfindingError(_) => write!(f, "failed to find a path in the plugins graph"), } diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs index 66bde9a0..07cff385 100644 --- a/src/sorting/groups.rs +++ b/src/sorting/groups.rs @@ -103,8 +103,8 @@ fn add_groups<'a>( } fn sorted_clone(strings: &[String]) -> Vec<&str> { - let mut strings: Vec<_> = strings.iter().map(|s| s.as_str()).collect(); - strings.sort(); + let mut strings: Vec<_> = strings.iter().map(String::as_str).collect(); + strings.sort_unstable(); strings } @@ -119,7 +119,7 @@ pub fn find_path( |_, e| { if *e == EdgeType::UserLoadAfter { // A very small number so that user edges are practically always preferred. - -1000000.0 + -1_000_000.0 } else { 1.0 } @@ -163,15 +163,12 @@ pub fn find_path( return Ok(Vec::new()); } - let edge = match graph.find_edge(*preceding_vertex, current) { - Some(e) => e, - None => { - return Err(PathfindingError::EdgeNotFound { - from_group: graph[*preceding_vertex].clone().into_string(), - to_group: graph[current].clone().into_string(), - } - .into()); + let Some(edge) = graph.find_edge(*preceding_vertex, current) else { + return Err(PathfindingError::EdgeNotFound { + from_group: graph[*preceding_vertex].clone().into_string(), + to_group: graph[current].clone().into_string(), } + .into()); }; let vertex = Vertex::new(graph[*preceding_vertex].clone().into_string()) @@ -190,17 +187,14 @@ fn find_node_by_weight( graph: &Graph, EdgeType>, weight: &str, ) -> Result { - match graph.node_indices().find(|i| { - graph - .node_weight(*i) - .map(|w| w.as_ref() == weight) - .unwrap_or(false) - }) { - Some(n) => Ok(n), - None => { - logging::error!("Can't find group with name {}", weight); - Err(UndefinedGroupError::new(weight.to_string())) - } + if let Some(n) = graph + .node_indices() + .find(|i| graph.node_weight(*i).is_some_and(|w| w.as_ref() == weight)) + { + Ok(n) + } else { + logging::error!("Can't find group with name {}", weight); + Err(UndefinedGroupError::new(weight.to_string())) } } @@ -243,7 +237,7 @@ struct GroupsPathLengthVisitor { impl GroupsPathLengthVisitor { fn new() -> Self { - Default::default() + GroupsPathLengthVisitor::default() } fn max_path_length(&self) -> usize { @@ -261,7 +255,7 @@ impl<'a> DfsVisitor<'a> for GroupsPathLengthVisitor { fn discover_node(&mut self, _: NodeIndex) { self.current_path_length += 1; if self.current_path_length > self.max_path_length { - self.max_path_length = self.current_path_length + self.max_path_length = self.current_path_length; } } @@ -290,7 +284,7 @@ mod tests { match build_groups_graph(groups, &[]) { Err(BuildGroupsGraphError::UndefinedGroup(e)) => { - assert_eq!("a", e.into_group_name()) + assert_eq!("a", e.into_group_name()); } _ => panic!("Expected an undefined group error"), } @@ -303,7 +297,7 @@ mod tests { match build_groups_graph(masterlist, userlist) { Err(BuildGroupsGraphError::UndefinedGroup(e)) => { - assert_eq!("a", e.into_group_name()) + assert_eq!("a", e.into_group_name()); } _ => panic!("Expected an undefined group error"), } diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 2798d776..601d6ba0 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -416,16 +416,16 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { // Assets don't overlap or both plugins load the same number of // assets, don't add an edge. continue; - } else { - outer_plugin_loads_first = plugin_asset_count > other_plugin_asset_count; - edge_type = EdgeType::AssetOverlap; } + + outer_plugin_loads_first = plugin_asset_count > other_plugin_asset_count; + edge_type = EdgeType::AssetOverlap; } else { // Records overlap and override different numbers of records. // Load this plugin first if it overrides more records. outer_plugin_loads_first = plugin.override_record_count > other_plugin.override_record_count; - edge_type = EdgeType::RecordOverlap + edge_type = EdgeType::RecordOverlap; } let (from_index, to_index) = if outer_plugin_loads_first { @@ -435,15 +435,15 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { }; if !self.is_path_cached(from_index, to_index) { - if !self.path_exists(to_index, from_index) { - self.add_edge(from_index, to_index, edge_type); - } else { + if self.path_exists(to_index, from_index) { logging::debug!( "Skipping {} edge from \"{}\" to \"{}\" as it would create a cycle.", edge_type, self[from_index].name(), self[to_index].name() ); + } else { + self.add_edge(from_index, to_index, edge_type); } } } @@ -653,7 +653,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { // Insert position is just after the found vertex, and a forward iterator // points to the element one after the element pointed to by the // corresponding reverse iterator. - let insert_position = previous_node_position.map(|i| i + 1).unwrap_or(range_start); + let insert_position = previous_node_position.map_or(range_start, |i| i + 1); // Add an edge going from this vertex to the next one in the "new load // order" path, in case there isn't already one. @@ -711,10 +711,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { } fn is_path_cached(&self, from: NodeIndex, to: NodeIndex) -> bool { - self.paths_cache - .get(&from) - .map(|s| s.contains(&to)) - .unwrap_or(false) + self.paths_cache.get(&from).is_some_and(|s| s.contains(&to)) } fn node_index_by_name(&self, name: &str) -> Option { @@ -751,8 +748,8 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { impl std::default::Default for PluginsGraph<'_, T> { fn default() -> Self { Self { - inner: Default::default(), - paths_cache: Default::default(), + inner: Graph::default(), + paths_cache: HashMap::default(), } } } @@ -974,7 +971,7 @@ impl BidirBfsVisitor for PathFinder<'_, '_, T> { } fn visit_intersection_node(&mut self, node: NodeIndex) { - self.intersection_node = Some(node) + self.intersection_node = Some(node); } } @@ -1081,7 +1078,7 @@ impl<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, fn find_plugins_in_group(&self, node_index: GroupNodeIndex) -> &'c [PluginNodeIndex] { self.groups_plugins .get(self.groups_graph[node_index].as_ref()) - .map(|v| v.as_slice()) + .map(Vec::as_slice) .unwrap_or_default() } @@ -1123,16 +1120,16 @@ impl<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, EdgeType::MasterlistGroup }; - if !self.plugins_graph.path_exists(*to_plugin, from_plugin) { - self.plugins_graph - .add_edge(from_plugin, *to_plugin, edge_type); - } else { + if self.plugins_graph.path_exists(*to_plugin, from_plugin) { logging::debug!( "Skipping a \"{}\" edge from \"{}\" to \"{}\" as it would create a cycle.", edge_type, self.plugins_graph[from_plugin].name(), self.plugins_graph[*to_plugin].name() ); + } else { + self.plugins_graph + .add_edge(from_plugin, *to_plugin, edge_type); } } } @@ -1222,6 +1219,7 @@ impl<'e, T: SortingPlugin> DfsVisitor<'e> for GroupsPathVisitor<'_, '_, '_, '_, #[cfg(test)] mod tests { + #![allow(clippy::many_single_char_names)] use super::*; use crate::sorting::{groups::build_groups_graph, test::TestPlugin}; @@ -1252,7 +1250,7 @@ mod tests { plugins: plugin_names .iter() .enumerate() - .map(|(i, n)| (n.to_string(), (TestPlugin::new(n), i))) + .map(|(i, n)| ((*n).to_owned(), (TestPlugin::new(n), i))) .collect(), } } diff --git a/src/tests.rs b/src/tests.rs index b7a4be8c..af611f5e 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -119,8 +119,7 @@ fn set_load_order( use std::io::Write; match game_type { - GameType::TES3 => {} - GameType::OpenMW => {} + GameType::TES3 | GameType::OpenMW => {} _ => { let mut file = File::create(local_path.join("Plugins.txt")).unwrap(); for (plugin, is_active) in load_order { @@ -140,7 +139,7 @@ fn set_load_order( if is_load_order_timestamp_based(game_type) { let mut mod_time = SystemTime::now(); for (plugin, _) in load_order { - let ghosted_path = data_path.join(plugin.to_string() + ".ghost"); + let ghosted_path = data_path.join(format!("{plugin}.ghost")); let file = if ghosted_path.exists() { File::options().write(true).open(ghosted_path) } else { From 0e98c82b575c03d757f5fc6558c7bec30df9acc3 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 18:06:42 +0100 Subject: [PATCH 093/206] Deny some rustc lints The unreachable_pub lint is commented out because it seems like a good idea but fixing the errors it causes would cause too much churn for it to be done in this commit. --- src/archive/find.rs | 44 +++++++++++++++++++++------------ src/database/mod.rs | 4 --- src/game.rs | 26 +++++-------------- src/lib.rs | 16 ++++++++++++ src/metadata/plugin_metadata.rs | 2 +- src/plugin/mod.rs | 2 +- 6 files changed, 52 insertions(+), 42 deletions(-) diff --git a/src/archive/find.rs b/src/archive/find.rs index 93f5785c..4c80319c 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -1,5 +1,8 @@ use std::path::{Path, PathBuf}; +#[cfg(windows)] +use windows::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION; + use crate::{GameType, game::GameCache, plugin::has_ascii_extension}; const BSA_FILE_EXTENSION: &str = "bsa"; @@ -126,11 +129,6 @@ fn find_associated_archives_with_arbitrary_suffixes( #[cfg(windows)] fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { use std::fs::File; - use std::os::windows::io::AsRawHandle; - use windows::Win32::{ - Foundation::HANDLE, - Storage::FileSystem::{BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle}, - }; if lhs == rhs { return true; @@ -144,24 +142,38 @@ fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { return false; }; - let mut lhs_info = BY_HANDLE_FILE_INFORMATION::default(); - let mut rhs_info = BY_HANDLE_FILE_INFORMATION::default(); - // SAFETY: This is safe because the file handles and the info struct pointers are all valid until this function exits. - unsafe { - if GetFileInformationByHandle(HANDLE(lhs_file.as_raw_handle()), &mut lhs_info).is_err() { - return false; - } + let Some(lhs_info) = get_file_info(&lhs_file) else { + return false; + }; - if GetFileInformationByHandle(HANDLE(rhs_file.as_raw_handle()), &mut rhs_info).is_err() { - return false; - } - } + let Some(rhs_info) = get_file_info(&rhs_file) else { + return false; + }; lhs_info.dwVolumeSerialNumber == rhs_info.dwVolumeSerialNumber && lhs_info.nFileIndexHigh == rhs_info.nFileIndexHigh && lhs_info.nFileIndexLow == rhs_info.nFileIndexLow } +#[cfg(windows)] +fn get_file_info(file: &std::fs::File) -> Option { + use std::os::windows::io::AsRawHandle; + use windows::Win32::{Foundation::HANDLE, Storage::FileSystem::GetFileInformationByHandle}; + + let mut info = BY_HANDLE_FILE_INFORMATION::default(); + + // SAFETY: This is safe because the file handles and the info struct pointers are all valid until this function exits. + #[expect( + unsafe_code, + reason = "There is currently no way to get this data safely" + )] + unsafe { + GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut info) + .is_ok() + .then_some(info) + } +} + #[cfg(not(windows))] fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { use std::os::unix::fs::MetadataExt; diff --git a/src/database/mod.rs b/src/database/mod.rs index 8897b772..25f514cb 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -602,8 +602,6 @@ plugins: } mod write_minimal_list { - use crate::tests::{BLANK_DIFFERENT_ESM, BLANK_ESM}; - use super::*; #[test] @@ -964,8 +962,6 @@ plugins: } mod plugin_metadata { - use crate::tests::BLANK_ESM; - use super::*; #[test] diff --git a/src/game.rs b/src/game.rs index bc5f848b..0879e4e1 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1176,11 +1176,6 @@ mod tests { mod set_additional_data_paths { use std::time::{Duration, SystemTime}; - use crate::{ - metadata::{File, PluginMetadata}, - tests::{BLANK_ESM, BLANK_ESP}, - }; - use super::*; #[test] @@ -1274,9 +1269,7 @@ mod tests { mod is_valid_plugin { use super::*; - use crate::tests::{ - BLANK_ESM, BLANK_MASTER_DEPENDENT_ESM, NON_ASCII_ESM, NON_PLUGIN_FILE, - }; + use crate::tests::{NON_ASCII_ESM, NON_PLUGIN_FILE}; #[apply(all_game_types)] fn should_return_true_for_a_valid_non_ascii_plugin(game_type: GameType) { @@ -1310,7 +1303,7 @@ mod tests { .unwrap(); let plugin = fixture.data_path().join("empty.omwscripts"); - let _ = std::fs::File::create(&plugin).unwrap(); + std::fs::File::create(&plugin).unwrap(); if game_type == GameType::OpenMW { assert!(game.is_valid_plugin(&plugin)); @@ -1345,7 +1338,7 @@ mod tests { .unwrap(); let empty_file_path = fixture.data_path().join("empty.esp"); - let _ = std::fs::File::create(&empty_file_path).unwrap(); + std::fs::File::create(&empty_file_path).unwrap(); assert!(!game.is_valid_plugin(&empty_file_path)); } @@ -1416,7 +1409,7 @@ mod tests { } mod load_plugin_headers { - use crate::tests::{BLANK_DIFFERENT_ESM, BLANK_ESM, BLANK_ESP, NON_PLUGIN_FILE}; + use crate::tests::NON_PLUGIN_FILE; use super::*; @@ -1517,10 +1510,7 @@ mod tests { mod load_plugins { use std::error::Error; - use crate::tests::{ - BLANK_DIFFERENT_ESM, BLANK_ESM, BLANK_ESP, BLANK_FULL_ESM, - BLANK_MASTER_DEPENDENT_ESM, - }; + use crate::tests::BLANK_FULL_ESM; use super::*; @@ -1692,8 +1682,6 @@ mod tests { } mod load_plugins_common { - use crate::tests::{BLANK_ESM, BLANK_MASTER_DEPENDENT_ESM}; - use super::*; #[apply(all_game_types)] @@ -1896,7 +1884,7 @@ mod tests { } mod sort_plugins { - use crate::tests::{BLANK_DIFFERENT_ESP, BLANK_ESP, initial_load_order}; + use crate::tests::initial_load_order; use super::*; @@ -1958,8 +1946,6 @@ mod tests { } mod is_plugin_active { - use crate::tests::BLANK_ESP; - use super::*; #[test] diff --git a/src/lib.rs b/src/lib.rs index 2d019c77..9fcd0fa7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,19 @@ +// Deny some rustc lints that are allow-by-default. +#![deny( + ambiguous_negative_literals, + impl_trait_overcaptures, + let_underscore_drop, + missing_copy_implementations, + missing_debug_implementations, + non_ascii_idents, + redundant_imports, + redundant_lifetimes, + trivial_casts, + trivial_numeric_casts, + unit_bindings, + // unreachable_pub, + unsafe_code +)] #![deny(clippy::pedantic)] // Allow a few clippy pedantic lints. #![allow(clippy::doc_markdown)] diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index f60265bd..189152c7 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -1017,7 +1017,7 @@ mod tests { mod emit_yaml { use super::*; - use crate::metadata::{MessageType, TagSuggestion, emit}; + use crate::metadata::emit; #[test] fn should_omit_group_if_not_set() { diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 6131ec6f..02f5f2a2 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -705,7 +705,7 @@ mod tests { std::fs::copy(data_path.join(blank_esm(game_type)), &omwgame).unwrap(); std::fs::copy(data_path.join(BLANK_ESP), &omwaddon).unwrap(); - let _ = File::create(&omwscripts).unwrap(); + File::create(&omwscripts).unwrap(); assert!( Plugin::new( From 919d2c229e5757ed57d3846c7d3513a738d10746 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 18:09:44 +0100 Subject: [PATCH 094/206] Deny some clippy restriction lints A few lints are commented out because they seem like good ideas but fixing the errors they cause would cause too much churn for it to be done in this commit. --- src/archive/bsa.rs | 12 ++++- src/archive/parse.rs | 18 +++---- src/database/mod.rs | 4 ++ src/game.rs | 4 +- src/lib.rs | 87 +++++++++++++++++++++++++++++++ src/logging.rs | 4 +- src/metadata/metadata_document.rs | 4 +- src/metadata/plugin_metadata.rs | 2 +- src/metadata/yaml/parse.rs | 2 +- src/sorting/dfs.rs | 6 +-- src/sorting/groups.rs | 2 +- src/sorting/mod.rs | 8 +-- src/sorting/plugins.rs | 23 ++++---- src/tests.rs | 6 +-- src/version.rs | 4 ++ 15 files changed, 144 insertions(+), 42 deletions(-) diff --git a/src/archive/bsa.rs b/src/archive/bsa.rs index 60d328ee..dd4cea36 100644 --- a/src/archive/bsa.rs +++ b/src/archive/bsa.rs @@ -73,7 +73,11 @@ mod v103 { pub(super) const FOLDER_RECORD_SIZE: usize = 16; pub(super) fn read_folder_record(value: &[u8]) -> FolderRecord { - assert!(value.len() >= FOLDER_RECORD_SIZE); + assert!( + value.len() >= FOLDER_RECORD_SIZE, + "Folder record byte slice is too small, expected {FOLDER_RECORD_SIZE} bytes, got {}", + value.len() + ); FolderRecord { name_hash: to_u64(value), @@ -91,7 +95,11 @@ mod v105 { pub(super) const FOLDER_RECORD_SIZE: usize = 24; pub(super) fn read_folder_record(value: &[u8]) -> FolderRecord { - assert!(value.len() >= FOLDER_RECORD_SIZE); + assert!( + value.len() >= FOLDER_RECORD_SIZE, + "Folder record byte slice is too small, expected {FOLDER_RECORD_SIZE} bytes, got {}", + value.len() + ); FolderRecord { name_hash: to_u64(value), diff --git a/src/archive/parse.rs b/src/archive/parse.rs index c89f0bd1..c28cbc0f 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -147,10 +147,10 @@ mod tests { assert_eq!(1, assets.len()); assert_eq!(1, files_count); assert_eq!(expected_key, *assets.first_key_value().unwrap().0); - assert_eq!(1, assets.get(&expected_key).unwrap().len()); + assert_eq!(1, assets[&expected_key].len()); assert_eq!( 0x4670_B683_6C07_7365, - *assets.get(&expected_key).unwrap().first().unwrap() + *assets[&expected_key].first().unwrap() ); } @@ -165,10 +165,10 @@ mod tests { assert_eq!(1, assets.len()); assert_eq!(1, files_count); assert_eq!(expected_key, *assets.first_key_value().unwrap().0); - assert_eq!(1, assets.get(&expected_key).unwrap().len()); + assert_eq!(1, assets[&expected_key].len()); assert_eq!( 0x4670_B683_6C07_7365, - *assets.get(&expected_key).unwrap().first().unwrap() + *assets[&expected_key].first().unwrap() ); } @@ -183,10 +183,10 @@ mod tests { assert_eq!(1, assets.len()); assert_eq!(1, files_count); assert_eq!(expected_key, *assets.first_key_value().unwrap().0); - assert_eq!(1, assets.get(&expected_key).unwrap().len()); + assert_eq!(1, assets[&expected_key].len()); assert_eq!( 0x4670_B683_6C07_7365, - *assets.get(&expected_key).unwrap().first().unwrap() + *assets[&expected_key].first().unwrap() ); } @@ -286,15 +286,15 @@ mod tests { assert_eq!(3, assets.len()); assert_eq!(3, files_count); - let value = assets.get(&0).unwrap(); + let value = &assets[&0]; assert_eq!(1, value.len()); assert_eq!(0x4670_B683_6C07_7365, *value.first().unwrap()); - let value = assets.get(&0x2E01_002E).unwrap(); + let value = &assets[&0x2E01_002E]; assert_eq!(1, value.len()); assert_eq!(0x4670_B683_6C07_7365, *value.first().unwrap()); - let value = assets.get(&0xB681_02C9_6417_6E73).unwrap(); + let value = &assets[&0xB681_02C9_6417_6E73]; assert_eq!(1, value.len()); assert_eq!(0x4670_B683_6C07_7365, *value.first().unwrap()); } diff --git a/src/database/mod.rs b/src/database/mod.rs index 25f514cb..f71e4890 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -119,6 +119,10 @@ impl Database { let Ok(mut minimal_plugin) = PluginMetadata::new(plugin.name()) else { // This should never happen because the regex plugin name from // an existing PluginMetadata object should be valid. + logging::error!( + "Unexpectedly encountered a regex error trying to create a PluginMetadata object with the name {}", + plugin.name() + ); continue; }; minimal_plugin.set_tags(plugin.tags().to_vec()); diff --git a/src/game.rs b/src/game.rs index 0879e4e1..8a34330e 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1707,8 +1707,8 @@ mod tests { .join("sub2") .join("archive") .with_extension(extension); - std::fs::create_dir(path1.parent().unwrap()).unwrap(); - std::fs::create_dir(path2.parent().unwrap()).unwrap(); + std::fs::create_dir_all(path1.parent().unwrap()).unwrap(); + std::fs::create_dir_all(path2.parent().unwrap()).unwrap(); std::fs::File::create(&path1).unwrap(); std::fs::File::create(&path2).unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 9fcd0fa7..ef3e790b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,93 @@ #![allow(clippy::doc_markdown)] #![allow(clippy::must_use_candidate)] #![allow(clippy::missing_errors_doc)] +// Selectively deny clippy restriction lints. +#![deny( + clippy::allow_attributes, + clippy::as_conversions, + clippy::as_underscore, + clippy::assertions_on_result_states, + clippy::big_endian_bytes, + clippy::cfg_not_test, + clippy::clone_on_ref_ptr, + clippy::create_dir, + clippy::dbg_macro, + clippy::decimal_literal_representation, + clippy::default_numeric_fallback, + clippy::doc_include_without_cfg, + clippy::empty_drop, + clippy::error_impl_error, + clippy::exit, + // clippy::exhaustive_enums, + // clippy::expect_used, + // clippy::filetype_is_file, + clippy::float_cmp_const, + clippy::fn_to_numeric_cast_any, + clippy::get_unwrap, + clippy::host_endian_bytes, + clippy::if_then_some_else_none, + // clippy::indexing_slicing, + clippy::infinite_loop, + clippy::integer_division, + clippy::integer_division_remainder_used, + clippy::iter_over_hash_type, + clippy::let_underscore_must_use, + clippy::lossy_float_literal, + clippy::map_err_ignore, + clippy::map_with_unused_argument_over_ranges, + clippy::mem_forget, + clippy::missing_assert_message, + clippy::missing_asserts_for_indexing, + clippy::missing_asserts_for_indexing, + clippy::mixed_read_write_in_expression, + clippy::multiple_inherent_impl, + clippy::multiple_unsafe_ops_per_block, + clippy::mutex_atomic, + clippy::mutex_integer, + clippy::needless_raw_strings, + clippy::non_ascii_literal, + clippy::non_zero_suggestions, + clippy::panic, + clippy::panic_in_result_fn, + clippy::partial_pub_fields, + clippy::pathbuf_init_then_push, + clippy::precedence_bits, + clippy::print_stderr, + clippy::print_stdout, + clippy::rc_buffer, + clippy::rc_mutex, + clippy::redundant_type_annotations, + clippy::ref_patterns, + clippy::rest_pat_in_fully_bound_structs, + // clippy::str_to_string, + clippy::string_lit_chars_any, + // clippy::string_slice, + clippy::string_to_string, + clippy::suspicious_xor_used_as_pow, + clippy::tests_outside_test_module, + clippy::todo, + clippy::try_err, + clippy::undocumented_unsafe_blocks, + clippy::unimplemented, + clippy::unnecessary_safety_comment, + clippy::unneeded_field_pattern, + clippy::unreachable, + clippy::unused_result_ok, + // clippy::unwrap_in_result, + clippy::unwrap_used, + // clippy::use_debug, + clippy::verbose_file_reads, + // clippy::wildcard_enum_match_arm, +)] +#![cfg_attr( + test, + allow( + clippy::assertions_on_result_states, + clippy::missing_asserts_for_indexing, + clippy::panic, + clippy::unwrap_used, + ) +)] mod archive; mod database; diff --git a/src/logging.rs b/src/logging.rs index 86639da8..86209eee 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -213,7 +213,7 @@ mod tests { let _lock = TEST_LOCK.lock().unwrap(); let messages = Arc::new(Mutex::new(Vec::new())); - let cloned_messages = messages.clone(); + let cloned_messages = Arc::clone(&messages); let callback = move |level, message: &str| { if let Ok(mut messages) = cloned_messages.lock() { messages.push((level, message.to_string())); @@ -247,7 +247,7 @@ mod tests { set_logging_callback(callback); let messages = Arc::new(Mutex::new(Vec::new())); - let cloned_messages = messages.clone(); + let cloned_messages = Arc::clone(&messages); let callback = move |level, message: &str| { if let Ok(mut messages) = cloned_messages.lock() { messages.push((level, message.to_string())); diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index c418a6f4..dc0a0968 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -456,7 +456,7 @@ plugins: #[test] fn load_from_str_should_resolve_aliases() { - let yaml = r" + let yaml = " prelude: - &anchor type: say @@ -489,7 +489,7 @@ plugins: #[test] fn load_from_str_should_error_if_a_plugin_has_two_exact_entries() { - let yaml = r" + let yaml = " plugins: - name: 'Blank.esm' msg: diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 189152c7..9b5f8b3f 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -337,7 +337,7 @@ pub(crate) fn iends_with_ascii(string: &str, suffix: &str) -> bool { } fn is_regex_name(name: &str) -> bool { - name.contains(|c| ":\\*?|".chars().any(|n| c == n)) + name.contains([':', '\\', '*', '?', '|']) } fn merge_slices(target: &mut Box<[T]>, source: &[T]) { diff --git a/src/metadata/yaml/parse.rs b/src/metadata/yaml/parse.rs index 1605549c..787e2386 100644 --- a/src/metadata/yaml/parse.rs +++ b/src/metadata/yaml/parse.rs @@ -144,7 +144,7 @@ pub fn get_u32_value( ) -> Result, ParseMetadataError> { match mapping.get(&as_string_node(key)) { Some(n) => match n.data.as_integer() { - Some(i) => i.try_into().map(Some).map_err(|_| { + Some(i) => i.try_into().map(Some).map_err(|_e| { ParseMetadataError::new(n.span.start, MetadataParsingErrorReason::NonU32Number(i)) }), None => Err(ParseMetadataError::unexpected_value_type( diff --git a/src/sorting/dfs.rs b/src/sorting/dfs.rs index dee6798e..0484756c 100644 --- a/src/sorting/dfs.rs +++ b/src/sorting/dfs.rs @@ -179,11 +179,7 @@ impl<'a, N, F: FnMut(&N) -> String> CycleDetector<'a, N, F> { } fn into_cycle_path(self) -> Option> { - if self.found_cycle { - Some(self.trail) - } else { - None - } + self.found_cycle.then_some(self.trail) } } diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs index 07cff385..dc380545 100644 --- a/src/sorting/groups.rs +++ b/src/sorting/groups.rs @@ -130,7 +130,7 @@ pub fn find_path( let to_vertex = find_node_by_weight(graph, to_group_name)?; let paths = - bellman_ford(&float_graph, from_vertex).map_err(|_| PathfindingError::NegativeCycle)?; + bellman_ford(&float_graph, from_vertex).map_err(|_e| PathfindingError::NegativeCycle)?; let mut path = vec![Vertex::new(graph[to_vertex].clone().into_string())]; let mut current = to_vertex; diff --git a/src/sorting/mod.rs b/src/sorting/mod.rs index 7c282860..9340a31f 100644 --- a/src/sorting/mod.rs +++ b/src/sorting/mod.rs @@ -14,10 +14,10 @@ mod test { pub struct TestPlugin { name: String, masters: Vec, - pub is_master: bool, - pub is_blueprint_plugin: bool, - pub override_record_count: usize, - pub asset_count: usize, + pub(super) is_master: bool, + pub(super) is_blueprint_plugin: bool, + pub(super) override_record_count: usize, + pub(super) asset_count: usize, overlapping_record_plugins: Vec, overlapping_asset_plugins: Vec, } diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 601d6ba0..c0e3a3b3 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -275,8 +275,8 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { early_loader_indices.sort_by_key(|e| e.0); for window in early_loader_indices.windows(2) { - if let [(_, from_index), (_, to_index)] = window { - self.add_edge(*from_index, *to_index, EdgeType::Hardcoded); + if let [(_, from_index), (_, to_index)] = *window { + self.add_edge(from_index, to_index, EdgeType::Hardcoded); } } @@ -506,9 +506,10 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { nodes.sort_by_key(|a| self[*a].load_order_index); for window in nodes.windows(2) { - let (current, next) = match window { - [a, b] => (*a, *b), - _ => panic!("Window length should be 2, got {}", window.len()), + let [current, next] = *window else { + // This should never happen. + logging::error!("Unexpectedly encountered a window length that was not 2"); + continue; }; match self.find_path(next, current)? { @@ -700,8 +701,8 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { logging::trace!("Checking uniqueness of path through plugin graph..."); - path.windows(2).find_map(|slice| match slice { - [a, b] => self.inner.contains_edge(*a, *b).not().then_some((*a, *b)), + path.windows(2).find_map(|slice| match *slice { + [a, b] => self.inner.contains_edge(a, b).not().then_some((a, b)), _ => None, }) } @@ -995,8 +996,10 @@ fn get_plugins_in_groups( if is_log_enabled(LogLevel::Debug) { logging::debug!("Found the following plugins in groups:"); - for (key, value) in &plugins_in_groups { - let plugin_names: Vec<_> = value + let mut keys = plugins_in_groups.keys().collect::>(); + keys.sort(); + for key in keys { + let plugin_names: Vec<_> = plugins_in_groups[key] .iter() .map(|i| format!("\"{}\"", graph[*i].name())) .collect(); @@ -1256,7 +1259,7 @@ mod tests { } fn get_plugin(&self, name: &str) -> &(TestPlugin, usize) { - self.plugins.get(name).unwrap() + &self.plugins[name] } fn get_plugin_mut(&mut self, name: &str) -> &mut TestPlugin { diff --git a/src/tests.rs b/src/tests.rs index af611f5e..e4246767 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -167,9 +167,9 @@ fn data_path(game_type: GameType, game_path: &Path) -> PathBuf { pub struct Fixture { _temp_dir: TempDir, - pub game_type: GameType, - pub game_path: PathBuf, - pub local_path: PathBuf, + pub(crate) game_type: GameType, + pub(crate) game_path: PathBuf, + pub(crate) local_path: PathBuf, } impl Fixture { diff --git a/src/version.rs b/src/version.rs index 0e95aafe..0bb260c9 100644 --- a/src/version.rs +++ b/src/version.rs @@ -28,6 +28,10 @@ pub fn is_compatible(major: u32, minor: u32, _patch: u32) -> bool { } } +#[expect( + clippy::as_conversions, + reason = "Can't const-convert the byte to a u32 a safer way" +)] const fn parse_u32(value: &str) -> u32 { let mut acc = 0; let mut i = 0; From e0a7d7c79f762c2dec2d88677f609fd314d15664 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 18:17:43 +0100 Subject: [PATCH 095/206] Deny the clippy::expect_used and clippy::unwrap_in_result lints --- src/archive/ba2.rs | 22 ++++++++--- src/archive/bsa.rs | 84 +++++++++++++++++++++-------------------- src/archive/error.rs | 12 ++++++ src/archive/parse.rs | 31 ++++++++++----- src/lib.rs | 4 +- src/metadata/message.rs | 8 ++++ src/plugin/mod.rs | 4 ++ src/sorting/groups.rs | 10 +++-- 8 files changed, 114 insertions(+), 61 deletions(-) diff --git a/src/archive/ba2.rs b/src/archive/ba2.rs index 23cf8480..e2b56ef0 100644 --- a/src/archive/ba2.rs +++ b/src/archive/ba2.rs @@ -4,7 +4,7 @@ use std::{ io::{BufRead, Seek}, }; -use super::error::ArchiveParsingError; +use super::error::{ArchiveParsingError, slice_too_small}; use super::parse::{to_u32, to_u64}; @@ -28,11 +28,10 @@ impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result { let header = Self { type_id: TYPE_ID, - version: to_u32(&value), - archive_type: <[u8; 4]>::try_from(&value[4..8]) - .expect("Bytes slice is large enough to hold a 4-byte array"), - file_count: to_u32(&value[8..]), - file_paths_offset: to_u64(&value[12..]), + version: to_u32(&value)?, + archive_type: to_archive_type(&value)?, + file_count: to_u32(&value[8..])?, + file_paths_offset: to_u64(&value[12..])?, }; // The header version is 1, 7 or 8 for Fallout 4 and 2 or 3 for Starfield. @@ -52,6 +51,17 @@ impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { } } +fn to_archive_type( + array: &[u8; HEADER_SIZE - TYPE_ID.len()], +) -> Result<[u8; 4], ArchiveParsingError> { + let slice = &array[4..8]; + + slice + .try_into() + // This should be impossible, but it can't be asserted at compile time. + .map_err(|_e| slice_too_small(slice, 4)) +} + pub(super) fn read_assets( mut reader: T, ) -> Result>, ArchiveParsingError> { diff --git a/src/archive/bsa.rs b/src/archive/bsa.rs index dd4cea36..a4fc0e93 100644 --- a/src/archive/bsa.rs +++ b/src/archive/bsa.rs @@ -30,21 +30,17 @@ impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result { let header = Self { type_id: TYPE_ID, - version: to_u32(&value), - records_offset: to_u32(&value[4..]), - archive_flags: to_u32(&value[8..]), - folder_count: to_u32(&value[12..]), - total_file_count: to_u32(&value[16..]), - total_folder_names_length: to_u32(&value[20..]), - total_file_names_length: to_u32(&value[24..]), - content_type_flags: to_u32(&value[28..]), + version: to_u32(&value)?, + records_offset: to_u32(&value[4..])?, + archive_flags: to_u32(&value[8..])?, + folder_count: to_u32(&value[12..])?, + total_file_count: to_u32(&value[16..])?, + total_folder_names_length: to_u32(&value[20..])?, + total_file_names_length: to_u32(&value[24..])?, + content_type_flags: to_u32(&value[28..])?, }; - if header.records_offset - != HEADER_SIZE - .try_into() - .expect("header size can fit in a u32") - { + if to_usize(header.records_offset) != HEADER_SIZE { return Err(ArchiveParsingError::InvalidRecordsOffset( header.records_offset, )); @@ -66,46 +62,54 @@ struct FolderRecord { // Also used for v104 BSAs. mod v103 { - use crate::archive::parse::{to_u32, to_u64}; + use crate::archive::{ + error::ArchiveParsingError, + parse::{to_u32, to_u64}, + }; use super::FolderRecord; pub(super) const FOLDER_RECORD_SIZE: usize = 16; - pub(super) fn read_folder_record(value: &[u8]) -> FolderRecord { - assert!( - value.len() >= FOLDER_RECORD_SIZE, - "Folder record byte slice is too small, expected {FOLDER_RECORD_SIZE} bytes, got {}", - value.len() - ); - - FolderRecord { - name_hash: to_u64(value), - file_count: to_u32(&value[8..]), - file_records_offset: to_u32(&value[12..]), + pub(super) fn read_folder_record(value: &[u8]) -> Result { + if value.len() < FOLDER_RECORD_SIZE { + return Err(ArchiveParsingError::SliceTooSmall { + expected: FOLDER_RECORD_SIZE, + actual: value.len(), + }); } + + Ok(FolderRecord { + name_hash: to_u64(value)?, + file_count: to_u32(&value[8..])?, + file_records_offset: to_u32(&value[12..])?, + }) } } mod v105 { - use crate::archive::parse::{to_u32, to_u64}; + use crate::archive::{ + error::ArchiveParsingError, + parse::{to_u32, to_u64}, + }; use super::FolderRecord; pub(super) const FOLDER_RECORD_SIZE: usize = 24; - pub(super) fn read_folder_record(value: &[u8]) -> FolderRecord { - assert!( - value.len() >= FOLDER_RECORD_SIZE, - "Folder record byte slice is too small, expected {FOLDER_RECORD_SIZE} bytes, got {}", - value.len() - ); - - FolderRecord { - name_hash: to_u64(value), - file_count: to_u32(&value[8..]), - file_records_offset: to_u32(&value[16..]), + pub(super) fn read_folder_record(value: &[u8]) -> Result { + if value.len() < FOLDER_RECORD_SIZE { + return Err(ArchiveParsingError::SliceTooSmall { + expected: FOLDER_RECORD_SIZE, + actual: value.len(), + }); } + + Ok(FolderRecord { + name_hash: to_u64(value)?, + file_count: to_u32(&value[8..])?, + file_records_offset: to_u32(&value[16..])?, + }) } } @@ -138,7 +142,7 @@ pub(super) fn read_assets( fn read_assets_with_header( mut reader: T, header: &Header, - read_folder_record: impl Fn(&[u8]) -> FolderRecord, + read_folder_record: impl Fn(&[u8]) -> Result, ) -> Result>, ArchiveParsingError> { let mut folders_buffer: Vec = vec![0; U * to_usize(header.folder_count)]; @@ -157,7 +161,7 @@ fn read_assets_with_header( let mut assets = BTreeMap::new(); for chunk in folders_buffer.chunks_exact(U) { - let folder_record = read_folder_record(chunk); + let folder_record = read_folder_record(chunk)?; let entry = assets.entry(folder_record.name_hash); if let Entry::Occupied(_) = entry { @@ -193,7 +197,7 @@ fn read_assets_with_header( .chunks_exact(FILE_RECORD_SIZE) .take(to_usize(folder_record.file_count)) { - let file_hash = to_u64(file_chunk); + let file_hash = to_u64(file_chunk)?; if !file_hashes.insert(file_hash) { return Err(ArchiveParsingError::HashCollision { diff --git a/src/archive/error.rs b/src/archive/error.rs index 379f6628..a07d320e 100644 --- a/src/archive/error.rs +++ b/src/archive/error.rs @@ -47,6 +47,7 @@ pub(crate) enum ArchiveParsingError { UsesBigEndianNumbers, FolderHashCollision(u64), HashCollision { folder_hash: u64, file_hash: u64 }, + SliceTooSmall { expected: usize, actual: usize }, } impl std::fmt::Display for ArchiveParsingError { @@ -74,6 +75,10 @@ impl std::fmt::Display for ArchiveParsingError { f, "unexpected collision for file name hash {file_hash:x} in set for folder name hash {folder_hash:x}" ), + Self::SliceTooSmall { expected, actual } => write!( + f, + "byte slice was unexpectedly too small: expected {expected} bytes, got {actual} bytes" + ), } } } @@ -92,3 +97,10 @@ impl From for ArchiveParsingError { ArchiveParsingError::IoError(value) } } + +pub(super) fn slice_too_small(slice: &[u8], expected_size: usize) -> ArchiveParsingError { + ArchiveParsingError::SliceTooSmall { + expected: expected_size, + actual: slice.len(), + } +} diff --git a/src/archive/parse.rs b/src/archive/parse.rs index c28cbc0f..c4d1c6ae 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -7,6 +7,7 @@ use std::{ use super::error::{ArchiveParsingError, ArchivePathParsingError}; use crate::{ + archive::error::slice_too_small, logging::{self, format_details}, plugin::has_ascii_extension, }; @@ -93,20 +94,30 @@ fn get_assets_in_archive( } } -pub(super) fn to_u32(bytes: &[u8]) -> u32 { - let array = - <[u8; 4]>::try_from(&bytes[..4]).expect("Bytes slice is large enough to hold a u32"); - u32::from_le_bytes(array) +pub(super) fn to_u32(bytes: &[u8]) -> Result { + const ARRAY_SIZE: usize = to_usize(u32::BITS >> 3); + + <[u8; ARRAY_SIZE]>::try_from(&bytes[..ARRAY_SIZE]) + .map(u32::from_le_bytes) + .map_err(|_e| slice_too_small(bytes, ARRAY_SIZE)) } -pub(super) fn to_u64(bytes: &[u8]) -> u64 { - let array = - <[u8; 8]>::try_from(&bytes[..8]).expect("Bytes slice is large enough to hold a u64"); - u64::from_le_bytes(array) +pub(super) fn to_u64(bytes: &[u8]) -> Result { + const ARRAY_SIZE: usize = to_usize(u64::BITS >> 3); + + <[u8; ARRAY_SIZE]>::try_from(&bytes[..ARRAY_SIZE]) + .map(u64::from_le_bytes) + .map_err(|_e| slice_too_small(bytes, ARRAY_SIZE)) } -pub(super) fn to_usize(size: u32) -> usize { - usize::try_from(size).expect("usize can hold a u32") +#[expect( + clippy::as_conversions, + reason = "Made safe by a compile-time assertion" +)] +pub(super) const fn to_usize(value: u32) -> usize { + // Error at compile time if this conversion isn't lossless. + const _: () = assert!(u32::BITS <= usize::BITS, "cannot fit a u32 into a usize!"); + value as usize } #[cfg(test)] diff --git a/src/lib.rs b/src/lib.rs index ef3e790b..724bead7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,7 +37,7 @@ clippy::error_impl_error, clippy::exit, // clippy::exhaustive_enums, - // clippy::expect_used, + clippy::expect_used, // clippy::filetype_is_file, clippy::float_cmp_const, clippy::fn_to_numeric_cast_any, @@ -91,7 +91,7 @@ clippy::unneeded_field_pattern, clippy::unreachable, clippy::unused_result_ok, - // clippy::unwrap_in_result, + clippy::unwrap_in_result, clippy::unwrap_used, // clippy::use_debug, clippy::verbose_file_reads, diff --git a/src/metadata/message.rs b/src/metadata/message.rs index c976a758..1069274c 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -312,12 +312,20 @@ impl TryFromYaml for Message { let subs = get_strings_vec_value(mapping, "subs", YamlObjectType::Message)?; if !subs.is_empty() { + #[expect( + clippy::expect_used, + reason = "Only panics if the hardcoded regex string is invalid" + )] static FMT_REGEX: LazyLock = LazyLock::new(|| { Regex::new(r"\{(\d+)\}").expect("hardcoded fmt placeholder regex should be valid") }); for mc in &mut content { if mc.text.contains("%1%") { + #[expect( + clippy::expect_used, + reason = "Only panics if the hardcoded regex string is invalid" + )] static BOOST_REGEX: LazyLock = LazyLock::new(|| { Regex::new(r"%(\d+)%") .expect("hardcoded Boost placeholder regex should be valid") diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 02f5f2a2..26b02773 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -404,6 +404,10 @@ fn extract_bash_tags(description: &str) -> Vec { } fn extract_version(description: &str) -> Result, Box> { + #[expect( + clippy::expect_used, + reason = "Only panics if a hardcoded regex string is invalid" + )] static VERSION_REGEXES: LazyLock> = LazyLock::new(|| { // The string below matches the range of version strings supported by // Pseudosem v1.0.1, excluding space separators, as they make version diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs index dc380545..f3e5c432 100644 --- a/src/sorting/groups.rs +++ b/src/sorting/groups.rs @@ -86,9 +86,13 @@ fn add_groups<'a>( ); } - let node_index = group_nodes - .get(group.name()) - .expect("Group node should have just been added"); + let Some(node_index) = group_nodes.get(group.name()) else { + logging::error!( + "Unexpectedly couldn't find node for group {}: it should have just been added to the graph", + group.name() + ); + return Err(UndefinedGroupError::new(group.name().to_string())); + }; for other_group_name in sorted_clone(group.after_groups()) { if let Some(other_index) = group_nodes.get(other_group_name) { From 2aaa13bd7efa2d35476c706f2e09e6d5d01551c1 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 18:18:32 +0100 Subject: [PATCH 096/206] Deny the clippy::str_to_string lint --- src/database/conditions.rs | 2 +- src/database/mod.rs | 2 +- src/game.rs | 8 ++++---- src/lib.rs | 2 +- src/logging.rs | 10 ++++----- src/metadata/file.rs | 2 +- src/metadata/message.rs | 2 +- src/metadata/metadata_document.rs | 20 +++++++++--------- src/metadata/yaml/emit.rs | 34 +++++++++++++++---------------- src/metadata/yaml/parse.rs | 2 +- src/plugin/mod.rs | 8 ++++---- src/sorting/error.rs | 4 ++-- src/sorting/groups.rs | 8 ++++---- src/sorting/mod.rs | 9 ++++---- src/sorting/plugins.rs | 21 ++++++++----------- src/sorting/validate.rs | 8 ++++---- src/tests.rs | 2 +- src/version.rs | 4 ++-- 18 files changed, 72 insertions(+), 76 deletions(-) diff --git a/src/database/conditions.rs b/src/database/conditions.rs index 3fd83081..ad43b79d 100644 --- a/src/database/conditions.rs +++ b/src/database/conditions.rs @@ -131,7 +131,7 @@ mod tests { let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); plugin.set_group("group1".into()); - let condition = "file(\"missing.esp\")".to_string(); + let condition = "file(\"missing.esp\")".to_owned(); let files = vec![ File::new(BLANK_ESP.into()), File::new(BLANK_DIFFERENT_ESM.into()).with_condition(condition.clone()), diff --git a/src/database/mod.rs b/src/database/mod.rs index f71e4890..ca2a6135 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -333,7 +333,7 @@ fn merge_groups(lhs: &[Group], rhs: &[Group]) -> Vec { let mut new_group = group.clone(); if let Some(description) = rhs_group.description() { - new_group = new_group.with_description(description.to_string()); + new_group = new_group.with_description(description.to_owned()); } if !rhs_group.after_groups().is_empty() { diff --git a/src/game.rs b/src/game.rs index 8a34330e..297d62b2 100644 --- a/src/game.rs +++ b/src/game.rs @@ -339,7 +339,7 @@ impl Game { .collect(); for plugin in &plugins { - loaded_plugins.insert(Filename::new(plugin.name().to_string()), plugin); + loaded_plugins.insert(Filename::new(plugin.name().to_owned()), plugin); } let loaded_plugins: Vec<_> = loaded_plugins.into_values().collect(); @@ -776,7 +776,7 @@ impl GameCache { fn insert_plugins(&mut self, plugins: Vec) { for plugin in plugins { self.plugins - .insert(Filename::new(plugin.name().to_string()), Arc::new(plugin)); + .insert(Filename::new(plugin.name().to_owned()), Arc::new(plugin)); } } @@ -793,7 +793,7 @@ impl GameCache { } fn plugin(&self, plugin_name: &str) -> Option<&Arc> { - self.plugins.get(&Filename::new(plugin_name.to_string())) + self.plugins.get(&Filename::new(plugin_name.to_owned())) } pub fn archives_iter(&self) -> impl Iterator { @@ -1259,7 +1259,7 @@ mod tests { .unwrap(); game.load_current_load_order_state().unwrap(); - load_order.push(filename.to_string()); + load_order.push(filename.to_owned()); assert_eq!(load_order, game.load_order()); } diff --git a/src/lib.rs b/src/lib.rs index 724bead7..5533d9f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -77,7 +77,7 @@ clippy::redundant_type_annotations, clippy::ref_patterns, clippy::rest_pat_in_fully_bound_structs, - // clippy::str_to_string, + clippy::str_to_string, clippy::string_lit_chars_any, // clippy::string_slice, clippy::string_to_string, diff --git a/src/logging.rs b/src/logging.rs index 86209eee..f444c29f 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -192,7 +192,7 @@ mod tests { fn callback(level: LogLevel, message: &str) { if let Ok(mut messages) = MESSAGES.lock() { - messages.push((level, message.to_string())); + messages.push((level, message.to_owned())); } } @@ -216,7 +216,7 @@ mod tests { let cloned_messages = Arc::clone(&messages); let callback = move |level, message: &str| { if let Ok(mut messages) = cloned_messages.lock() { - messages.push((level, message.to_string())); + messages.push((level, message.to_owned())); } }; @@ -237,7 +237,7 @@ mod tests { fn callback_fn(level: LogLevel, message: &str) { if let Ok(mut messages) = MESSAGES.lock() { - messages.push((level, message.to_string())); + messages.push((level, message.to_owned())); } } @@ -250,7 +250,7 @@ mod tests { let cloned_messages = Arc::clone(&messages); let callback = move |level, message: &str| { if let Ok(mut messages) = cloned_messages.lock() { - messages.push((level, message.to_string())); + messages.push((level, message.to_owned())); } }; @@ -284,7 +284,7 @@ mod tests { fn callback(level: LogLevel, message: &str) { if let Ok(mut messages) = MESSAGES.lock() { - messages.push((level, message.to_string())); + messages.push((level, message.to_owned())); } } diff --git a/src/metadata/file.rs b/src/metadata/file.rs index 0a9d1f75..7ac5b102 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -213,7 +213,7 @@ impl TryFromYaml for File { let constraint = parse_condition(h, "constraint", YamlObjectType::File)?; Ok(File { - name: Filename::new(name.to_string()), + name: Filename::new(name.to_owned()), display_name: display_name.map(|(_, s)| s.into()), detail, condition, diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 1069274c..19f1bb15 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -369,7 +369,7 @@ impl TryFromYaml for Message { if let Ok(Some(m)) = FMT_REGEX.find(&mc.text) { return Err(ParseMetadataError::new( value.span.start, - MetadataParsingErrorReason::MissingSubstitution(m.as_str().to_string()), + MetadataParsingErrorReason::MissingSubstitution(m.as_str().to_owned()), )); } } diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index dc0a0968..ea39b3d9 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -120,11 +120,11 @@ impl MetadataDocument { if plugin.is_regex_plugin() { regex_plugins.push(plugin); } else { - let filename = Filename::new(plugin.name().to_string()); + let filename = Filename::new(plugin.name().to_owned()); if let Some(old) = plugins.insert(filename, plugin) { return Err(ParseMetadataError::duplicate_entry( plugin_yaml.span.start, - old.name().to_string(), + old.name().to_owned(), YamlObjectType::PluginMetadata, ) .into()); @@ -155,13 +155,13 @@ impl MetadataDocument { if str_set.contains(bash_tag) { return Err(ParseMetadataError::duplicate_entry( bash_tag_yaml.span.start, - bash_tag.to_string(), + bash_tag.to_owned(), YamlObjectType::BashTagsElement, ) .into()); } - bash_tags.push(bash_tag.to_string()); + bash_tags.push(bash_tag.to_owned()); str_set.insert(bash_tag); } @@ -170,11 +170,11 @@ impl MetadataDocument { for group_yaml in get_as_slice(&doc, "groups", YamlObjectType::MetadataDocument)? { let group = Group::try_from_yaml(group_yaml)?; - let name = group.name().to_string(); + let name = group.name().to_owned(); if group_names.contains(&name) { return Err(ParseMetadataError::duplicate_entry( group_yaml.span.start, - group.name().to_string(), + group.name().to_owned(), YamlObjectType::Group, ) .into()); @@ -266,7 +266,7 @@ impl MetadataDocument { } pub fn find_plugin(&self, plugin_name: &str) -> Result, RegexError> { - let mut metadata = match self.plugins.get(&Filename::new(plugin_name.to_string())) { + let mut metadata = match self.plugins.get(&Filename::new(plugin_name.to_owned())) { Some(m) => m.clone(), None => PluginMetadata::new(plugin_name)?, }; @@ -303,14 +303,14 @@ impl MetadataDocument { self.regex_plugins.push(plugin_metadata); } else { self.plugins.insert( - Filename::new(plugin_metadata.name().to_string()), + Filename::new(plugin_metadata.name().to_owned()), plugin_metadata, ); } } pub fn remove_plugin_metadata(&mut self, plugin_name: &str) { - self.plugins.remove(&Filename::new(plugin_name.to_string())); + self.plugins.remove(&Filename::new(plugin_name.to_owned())); } pub fn clear(&mut self) { @@ -390,7 +390,7 @@ fn find_prelude_bounds(masterlist: &str) -> Option<(usize, usize)> { } fn indent_prelude(prelude: &str, line_ending: &str) -> String { - let prelude = ("\n ".to_string() + &prelude.replace('\n', "\n ")) + let prelude = ("\n ".to_owned() + &prelude.replace('\n', "\n ")) .replace(&format!(" {line_ending}"), line_ending); if prelude.ends_with("\n ") { diff --git a/src/metadata/yaml/emit.rs b/src/metadata/yaml/emit.rs index 5cbbafcb..a4a554bc 100644 --- a/src/metadata/yaml/emit.rs +++ b/src/metadata/yaml/emit.rs @@ -271,23 +271,23 @@ fn double_quote(value: &str) -> String { .map(|c| { if should_escape(c) { match c { - '\x00' => "\\0".to_string(), - '\x07' => "\\a".to_string(), - '\x08' => "\\b".to_string(), - '\x09' => "\\t".to_string(), - '\x0A' => "\\n".to_string(), - '\x0B' => "\\v".to_string(), - '\x0C' => "\\f".to_string(), - '\x0D' => "\\r".to_string(), - '\x1B' => "\\e".to_string(), - '\x20' => "\\x20".to_string(), - '"' => "\\\"".to_string(), - '/' => "\\/".to_string(), - '\\' => "\\\\".to_string(), - '\u{0085}' => "\\N".to_string(), - '\u{00A0}' => "\\_".to_string(), - '\u{2028}' => "\\L".to_string(), - '\u{2029}' => "\\P".to_string(), + '\x00' => "\\0".to_owned(), + '\x07' => "\\a".to_owned(), + '\x08' => "\\b".to_owned(), + '\x09' => "\\t".to_owned(), + '\x0A' => "\\n".to_owned(), + '\x0B' => "\\v".to_owned(), + '\x0C' => "\\f".to_owned(), + '\x0D' => "\\r".to_owned(), + '\x1B' => "\\e".to_owned(), + '\x20' => "\\x20".to_owned(), + '"' => "\\\"".to_owned(), + '/' => "\\/".to_owned(), + '\\' => "\\\\".to_owned(), + '\u{0085}' => "\\N".to_owned(), + '\u{00A0}' => "\\_".to_owned(), + '\u{2028}' => "\\L".to_owned(), + '\u{2029}' => "\\P".to_owned(), '\u{00}'..='\u{FF}' => format!("\\x{:02X}", u32::from(c)), '\u{0100}'..='\u{FFFF}' => format!("\\u{:04X}", u32::from(c)), c => format!("\\U{:08X}", u32::from(c)), diff --git a/src/metadata/yaml/parse.rs b/src/metadata/yaml/parse.rs index 787e2386..e953815a 100644 --- a/src/metadata/yaml/parse.rs +++ b/src/metadata/yaml/parse.rs @@ -185,7 +185,7 @@ pub fn parse_condition( ) -> Result>, ParseMetadataError> { match get_string_value(mapping, key, yaml_type)? { Some((marker, s)) => { - let s = s.to_string(); + let s = s.to_owned(); if let Err(e) = Expression::from_str(&s) { return Err(ParseMetadataError::invalid_condition(marker, s, e)); } diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 26b02773..a877647a 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -358,8 +358,8 @@ pub(crate) fn plugins_metadata( fn name_string(game_type: GameType, path: &Path) -> Result { match path.file_name() { Some(f) => match f.to_str() { - Some(f) if game_type == GameType::OpenMW => Ok(f.to_string()), - Some(f) => Ok(trim_dot_ghost(f).to_string()), + Some(f) if game_type == GameType::OpenMW => Ok(f.to_owned()), + Some(f) => Ok(trim_dot_ghost(f).to_owned()), None => Err(LoadPluginError::InvalidFilename( InvalidFilenameReason::NonUnicode, )), @@ -396,7 +396,7 @@ fn extract_bash_tags(description: &str) -> Vec { if let Some(end_pos) = description[start_pos..].find("}}") { return description[start_pos..start_pos + end_pos] .split(',') - .map(|s| s.trim().to_string()) + .map(|s| s.trim().to_owned()) .collect(); } } @@ -1147,7 +1147,7 @@ Requires Skyrim Special Edition 1.5.39 or greater. assert_eq!( vec![ - "C.Climate".to_string(), + "C.Climate".to_owned(), "C.Encounter".into(), "C.ImageSpace".into(), "C.Light".into(), diff --git a/src/sorting/error.rs b/src/sorting/error.rs index dafec336..4c37b4da 100644 --- a/src/sorting/error.rs +++ b/src/sorting/error.rs @@ -56,10 +56,10 @@ pub(crate) fn display_cycle(cycle: &[Vertex]) -> String { if let Some(edge_type) = v.out_edge_type() { format!("{} --[{}]-> ", v.name(), edge_type) } else { - v.name().to_string() + v.name().to_owned() } }) - .chain(cycle.first().iter().map(|v| v.name().to_string())) + .chain(cycle.first().iter().map(|v| v.name().to_owned())) .collect() } diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs index f3e5c432..d152a334 100644 --- a/src/sorting/groups.rs +++ b/src/sorting/groups.rs @@ -91,14 +91,14 @@ fn add_groups<'a>( "Unexpectedly couldn't find node for group {}: it should have just been added to the graph", group.name() ); - return Err(UndefinedGroupError::new(group.name().to_string())); + return Err(UndefinedGroupError::new(group.name().to_owned())); }; for other_group_name in sorted_clone(group.after_groups()) { if let Some(other_index) = group_nodes.get(other_group_name) { graph.update_edge(*other_index, *node_index, edge_type); } else { - return Err(UndefinedGroupError::new(other_group_name.to_string())); + return Err(UndefinedGroupError::new(other_group_name.to_owned())); } } } @@ -198,7 +198,7 @@ fn find_node_by_weight( Ok(n) } else { logging::error!("Can't find group with name {}", weight); - Err(UndefinedGroupError::new(weight.to_string())) + Err(UndefinedGroupError::new(weight.to_owned())) } } @@ -272,7 +272,7 @@ pub fn get_default_group_node(graph: &GroupsGraph) -> Result Self { Self { - name: name.to_string(), + name: name.to_owned(), ..Default::default() } } pub fn add_master(&mut self, plugin_name: &str) { - self.masters.push(plugin_name.to_string()); + self.masters.push(plugin_name.to_owned()); } pub fn add_overlapping_records(&mut self, plugin_name: &str) { - self.overlapping_record_plugins - .push(plugin_name.to_string()); + self.overlapping_record_plugins.push(plugin_name.to_owned()); } pub fn add_overlapping_assets(&mut self, plugin_name: &str) { - self.overlapping_asset_plugins.push(plugin_name.to_string()); + self.overlapping_asset_plugins.push(plugin_name.to_owned()); } } diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index c0e3a3b3..8417bb4b 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -142,10 +142,7 @@ impl SortingPlugin for Plugin { } fn to_filenames(files: &[File]) -> Box<[String]> { - files - .iter() - .map(|f| f.name().as_str().to_string()) - .collect() + files.iter().map(|f| f.name().as_str().to_owned()).collect() } type InnerPluginsGraph<'a, T> = Graph>, EdgeType>; @@ -288,7 +285,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { } fn check_for_cycles(&mut self) -> Result<(), CyclicInteractionError> { - if let Some(cycle) = find_cycle(&self.inner, |node| node.name().to_string()) { + if let Some(cycle) = find_cycle(&self.inner, |node| node.name().to_owned()) { Err(CyclicInteractionError::new(cycle)) } else { Ok(()) @@ -692,7 +689,7 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { fn topological_sort(&self) -> Result, SortingError> { petgraph::algo::toposort(&self.inner, None) - .map_err(|e| SortingError::CycleInvolving(self[e.node_id()].name().to_string())) + .map_err(|e| SortingError::CycleInvolving(self[e.node_id()].name().to_owned())) } /// Returns the first pair of consecutive nodes that don't have an edge joining them. @@ -862,7 +859,7 @@ fn sort_plugins_partition( let sorted_plugin_names = sorted_nodes .into_iter() - .map(|i| graph[i].name().to_string()) + .map(|i| graph[i].name().to_owned()) .collect(); Ok(sorted_plugin_names) @@ -926,7 +923,7 @@ impl<'a, 'b, T: SortingPlugin> PathFinder<'a, 'b, T> { path_to_string(self.graph, &path) ); return Err(PathfindingError::PrecedingNodeNotFound( - self.graph[current_node].name().to_string(), + self.graph[current_node].name().to_owned(), )); } } @@ -947,7 +944,7 @@ impl<'a, 'b, T: SortingPlugin> PathFinder<'a, 'b, T> { path_to_string(self.graph, &path) ); return Err(PathfindingError::FollowingNodeNotFound( - self.graph[current_node].name().to_string(), + self.graph[current_node].name().to_owned(), )); } } @@ -3109,7 +3106,7 @@ mod tests { let sorted_plugin_names: Vec<_> = sorted .into_iter() - .map(|i| graph[i].name().to_string()) + .map(|i| graph[i].name().to_owned()) .collect(); assert_eq!( @@ -3154,7 +3151,7 @@ mod tests { let sorted_plugin_names: Vec<_> = sorted .into_iter() - .map(|i| graph[i].name().to_string()) + .map(|i| graph[i].name().to_owned()) .collect(); assert_eq!( @@ -3199,7 +3196,7 @@ mod tests { let sorted_plugin_names: Vec<_> = sorted .into_iter() - .map(|i| graph[i].name().to_string()) + .map(|i| graph[i].name().to_owned()) .collect(); assert_eq!( diff --git a/src/sorting/validate.rs b/src/sorting/validate.rs index f8caa7d6..7494c1b9 100644 --- a/src/sorting/validate.rs +++ b/src/sorting/validate.rs @@ -93,7 +93,7 @@ fn validate_plugin( if non_masters.contains(&key) { return Err(CyclicInteractionError::new(vec![ Vertex::new(master).with_out_edge_type(EdgeType::Master), - Vertex::new(plugin.name().to_string()).with_out_edge_type(EdgeType::MasterFlag), + Vertex::new(plugin.name().to_owned()).with_out_edge_type(EdgeType::MasterFlag), ]) .into()); } @@ -164,14 +164,14 @@ fn validate_files( if non_masters.contains(&key) { return Err(CyclicInteractionError::new(vec![ Vertex::new(file.clone()).with_out_edge_type(edge_type), - Vertex::new(plugin_name.to_string()).with_out_edge_type(EdgeType::MasterFlag), + Vertex::new(plugin_name.to_owned()).with_out_edge_type(EdgeType::MasterFlag), ])); } if blueprint_masters.contains(&key) { return Err(CyclicInteractionError::new(vec![ Vertex::new(file.clone()).with_out_edge_type(edge_type), - Vertex::new(plugin_name.to_string()).with_out_edge_type(EdgeType::BlueprintMaster), + Vertex::new(plugin_name.to_owned()).with_out_edge_type(EdgeType::BlueprintMaster), ])); } } @@ -191,7 +191,7 @@ fn validate_early_loading_plugins( // Just report the cycle to the first master. return Err(CyclicInteractionError::new(vec![ Vertex::new(plugin.to_string()).with_out_edge_type(EdgeType::Hardcoded), - Vertex::new(master.name().to_string()).with_out_edge_type(EdgeType::MasterFlag), + Vertex::new(master.name().to_owned()).with_out_edge_type(EdgeType::MasterFlag), ])); } } diff --git a/src/tests.rs b/src/tests.rs index e4246767..32e6ff44 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -284,7 +284,7 @@ impl Fixture { } else { std::fs::rename( data_path.join(BLANK_MASTER_DEPENDENT_ESM), - data_path.join(BLANK_MASTER_DEPENDENT_ESM.to_string() + ".ghost"), + data_path.join(BLANK_MASTER_DEPENDENT_ESM.to_owned() + ".ghost"), ) .unwrap(); } diff --git a/src/version.rs b/src/version.rs index 0bb260c9..a2a7f2b3 100644 --- a/src/version.rs +++ b/src/version.rs @@ -9,12 +9,12 @@ pub const LIBLOOT_VERSION_PATCH: u32 = parse_u32(env!("CARGO_PKG_VERSION_PATCH") /// Get the library version in the form "major.minor.patch". pub fn libloot_version() -> String { - env!("CARGO_PKG_VERSION").to_string() + env!("CARGO_PKG_VERSION").to_owned() } /// Get the ID of the source control revision that libloot was built from. pub fn libloot_revision() -> String { - libloot_revision_const().to_string() + libloot_revision_const().to_owned() } /// Checks whether the loaded API is compatible with the given version of the From 460ab834176dceb60f25aa5431dfe7abf3a34e97 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 18:19:10 +0100 Subject: [PATCH 097/206] Deny the clippy::use_debug lint --- src/archive/error.rs | 8 ++++++-- src/lib.rs | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/archive/error.rs b/src/archive/error.rs index a07d320e..565cba01 100644 --- a/src/archive/error.rs +++ b/src/archive/error.rs @@ -55,8 +55,12 @@ impl std::fmt::Display for ArchiveParsingError { match self { Self::IoError(_) => write!(f, "an I/O error occurred"), Self::UnsupportedHeaderVersion(v) => write!(f, "unsupported archive version {v}"), - Self::UnsupportedHeaderArchiveType(a) => write!(f, "unsupported archive type {a:?}"), - Self::UnsupportedArchiveTypeId(t) => write!(f, "unsupported archive type ID {t:?}"), + Self::UnsupportedHeaderArchiveType(a) => { + write!(f, "unsupported archive type {}", a.escape_ascii()) + } + Self::UnsupportedArchiveTypeId(t) => { + write!(f, "unsupported archive type ID {}", t.escape_ascii()) + } Self::InvalidRecordsOffset(o) => write!(f, "invalid records offset {o}"), Self::InvalidFolderNameLengthOffset(o) => { write!(f, "invalid folder name length offset {o}") diff --git a/src/lib.rs b/src/lib.rs index 5533d9f8..2e1be808 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -93,7 +93,7 @@ clippy::unused_result_ok, clippy::unwrap_in_result, clippy::unwrap_used, - // clippy::use_debug, + clippy::use_debug, clippy::verbose_file_reads, // clippy::wildcard_enum_match_arm, )] From 10ff304473835fa2b769ec52ef555c196786a83f Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 21:15:16 +0100 Subject: [PATCH 098/206] Deny the clippy::string_slice lint --- src/lib.rs | 2 +- src/metadata/metadata_document.rs | 63 +++++++++++++++++++------------ src/metadata/plugin_metadata.rs | 51 ++++++++++++------------- src/plugin/mod.rs | 13 ++----- 4 files changed, 67 insertions(+), 62 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 2e1be808..d86a83c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,7 +79,7 @@ clippy::rest_pat_in_fully_bound_structs, clippy::str_to_string, clippy::string_lit_chars_any, - // clippy::string_slice, + clippy::string_slice, clippy::string_to_string, clippy::suspicious_xor_used_as_pow, clippy::tests_outside_test_module, diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index ea39b3d9..88074188 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -336,10 +336,10 @@ impl std::default::Default for MetadataDocument { fn replace_prelude(masterlist: String, prelude: &str) -> String { let line_ending = detect_line_ending(&masterlist); - if let Some((start, end)) = find_prelude_bounds(&masterlist) { + if let Some((start, end)) = split_on_prelude(&masterlist) { let prelude = indent_prelude(prelude, line_ending); - masterlist[..start].to_string() + &prelude + &masterlist[end..] + format!("{start}{prelude}{end}") } else { masterlist } @@ -359,34 +359,49 @@ fn detect_line_ending(masterlist: &str) -> &'static str { } } -fn find_prelude_bounds(masterlist: &str) -> Option<(usize, usize)> { - let prelude_on_first_line = "prelude:"; - let prelude_on_new_line = "\nprelude:"; +fn split_on_prelude(masterlist: &str) -> Option<(&str, &str)> { + let (prefix, remainder) = split_on_prelude_start(masterlist)?; - let start = if masterlist.starts_with(prelude_on_first_line) { - prelude_on_first_line.len() - } else if let Some(pos) = masterlist.find(prelude_on_new_line) { - pos + prelude_on_new_line.len() - } else { - return None; - }; - - let mut pos = start; - while let Some(next_line_break_pos) = masterlist[pos..].find('\n') { - if next_line_break_pos == masterlist.len() - 1 { - break; + let mut iter = remainder.bytes().enumerate().peekable(); + while let Some((index, byte)) = iter.next() { + if byte != b'\n' { + continue; } - pos += next_line_break_pos + 1; - - if let Some(c) = masterlist.as_bytes().get(pos) { - if !matches!(*c, b' ' | b'#' | b'\n' | b'\r') { - return Some((start, pos - 1)); + if let Some((_, next_byte)) = iter.peek() { + if !matches!(next_byte, b' ' | b'#' | b'\n' | b'\r') { + // Slicing at index should never fail, but we can't prove that, + // and we don't want to risk panicking. + if let Some(suffix) = remainder.get(index..) { + return Some((prefix, suffix)); + } } } } - Some((start, masterlist.len())) + Some((prefix, "")) +} + +fn split_on_prelude_start(masterlist: &str) -> Option<(&str, &str)> { + let prelude_on_first_line = "prelude:"; + let prelude_on_new_line = "\nprelude:"; + + if let Some(remainder) = masterlist.strip_prefix(prelude_on_first_line) { + Some((prelude_on_first_line, remainder)) + } else { + if let Some(pos) = masterlist.find(prelude_on_new_line) { + let index = pos + prelude_on_new_line.len(); + // A checked split shouldn't be necessary, but there's no + // split_inclusive_once() method, so we need to find and split in + // two steps and there's always the risk of a bug being introduced + // in the middle. + if let Some((prefix, remainder)) = masterlist.split_at_checked(index) { + return Some((prefix, remainder)) + } + } + None + } + } fn indent_prelude(prelude: &str, line_ending: &str) -> String { @@ -394,7 +409,7 @@ fn indent_prelude(prelude: &str, line_ending: &str) -> String { .replace(&format!(" {line_ending}"), line_ending); if prelude.ends_with("\n ") { - prelude[..prelude.len() - 2].to_string() + prelude.trim_end_matches(' ').to_owned() } else { prelude } diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 9b5f8b3f..71719fa4 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -318,8 +318,13 @@ impl std::hash::Hash for PluginName { } pub(crate) fn trim_dot_ghost(string: &str) -> &str { - if iends_with_ascii(string, GHOST_FILE_EXTENSION) { - &string[..(string.len() - 6)] + let suffix_start_index = string.len().saturating_sub(GHOST_FILE_EXTENSION.len()); + if let Some((first, last)) = string.split_at_checked(suffix_start_index) { + if last.eq_ignore_ascii_case(GHOST_FILE_EXTENSION) { + first + } else { + string + } } else { string } @@ -352,38 +357,30 @@ fn merge_slices(target: &mut Box<[T]>, source: &[T]) { } fn replace_capturing_groups(regex_string: &str) -> Cow<'_, str> { - let mut output = String::new(); - let mut prefix_length = 0; - let mut remainder = regex_string; - while let Some(pos) = remainder.find('(') { - let Some((before, after)) = remainder.split_at_checked(pos + 1) else { + let mut iter = regex_string.split_inclusive('(').peekable(); + + let mut parts = Vec::new(); + + while let Some(before) = iter.next() { + let Some(after) = iter.peek() else { + parts.push(before); break; }; if after.starts_with('?') || (before.ends_with("\\(") && !before.ends_with("\\\\(")) { - if output.is_empty() { - // No need to copy the string yet. - prefix_length += before.len(); - } else { - output.push_str(before); - } + parts.push(before); } else { - if output.is_empty() { - output.push_str(®ex_string[..prefix_length]); - } - - output.push_str(before); - output.push_str("?:"); + parts.push(before); + parts.push("?:"); } - - remainder = after; } - if output.is_empty() { + let new_length: usize = parts.iter().map(|s| s.len()).sum(); + + if new_length == regex_string.len() { Cow::Borrowed(regex_string) } else { - output.push_str(remainder); - Cow::Owned(output) + Cow::Owned(parts.into_iter().collect()) } } @@ -1237,7 +1234,7 @@ mod tests { match replace_capturing_groups(input) { Cow::Borrowed(output) => assert_eq!(input, output), - Cow::Owned(output) => panic!("Expected borrowed output, got {output}"), + Cow::Owned(output) => panic!("Expected borrowed output, got \"{output}\""), } } @@ -1247,14 +1244,14 @@ mod tests { match replace_capturing_groups(input) { Cow::Borrowed(output) => assert_eq!(input, output), - Cow::Owned(output) => panic!("Expected borrowed output, got {output}"), + Cow::Owned(output) => panic!("Expected borrowed output, got \"{output}\""), } let input = "no paren(?:th(?:e)s)es"; match replace_capturing_groups(input) { Cow::Borrowed(output) => assert_eq!(input, output), - Cow::Owned(output) => panic!("Expected borrowed output, got {output}"), + Cow::Owned(output) => panic!("Expected borrowed output, got \"{output}\""), } } diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index a877647a..d98da511 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -388,16 +388,9 @@ fn calculate_crc(path: &Path) -> std::io::Result { } fn extract_bash_tags(description: &str) -> Vec { - let bash_tags_opener = "{{BASH:"; - - if let Some(mut start_pos) = description.find(bash_tags_opener) { - start_pos += bash_tags_opener.len(); - - if let Some(end_pos) = description[start_pos..].find("}}") { - return description[start_pos..start_pos + end_pos] - .split(',') - .map(|s| s.trim().to_owned()) - .collect(); + if let Some((_, bash_tags)) = description.split_once("{{BASH:") { + if let Some((bash_tags, _)) = bash_tags.split_once("}}") { + return bash_tags.split(',').map(|s| s.trim().to_owned()).collect(); } } Vec::new() From 0fd0f94c2a6bf838d752fcc4514008b1144e15f3 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 22:05:17 +0100 Subject: [PATCH 099/206] Deny the clippy::indexing_slicing lint --- src/archive/ba2.rs | 13 ++++++++----- src/archive/bsa.rs | 30 +++++++++++++++--------------- src/archive/find.rs | 3 +-- src/archive/parse.rs | 25 ++++++++++++++++--------- src/lib.rs | 3 ++- src/metadata/metadata_document.rs | 25 +++++-------------------- src/sorting/plugins.rs | 26 ++++++++++++++++++++------ src/version.rs | 4 ++++ 8 files changed, 71 insertions(+), 58 deletions(-) diff --git a/src/archive/ba2.rs b/src/archive/ba2.rs index e2b56ef0..e9990802 100644 --- a/src/archive/ba2.rs +++ b/src/archive/ba2.rs @@ -28,10 +28,10 @@ impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result { let header = Self { type_id: TYPE_ID, - version: to_u32(&value)?, + version: to_u32(&value, 0)?, archive_type: to_archive_type(&value)?, - file_count: to_u32(&value[8..])?, - file_paths_offset: to_u64(&value[12..])?, + file_count: to_u32(&value, 8)?, + file_paths_offset: to_u64(&value, 12)?, }; // The header version is 1, 7 or 8 for Fallout 4 and 2 or 3 for Starfield. @@ -140,8 +140,11 @@ fn trim_slashes(mut path_bytes: &[u8]) -> &[u8] { } fn rsplit_on(slice: &[u8], needle: u8) -> Option<(&[u8], &[u8])> { - let index = slice.iter().rposition(|b| *b == needle)?; - Some((&slice[..index], &slice[index + 1..])) + let mut iter = slice.rsplitn(2, |b| *b == needle); + let second = iter.next()?; + let first = iter.next()?; + + Some((first, second)) } fn hash(value: &T) -> u64 { diff --git a/src/archive/bsa.rs b/src/archive/bsa.rs index a4fc0e93..04c7d07e 100644 --- a/src/archive/bsa.rs +++ b/src/archive/bsa.rs @@ -30,14 +30,14 @@ impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result { let header = Self { type_id: TYPE_ID, - version: to_u32(&value)?, - records_offset: to_u32(&value[4..])?, - archive_flags: to_u32(&value[8..])?, - folder_count: to_u32(&value[12..])?, - total_file_count: to_u32(&value[16..])?, - total_folder_names_length: to_u32(&value[20..])?, - total_file_names_length: to_u32(&value[24..])?, - content_type_flags: to_u32(&value[28..])?, + version: to_u32(&value, 0)?, + records_offset: to_u32(&value, 4)?, + archive_flags: to_u32(&value, 8)?, + folder_count: to_u32(&value, 12)?, + total_file_count: to_u32(&value, 16)?, + total_folder_names_length: to_u32(&value, 20)?, + total_file_names_length: to_u32(&value, 24)?, + content_type_flags: to_u32(&value, 28)?, }; if to_usize(header.records_offset) != HEADER_SIZE { @@ -80,9 +80,9 @@ mod v103 { } Ok(FolderRecord { - name_hash: to_u64(value)?, - file_count: to_u32(&value[8..])?, - file_records_offset: to_u32(&value[12..])?, + name_hash: to_u64(value, 0)?, + file_count: to_u32(value, 8)?, + file_records_offset: to_u32(value, 12)?, }) } } @@ -106,9 +106,9 @@ mod v105 { } Ok(FolderRecord { - name_hash: to_u64(value)?, - file_count: to_u32(&value[8..])?, - file_records_offset: to_u32(&value[16..])?, + name_hash: to_u64(value, 0)?, + file_count: to_u32(value, 8)?, + file_records_offset: to_u32(value, 16)?, }) } } @@ -197,7 +197,7 @@ fn read_assets_with_header( .chunks_exact(FILE_RECORD_SIZE) .take(to_usize(folder_record.file_count)) { - let file_hash = to_u64(file_chunk)?; + let file_hash = to_u64(file_chunk, 0)?; if !file_hashes.insert(file_hash) { return Err(ArchiveParsingError::HashCollision { diff --git a/src/archive/find.rs b/src/archive/find.rs index 4c80319c..03df9083 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -111,8 +111,7 @@ fn find_associated_archives_with_arbitrary_suffixes( return false; } - let Ok(filename) = std::str::from_utf8(&archive_filename.as_bytes()[..plugin_stem_len]) - else { + let Some(filename) = archive_filename.get(..plugin_stem_len) else { return false; }; diff --git a/src/archive/parse.rs b/src/archive/parse.rs index c4d1c6ae..c81249e0 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -94,20 +94,27 @@ fn get_assets_in_archive( } } -pub(super) fn to_u32(bytes: &[u8]) -> Result { +pub(super) fn to_u32(bytes: &[u8], start_index: usize) -> Result { const ARRAY_SIZE: usize = to_usize(u32::BITS >> 3); - - <[u8; ARRAY_SIZE]>::try_from(&bytes[..ARRAY_SIZE]) - .map(u32::from_le_bytes) - .map_err(|_e| slice_too_small(bytes, ARRAY_SIZE)) + subarray::(bytes, start_index).map(u32::from_le_bytes) } -pub(super) fn to_u64(bytes: &[u8]) -> Result { +pub(super) fn to_u64(bytes: &[u8], start_index: usize) -> Result { const ARRAY_SIZE: usize = to_usize(u64::BITS >> 3); + subarray::(bytes, start_index).map(u64::from_le_bytes) +} - <[u8; ARRAY_SIZE]>::try_from(&bytes[..ARRAY_SIZE]) - .map(u64::from_le_bytes) - .map_err(|_e| slice_too_small(bytes, ARRAY_SIZE)) +fn subarray( + bytes: &[u8], + start_index: usize, +) -> Result<[u8; SIZE], ArchiveParsingError> { + let stop_index = start_index + SIZE; + + let bytes = bytes + .get(start_index..stop_index) + .ok_or_else(|| slice_too_small(bytes, stop_index))?; + + <[u8; SIZE]>::try_from(bytes).map_err(|_e| slice_too_small(bytes, SIZE)) } #[expect( diff --git a/src/lib.rs b/src/lib.rs index d86a83c4..77e327a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,7 +44,7 @@ clippy::get_unwrap, clippy::host_endian_bytes, clippy::if_then_some_else_none, - // clippy::indexing_slicing, + clippy::indexing_slicing, clippy::infinite_loop, clippy::integer_division, clippy::integer_division_remainder_used, @@ -101,6 +101,7 @@ test, allow( clippy::assertions_on_result_states, + clippy::indexing_slicing, clippy::missing_asserts_for_indexing, clippy::panic, clippy::unwrap_used, diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index 88074188..72705097 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -335,9 +335,8 @@ impl std::default::Default for MetadataDocument { } fn replace_prelude(masterlist: String, prelude: &str) -> String { - let line_ending = detect_line_ending(&masterlist); if let Some((start, end)) = split_on_prelude(&masterlist) { - let prelude = indent_prelude(prelude, line_ending); + let prelude = indent_prelude(prelude); format!("{start}{prelude}{end}") } else { @@ -345,20 +344,6 @@ fn replace_prelude(masterlist: String, prelude: &str) -> String { } } -fn detect_line_ending(masterlist: &str) -> &'static str { - if let Some(pos) = masterlist.rfind('\n') { - if pos == 0 { - "\n" - } else if masterlist.as_bytes()[pos - 1] == b'\r' { - "\r\n" - } else { - "\n" - } - } else { - "\n" - } -} - fn split_on_prelude(masterlist: &str) -> Option<(&str, &str)> { let (prefix, remainder) = split_on_prelude_start(masterlist)?; @@ -396,17 +381,17 @@ fn split_on_prelude_start(masterlist: &str) -> Option<(&str, &str)> { // two steps and there's always the risk of a bug being introduced // in the middle. if let Some((prefix, remainder)) = masterlist.split_at_checked(index) { - return Some((prefix, remainder)) + return Some((prefix, remainder)); } } None } - } -fn indent_prelude(prelude: &str, line_ending: &str) -> String { +fn indent_prelude(prelude: &str) -> String { let prelude = ("\n ".to_owned() + &prelude.replace('\n', "\n ")) - .replace(&format!(" {line_ending}"), line_ending); + .replace(" \r\n", "\r\n") + .replace(" \n", "\n"); if prelude.ends_with("\n ") { prelude.trim_end_matches(' ').to_owned() diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 8417bb4b..7c4bf892 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -996,8 +996,10 @@ fn get_plugins_in_groups( let mut keys = plugins_in_groups.keys().collect::>(); keys.sort(); for key in keys { - let plugin_names: Vec<_> = plugins_in_groups[key] - .iter() + let plugin_names: Vec<_> = plugins_in_groups + .get(key) + .into_iter() + .flatten() .map(|i| format!("\"{}\"", graph[*i].name())) .collect(); logging::debug!("\t{}: {}", key, plugin_names.join(", ")); @@ -1087,13 +1089,25 @@ impl<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, edge_stack_index: usize, target_plugins: &[PluginNodeIndex], ) { - let from_plugins = self.edge_stack[edge_stack_index].1; + let Some([from_edge, edges @ ..]) = self.edge_stack.get(edge_stack_index..) else { + if is_log_enabled(LogLevel::Error) { + logging::error!( + "Unexpected invalid edge stack index {} for edge stack {:?}", + edge_stack_index, + self.edge_stack + .iter() + .map(|e| e.0.weight()) + .collect::>() + ); + } + return; + }; - let path_involves_user_metadata = self.edge_stack[edge_stack_index..] - .iter() + let path_involves_user_metadata = std::iter::once(from_edge) + .chain(edges.iter()) .any(|p| *p.0.weight() == EdgeType::UserLoadAfter); - for from_plugin in from_plugins { + for from_plugin in from_edge.1 { self.add_edges_from_plugin(*from_plugin, target_plugins, path_involves_user_metadata); } } diff --git a/src/version.rs b/src/version.rs index a2a7f2b3..5965889e 100644 --- a/src/version.rs +++ b/src/version.rs @@ -32,6 +32,10 @@ pub fn is_compatible(major: u32, minor: u32, _patch: u32) -> bool { clippy::as_conversions, reason = "Can't const-convert the byte to a u32 a safer way" )] +#[expect( + clippy::indexing_slicing, + reason = "Can't const-convert the byte to a u8 a safer way" +)] const fn parse_u32(value: &str) -> u32 { let mut acc = 0; let mut i = 0; From b93a83aa3d80227ab326f7dc05e687fb77ffb9f3 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 22:38:34 +0100 Subject: [PATCH 100/206] Deny various rustc and clippy lints in Python wrapper --- pyo3/src/database.rs | 44 ++++++++++---------- pyo3/src/error.rs | 14 ++++--- pyo3/src/game.rs | 74 ++++++++++++++++++---------------- pyo3/src/lib.rs | 96 ++++++++++++++++++++++++++++++++++++++++++++ pyo3/src/metadata.rs | 12 ++++-- 5 files changed, 176 insertions(+), 64 deletions(-) diff --git a/pyo3/src/database.rs b/pyo3/src/database.rs index 99dc5ee4..2bd7d8a9 100644 --- a/pyo3/src/database.rs +++ b/pyo3/src/database.rs @@ -22,14 +22,16 @@ pub struct Database(Arc>); #[pymethods] impl Database { + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] pub fn load_masterlist(&self, path: PathBuf) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .load_masterlist(&path) .map_err(Into::into) } + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] pub fn load_masterlist_with_prelude( &self, masterlist_path: PathBuf, @@ -37,19 +39,21 @@ impl Database { ) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .load_masterlist_with_prelude(&masterlist_path, &prelude_path) .map_err(Into::into) } + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] pub fn load_userlist(&self, path: PathBuf) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .load_userlist(&path) .map_err(Into::into) } + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] pub fn write_user_metadata( &self, output_path: PathBuf, @@ -63,11 +67,12 @@ impl Database { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .write_user_metadata(&output_path, write_mode) .map_err(Into::into) } + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] pub fn write_minimal_list( &self, output_path: PathBuf, @@ -81,7 +86,7 @@ impl Database { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .write_minimal_list(&output_path, write_mode) .map_err(Into::into) } @@ -89,7 +94,7 @@ impl Database { pub fn evaluate(&self, condition: &str) -> Result { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .evaluate(condition) .map_err(Into::into) } @@ -98,7 +103,7 @@ impl Database { Ok(self .0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .known_bash_tags()) } @@ -108,7 +113,7 @@ impl Database { ) -> Result, VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .general_messages(evaluate_conditions) .map(|v| v.into_iter().map(Into::into).collect()) .map_err(Into::into) @@ -118,7 +123,7 @@ impl Database { Ok(self .0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .groups(include_user_metadata) .into_iter() .map(Into::into) @@ -129,7 +134,7 @@ impl Database { Ok(self .0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .user_groups() .iter() .cloned() @@ -141,7 +146,7 @@ impl Database { let groups = groups.into_iter().map(Into::into).collect(); self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .set_user_groups(groups); Ok(()) } @@ -153,7 +158,7 @@ impl Database { ) -> Result, VerboseError> { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .groups_path(from_group_name, to_group_name) .map(|v| v.into_iter().map(Into::into).collect()) .map_err(Into::into) @@ -167,7 +172,7 @@ impl Database { ) -> Result, VerboseError> { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .plugin_metadata(plugin_name, include_user_metadata, evaluate_conditions) .map(|p| p.map(Into::into)) .map_err(Into::into) @@ -180,7 +185,7 @@ impl Database { ) -> Result, VerboseError> { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .plugin_user_metadata(plugin_name, evaluate_conditions) .map(|p| p.map(Into::into)) .map_err(Into::into) @@ -192,7 +197,7 @@ impl Database { ) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .set_plugin_user_metadata(plugin_metadata.into()); Ok(()) } @@ -200,7 +205,7 @@ impl Database { pub fn discard_plugin_user_metadata(&self, plugin: &str) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .discard_plugin_user_metadata(plugin); Ok(()) } @@ -208,7 +213,7 @@ impl Database { pub fn discard_all_user_metadata(&self) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .discard_all_user_metadata(); Ok(()) } @@ -259,10 +264,7 @@ impl Vertex { "{}({}, {})", class_name, inner.name(), - inner - .out_edge_type() - .map(repr_edge_type) - .unwrap_or(NONE_REPR), + inner.out_edge_type().map_or(NONE_REPR, repr_edge_type), )) } diff --git a/pyo3/src/error.rs b/pyo3/src/error.rs index ff48b6b9..b8d0b0ab 100644 --- a/pyo3/src/error.rs +++ b/pyo3/src/error.rs @@ -48,7 +48,7 @@ impl From for VerboseError { fn from(value: GameHandleCreationError) -> Self { match value { GameHandleCreationError::LoadOrderError(e) => e.into(), - _ => Self::Other(Box::new(value)), + GameHandleCreationError::NotADirectory(_) | _ => Self::Other(Box::new(value)), } } } @@ -60,7 +60,11 @@ impl From for VerboseError { SortPluginsError::UndefinedGroup(g) => Self::UndefinedGroupError(g), SortPluginsError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), SortPluginsError::PluginDataError(e) => e.into(), - _ => Self::Other(Box::new(value)), + SortPluginsError::DatabaseLockPoisoned + | SortPluginsError::PluginNotLoaded(_) + | SortPluginsError::CycleFoundInvolving(_) + | SortPluginsError::PathfindingError(_) + | _ => Self::Other(Box::new(value)), } } } @@ -69,7 +73,7 @@ impl From for VerboseError { fn from(value: LoadOrderStateError) -> Self { match value { LoadOrderStateError::LoadOrderError(e) => e.into(), - _ => Self::Other(Box::new(value)), + LoadOrderStateError::DatabaseLockPoisoned | _ => Self::Other(Box::new(value)), } } } @@ -79,7 +83,7 @@ impl From for VerboseError { match value { GroupsPathError::UndefinedGroup(g) => Self::UndefinedGroupError(g), GroupsPathError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), - _ => Self::Other(Box::new(value)), + GroupsPathError::PathfindingError(_) => Self::Other(Box::new(value)), } } } @@ -88,7 +92,7 @@ impl From for VerboseError { fn from(value: MetadataRetrievalError) -> Self { match value { MetadataRetrievalError::ConditionEvaluationError(e) => e.into(), - _ => Self::Other(Box::new(value)), + MetadataRetrievalError::RegexError(_) => Self::Other(Box::new(value)), } } } diff --git a/pyo3/src/game.rs b/pyo3/src/game.rs index 484ca47d..462e53fe 100644 --- a/pyo3/src/game.rs +++ b/pyo3/src/game.rs @@ -5,21 +5,20 @@ use pyo3::{pyclass, pymethods}; use crate::{database::Database, error::VerboseError, plugin::Plugin}; -#[allow(non_camel_case_types)] #[pyclass(eq, frozen, hash, ord)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub enum GameType { - tes4, - tes5, - fo3, - fonv, - fo4, - tes5se, - fo4vr, - tes5vr, - tes3, - starfield, - openmw, + Oblivion, + Skyrim, + Fallout3, + FalloutNV, + Fallout4, + SkyrimSE, + Fallout4VR, + SkyrimVR, + Morrowind, + Starfield, + OpenMW, } impl TryFrom for GameType { @@ -27,17 +26,17 @@ impl TryFrom for GameType { fn try_from(value: libloot::GameType) -> Result { match value { - libloot::GameType::TES4 => Ok(GameType::tes4), - libloot::GameType::TES5 => Ok(GameType::tes5), - libloot::GameType::FO3 => Ok(GameType::fo3), - libloot::GameType::FONV => Ok(GameType::fonv), - libloot::GameType::FO4 => Ok(GameType::fo4), - libloot::GameType::TES5SE => Ok(GameType::tes5se), - libloot::GameType::FO4VR => Ok(GameType::fo4vr), - libloot::GameType::TES5VR => Ok(GameType::tes5vr), - libloot::GameType::TES3 => Ok(GameType::tes3), - libloot::GameType::Starfield => Ok(GameType::starfield), - libloot::GameType::OpenMW => Ok(GameType::openmw), + libloot::GameType::TES4 => Ok(GameType::Oblivion), + libloot::GameType::TES5 => Ok(GameType::Skyrim), + libloot::GameType::FO3 => Ok(GameType::Fallout3), + libloot::GameType::FONV => Ok(GameType::FalloutNV), + libloot::GameType::FO4 => Ok(GameType::Fallout4), + libloot::GameType::TES5SE => Ok(GameType::SkyrimSE), + libloot::GameType::FO4VR => Ok(GameType::Fallout4VR), + libloot::GameType::TES5VR => Ok(GameType::SkyrimVR), + libloot::GameType::TES3 => Ok(GameType::Morrowind), + libloot::GameType::Starfield => Ok(GameType::Starfield), + libloot::GameType::OpenMW => Ok(GameType::OpenMW), _ => Err(UnsupportedEnumValueError), } } @@ -48,17 +47,17 @@ impl TryFrom for libloot::GameType { fn try_from(value: GameType) -> Result { match value { - GameType::tes4 => Ok(libloot::GameType::TES4), - GameType::tes5 => Ok(libloot::GameType::TES5), - GameType::fo3 => Ok(libloot::GameType::FO3), - GameType::fonv => Ok(libloot::GameType::FONV), - GameType::fo4 => Ok(libloot::GameType::FO4), - GameType::tes5se => Ok(libloot::GameType::TES5SE), - GameType::fo4vr => Ok(libloot::GameType::FO4VR), - GameType::tes5vr => Ok(libloot::GameType::TES5VR), - GameType::tes3 => Ok(libloot::GameType::TES3), - GameType::starfield => Ok(libloot::GameType::Starfield), - GameType::openmw => Ok(libloot::GameType::OpenMW), + GameType::Oblivion => Ok(libloot::GameType::TES4), + GameType::Skyrim => Ok(libloot::GameType::TES5), + GameType::Fallout3 => Ok(libloot::GameType::FO3), + GameType::FalloutNV => Ok(libloot::GameType::FONV), + GameType::Fallout4 => Ok(libloot::GameType::FO4), + GameType::SkyrimSE => Ok(libloot::GameType::TES5SE), + GameType::Fallout4VR => Ok(libloot::GameType::FO4VR), + GameType::SkyrimVR => Ok(libloot::GameType::TES5VR), + GameType::Morrowind => Ok(libloot::GameType::TES3), + GameType::Starfield => Ok(libloot::GameType::Starfield), + GameType::OpenMW => Ok(libloot::GameType::OpenMW), } } } @@ -71,6 +70,7 @@ pub struct Game(libloot::Game); impl Game { #[new] #[pyo3(signature = (game_type, game_path, local_path = None))] + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] fn new( game_type: GameType, game_path: PathBuf, @@ -94,6 +94,7 @@ impl Game { self.0.additional_data_paths() } + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] fn set_additional_data_paths(&mut self, paths: Vec) -> Result<(), VerboseError> { self.0.set_additional_data_paths(&as_paths(&paths))?; Ok(()) @@ -103,15 +104,18 @@ impl Game { self.0.database().into() } + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] fn is_valid_plugin(&self, plugin_path: PathBuf) -> bool { self.0.is_valid_plugin(&plugin_path) } + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] fn load_plugins(&mut self, plugin_paths: Vec) -> Result<(), VerboseError> { self.0.load_plugins(&as_paths(&plugin_paths))?; Ok(()) } + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] fn load_plugin_headers(&mut self, plugin_paths: Vec) -> Result<(), VerboseError> { self.0.load_plugin_headers(&as_paths(&plugin_paths))?; Ok(()) @@ -133,6 +137,7 @@ impl Game { .collect() } + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] fn sort_plugins(&self, plugin_names: Vec) -> Result, VerboseError> { Ok(self.0.sort_plugins(&as_strs(&plugin_names))?) } @@ -158,6 +163,7 @@ impl Game { self.0.load_order() } + #[expect(clippy::needless_pass_by_value, reason = "Required by PyO3")] fn set_load_order(&mut self, load_order: Vec) -> Result<(), VerboseError> { self.0.set_load_order(&as_strs(&load_order))?; Ok(()) diff --git a/pyo3/src/lib.rs b/pyo3/src/lib.rs index ab6ef8a6..27c25a50 100644 --- a/pyo3/src/lib.rs +++ b/pyo3/src/lib.rs @@ -1,3 +1,99 @@ +// Deny some rustc lints that are allow-by-default. +#![deny( + ambiguous_negative_literals, + impl_trait_overcaptures, + let_underscore_drop, + missing_copy_implementations, + missing_debug_implementations, + non_ascii_idents, + redundant_imports, + redundant_lifetimes, + trivial_casts, + trivial_numeric_casts, + unit_bindings, + unreachable_pub, + unsafe_code +)] +#![deny(clippy::pedantic)] +// Selectively deny clippy restriction lints. +#![deny( + clippy::allow_attributes, + clippy::as_conversions, + clippy::as_underscore, + clippy::assertions_on_result_states, + clippy::big_endian_bytes, + clippy::cfg_not_test, + clippy::clone_on_ref_ptr, + clippy::create_dir, + clippy::dbg_macro, + clippy::decimal_literal_representation, + clippy::default_numeric_fallback, + clippy::doc_include_without_cfg, + clippy::empty_drop, + clippy::error_impl_error, + clippy::exit, + clippy::exhaustive_enums, + clippy::expect_used, + clippy::filetype_is_file, + clippy::float_cmp_const, + clippy::fn_to_numeric_cast_any, + clippy::get_unwrap, + clippy::host_endian_bytes, + clippy::if_then_some_else_none, + clippy::indexing_slicing, + clippy::infinite_loop, + clippy::integer_division, + clippy::integer_division_remainder_used, + clippy::iter_over_hash_type, + clippy::let_underscore_must_use, + clippy::lossy_float_literal, + clippy::map_err_ignore, + clippy::map_with_unused_argument_over_ranges, + clippy::mem_forget, + clippy::missing_assert_message, + clippy::missing_asserts_for_indexing, + clippy::missing_asserts_for_indexing, + clippy::mixed_read_write_in_expression, + clippy::multiple_inherent_impl, + clippy::multiple_unsafe_ops_per_block, + clippy::mutex_atomic, + clippy::mutex_integer, + clippy::needless_raw_strings, + clippy::non_ascii_literal, + clippy::non_zero_suggestions, + clippy::panic, + clippy::panic_in_result_fn, + clippy::partial_pub_fields, + clippy::pathbuf_init_then_push, + clippy::precedence_bits, + clippy::print_stderr, + clippy::print_stdout, + clippy::rc_buffer, + clippy::rc_mutex, + clippy::redundant_type_annotations, + clippy::ref_patterns, + clippy::rest_pat_in_fully_bound_structs, + clippy::str_to_string, + clippy::string_lit_chars_any, + clippy::string_slice, + clippy::string_to_string, + clippy::suspicious_xor_used_as_pow, + clippy::tests_outside_test_module, + clippy::todo, + clippy::try_err, + clippy::undocumented_unsafe_blocks, + clippy::unimplemented, + clippy::unnecessary_safety_comment, + clippy::unneeded_field_pattern, + clippy::unreachable, + clippy::unused_result_ok, + clippy::unwrap_in_result, + clippy::unwrap_used, + clippy::use_debug, + clippy::verbose_file_reads, + clippy::wildcard_enum_match_arm +)] + mod database; mod error; mod game; diff --git a/pyo3/src/metadata.rs b/pyo3/src/metadata.rs index 5a55b9c2..c5b2539e 100644 --- a/pyo3/src/metadata.rs +++ b/pyo3/src/metadata.rs @@ -8,7 +8,7 @@ use pyo3::{ use crate::error::VerboseError; -pub const NONE_REPR: &str = "None"; +pub(crate) const NONE_REPR: &str = "None"; #[pyclass(eq, ord, str = "{0:?}")] #[repr(transparent)] @@ -171,6 +171,10 @@ fn repr_message_contents(contents: &[libloot::metadata::MessageContent]) -> Stri .join(",") } +#[expect( + unreachable_pub, + reason = "It's exported by PyO3, so while the pub isn't necessary, it's misleading to not have it" +)] #[pyfunction] pub fn select_message_content( content: Vec, @@ -745,8 +749,8 @@ pub struct PluginMetadata(libloot::metadata::PluginMetadata); #[pymethods] impl PluginMetadata { #[new] - fn new(name: String) -> Result { - Ok(Self(libloot::metadata::PluginMetadata::new(&name)?)) + fn new(name: &str) -> Result { + Ok(Self(libloot::metadata::PluginMetadata::new(name)?)) } #[getter] @@ -880,7 +884,7 @@ impl PluginMetadata { self.0.set_locations(value); } - fn merge_metadata(&mut self, other: PluginMetadata) { + fn merge_metadata(&mut self, other: &PluginMetadata) { self.0.merge_metadata(&other.0); } From e0d890ab1a8f7ce2077d2f1f08ee38ad3129a99b Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 22:57:24 +0100 Subject: [PATCH 101/206] Deny various rustc and clippy lints in Node.js wrapper --- nodejs/src/database.rs | 34 +++++++-------- nodejs/src/game.rs | 67 ++++++++++++++-------------- nodejs/src/lib.rs | 99 +++++++++++++++++++++++++++++++++++++++++- nodejs/src/metadata.rs | 19 ++++---- 4 files changed, 159 insertions(+), 60 deletions(-) diff --git a/nodejs/src/database.rs b/nodejs/src/database.rs index 43355b10..86854e8d 100644 --- a/nodejs/src/database.rs +++ b/nodejs/src/database.rs @@ -22,7 +22,7 @@ impl Database { pub fn load_masterlist(&self, path: String) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .load_masterlist(Path::new(&path)) .map_err(Into::into) } @@ -35,7 +35,7 @@ impl Database { ) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .load_masterlist_with_prelude(Path::new(&masterlist_path), Path::new(&prelude_path)) .map_err(Into::into) } @@ -44,7 +44,7 @@ impl Database { pub fn load_userlist(&self, path: String) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .load_userlist(Path::new(&path)) .map_err(Into::into) } @@ -63,7 +63,7 @@ impl Database { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .write_user_metadata(Path::new(&output_path), write_mode) .map_err(Into::into) } @@ -82,7 +82,7 @@ impl Database { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .write_minimal_list(Path::new(&output_path), write_mode) .map_err(Into::into) } @@ -91,7 +91,7 @@ impl Database { pub fn evaluate(&self, condition: String) -> Result { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .evaluate(&condition) .map_err(Into::into) } @@ -101,7 +101,7 @@ impl Database { Ok(self .0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .known_bash_tags()) } @@ -112,7 +112,7 @@ impl Database { ) -> Result, VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .general_messages(evaluate_conditions) .map(|v| v.into_iter().map(Into::into).collect()) .map_err(Into::into) @@ -123,7 +123,7 @@ impl Database { Ok(self .0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .groups(include_user_metadata) .into_iter() .map(Into::into) @@ -135,7 +135,7 @@ impl Database { Ok(self .0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .user_groups() .iter() .cloned() @@ -148,7 +148,7 @@ impl Database { let groups = groups.into_iter().cloned().map(Into::into).collect(); self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .set_user_groups(groups); Ok(()) } @@ -161,7 +161,7 @@ impl Database { ) -> Result, VerboseError> { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .groups_path(&from_group_name, &to_group_name) .map(|v| v.into_iter().map(Into::into).collect()) .map_err(Into::into) @@ -176,7 +176,7 @@ impl Database { ) -> Result, VerboseError> { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .plugin_metadata(&plugin_name, include_user_metadata, evaluate_conditions) .map(|p| p.map(Into::into)) .map_err(Into::into) @@ -190,7 +190,7 @@ impl Database { ) -> Result, VerboseError> { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .plugin_user_metadata(&plugin_name, evaluate_conditions) .map(|p| p.map(Into::into)) .map_err(Into::into) @@ -203,7 +203,7 @@ impl Database { ) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .set_plugin_user_metadata(plugin_metadata.clone().into()); Ok(()) } @@ -212,7 +212,7 @@ impl Database { pub fn discard_plugin_user_metadata(&self, plugin: String) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .discard_plugin_user_metadata(&plugin); Ok(()) } @@ -221,7 +221,7 @@ impl Database { pub fn discard_all_user_metadata(&self) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .discard_all_user_metadata(); Ok(()) } diff --git a/nodejs/src/game.rs b/nodejs/src/game.rs index 7d15891b..24e185dc 100644 --- a/nodejs/src/game.rs +++ b/nodejs/src/game.rs @@ -5,21 +5,20 @@ use napi_derive::napi; use crate::{database::Database, error::VerboseError, plugin::Plugin}; -#[allow(non_camel_case_types)] #[napi] #[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub enum GameType { - tes4, - tes5, - fo3, - fonv, - fo4, - tes5se, - fo4vr, - tes5vr, - tes3, - starfield, - openmw, + Oblivion, + Skyrim, + Fallout3, + FalloutNV, + Fallout4, + SkyrimSE, + Fallout4VR, + SkyrimVR, + Morrowind, + Starfield, + OpenMW, } impl TryFrom for GameType { @@ -27,17 +26,17 @@ impl TryFrom for GameType { fn try_from(value: libloot::GameType) -> Result { match value { - libloot::GameType::TES4 => Ok(GameType::tes4), - libloot::GameType::TES5 => Ok(GameType::tes5), - libloot::GameType::FO3 => Ok(GameType::fo3), - libloot::GameType::FONV => Ok(GameType::fonv), - libloot::GameType::FO4 => Ok(GameType::fo4), - libloot::GameType::TES5SE => Ok(GameType::tes5se), - libloot::GameType::FO4VR => Ok(GameType::fo4vr), - libloot::GameType::TES5VR => Ok(GameType::tes5vr), - libloot::GameType::TES3 => Ok(GameType::tes3), - libloot::GameType::Starfield => Ok(GameType::starfield), - libloot::GameType::OpenMW => Ok(GameType::openmw), + libloot::GameType::TES4 => Ok(GameType::Oblivion), + libloot::GameType::TES5 => Ok(GameType::Skyrim), + libloot::GameType::FO3 => Ok(GameType::Fallout3), + libloot::GameType::FONV => Ok(GameType::FalloutNV), + libloot::GameType::FO4 => Ok(GameType::Fallout4), + libloot::GameType::TES5SE => Ok(GameType::SkyrimSE), + libloot::GameType::FO4VR => Ok(GameType::Fallout4VR), + libloot::GameType::TES5VR => Ok(GameType::SkyrimVR), + libloot::GameType::TES3 => Ok(GameType::Morrowind), + libloot::GameType::Starfield => Ok(GameType::Starfield), + libloot::GameType::OpenMW => Ok(GameType::OpenMW), _ => Err(UnsupportedEnumValueError), } } @@ -46,17 +45,17 @@ impl TryFrom for GameType { impl From for libloot::GameType { fn from(value: GameType) -> Self { match value { - GameType::tes4 => libloot::GameType::TES4, - GameType::tes5 => libloot::GameType::TES5, - GameType::fo3 => libloot::GameType::FO3, - GameType::fonv => libloot::GameType::FONV, - GameType::fo4 => libloot::GameType::FO4, - GameType::tes5se => libloot::GameType::TES5SE, - GameType::fo4vr => libloot::GameType::FO4VR, - GameType::tes5vr => libloot::GameType::TES5VR, - GameType::tes3 => libloot::GameType::TES3, - GameType::starfield => libloot::GameType::Starfield, - GameType::openmw => libloot::GameType::OpenMW, + GameType::Oblivion => libloot::GameType::TES4, + GameType::Skyrim => libloot::GameType::TES5, + GameType::Fallout3 => libloot::GameType::FO3, + GameType::FalloutNV => libloot::GameType::FONV, + GameType::Fallout4 => libloot::GameType::FO4, + GameType::SkyrimSE => libloot::GameType::TES5SE, + GameType::Fallout4VR => libloot::GameType::FO4VR, + GameType::SkyrimVR => libloot::GameType::TES5VR, + GameType::Morrowind => libloot::GameType::TES3, + GameType::Starfield => libloot::GameType::Starfield, + GameType::OpenMW => libloot::GameType::OpenMW, } } } diff --git a/nodejs/src/lib.rs b/nodejs/src/lib.rs index 42fdb5fb..1bdc7f41 100644 --- a/nodejs/src/lib.rs +++ b/nodejs/src/lib.rs @@ -1,4 +1,100 @@ -#![deny(clippy::all)] +// Deny some rustc lints that are allow-by-default. +#![deny( + ambiguous_negative_literals, + impl_trait_overcaptures, + let_underscore_drop, + missing_copy_implementations, + missing_debug_implementations, + non_ascii_idents, + redundant_imports, + redundant_lifetimes, + trivial_casts, + trivial_numeric_casts, + unit_bindings, + unreachable_pub, + unsafe_code +)] +#![deny(clippy::pedantic)] +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::must_use_candidate)] +#![allow(clippy::needless_pass_by_value)] +// Selectively deny clippy restriction lints. +#![deny( + clippy::allow_attributes, + clippy::as_conversions, + clippy::as_underscore, + clippy::assertions_on_result_states, + clippy::big_endian_bytes, + clippy::clone_on_ref_ptr, + clippy::create_dir, + clippy::dbg_macro, + clippy::decimal_literal_representation, + clippy::default_numeric_fallback, + clippy::doc_include_without_cfg, + clippy::empty_drop, + clippy::error_impl_error, + clippy::exit, + // clippy::exhaustive_enums, + clippy::expect_used, + clippy::filetype_is_file, + clippy::float_cmp_const, + clippy::fn_to_numeric_cast_any, + clippy::get_unwrap, + clippy::host_endian_bytes, + clippy::if_then_some_else_none, + clippy::indexing_slicing, + clippy::infinite_loop, + clippy::integer_division, + clippy::integer_division_remainder_used, + clippy::iter_over_hash_type, + clippy::let_underscore_must_use, + clippy::lossy_float_literal, + clippy::map_err_ignore, + clippy::map_with_unused_argument_over_ranges, + clippy::mem_forget, + clippy::missing_assert_message, + clippy::missing_asserts_for_indexing, + clippy::missing_asserts_for_indexing, + clippy::mixed_read_write_in_expression, + clippy::multiple_inherent_impl, + clippy::multiple_unsafe_ops_per_block, + clippy::mutex_atomic, + clippy::mutex_integer, + clippy::needless_raw_strings, + clippy::non_ascii_literal, + clippy::non_zero_suggestions, + clippy::panic, + clippy::panic_in_result_fn, + clippy::partial_pub_fields, + clippy::pathbuf_init_then_push, + clippy::precedence_bits, + clippy::print_stderr, + clippy::print_stdout, + clippy::rc_buffer, + clippy::rc_mutex, + clippy::redundant_type_annotations, + clippy::ref_patterns, + clippy::rest_pat_in_fully_bound_structs, + clippy::str_to_string, + clippy::string_lit_chars_any, + clippy::string_slice, + clippy::string_to_string, + clippy::suspicious_xor_used_as_pow, + clippy::tests_outside_test_module, + clippy::todo, + clippy::try_err, + clippy::undocumented_unsafe_blocks, + clippy::unimplemented, + clippy::unnecessary_safety_comment, + clippy::unneeded_field_pattern, + clippy::unreachable, + clippy::unused_result_ok, + clippy::unwrap_in_result, + clippy::unwrap_used, + clippy::use_debug, + clippy::verbose_file_reads, + clippy::wildcard_enum_match_arm +)] mod database; mod error; @@ -39,6 +135,7 @@ pub const LIBLOOT_VERSION_MINOR: u32 = libloot::LIBLOOT_VERSION_MINOR; pub const LIBLOOT_VERSION_PATCH: u32 = libloot::LIBLOOT_VERSION_PATCH; #[napi] +#[derive(Debug)] pub enum LogLevel { Trace, Debug, diff --git a/nodejs/src/metadata.rs b/nodejs/src/metadata.rs index 2dd46df8..8ec7f881 100644 --- a/nodejs/src/metadata.rs +++ b/nodejs/src/metadata.rs @@ -4,7 +4,7 @@ use napi_derive::napi; use crate::error::VerboseError; #[napi] -#[derive(Clone)] +#[derive(Clone, Debug)] #[repr(transparent)] pub struct Group(libloot::metadata::Group); @@ -73,7 +73,7 @@ impl From for libloot::metadata::Group { } #[napi] -#[derive(Clone)] +#[derive(Clone, Debug)] #[repr(transparent)] pub struct MessageContent(libloot::metadata::MessageContent); @@ -135,6 +135,7 @@ pub fn select_message_content( } #[napi] +#[derive(Debug)] pub enum MessageType { Say, Warn, @@ -162,7 +163,7 @@ impl From for libloot::metadata::MessageType { } #[napi] -#[derive(Clone)] +#[derive(Clone, Debug)] #[repr(transparent)] pub struct Message(libloot::metadata::Message); @@ -223,7 +224,7 @@ impl From for libloot::metadata::Message { } #[napi] -#[derive(Clone)] +#[derive(Clone, Debug)] #[repr(transparent)] pub struct File(libloot::metadata::File); @@ -320,6 +321,7 @@ impl From for libloot::metadata::File { } #[napi] +#[derive(Debug)] #[repr(transparent)] pub struct Filename(libloot::metadata::Filename); @@ -343,7 +345,7 @@ impl From for Filename { } #[napi] -#[derive(Clone)] +#[derive(Clone, Debug)] #[repr(transparent)] pub struct PluginCleaningData(libloot::metadata::PluginCleaningData); @@ -446,6 +448,7 @@ impl From for libloot::metadata::PluginCleaningData { } #[napi] +#[derive(Debug)] pub enum TagSuggestion { Addition, Removal, @@ -461,7 +464,7 @@ impl From for libloot::metadata::TagSuggestion { } #[napi] -#[derive(Clone)] +#[derive(Clone, Debug)] #[repr(transparent)] pub struct Tag(libloot::metadata::Tag); @@ -512,7 +515,7 @@ impl From for libloot::metadata::Tag { } #[napi] -#[derive(Clone)] +#[derive(Clone, Debug)] #[repr(transparent)] pub struct Location(libloot::metadata::Location); @@ -558,7 +561,7 @@ impl From for libloot::metadata::Location { } #[napi] -#[derive(Clone)] +#[derive(Clone, Debug)] #[repr(transparent)] pub struct PluginMetadata(libloot::metadata::PluginMetadata); From 3926e7579203f875e02a3ac5549d05c3d7dabf32 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 23:22:38 +0100 Subject: [PATCH 102/206] Deny various rustc and clippy lints in ffi-errors crate --- cxx/src/error.rs | 1 + ffi-errors/src/esplugin.rs | 15 ++- ffi-errors/src/lci.rs | 10 +- ffi-errors/src/lib.rs | 166 ++++++++++++++++++++++++++++----- ffi-errors/src/libloadorder.rs | 68 ++++++++------ 5 files changed, 199 insertions(+), 61 deletions(-) diff --git a/cxx/src/error.rs b/cxx/src/error.rs index c735125c..c9ab5196 100644 --- a/cxx/src/error.rs +++ b/cxx/src/error.rs @@ -47,6 +47,7 @@ impl std::fmt::Display for VerboseError { SystemErrorCategory::Esplugin => "EspluginError", SystemErrorCategory::Libloadorder => "LibloadorderError", SystemErrorCategory::LootConditionInterpreter => "LciError", + _ => "UnknownCategoryError", }; write!(f, "{}: {}: {}", prefix, e.code(), e.message()) } diff --git a/ffi-errors/src/esplugin.rs b/ffi-errors/src/esplugin.rs index 71ccba5c..6df1704e 100644 --- a/ffi-errors/src/esplugin.rs +++ b/ffi-errors/src/esplugin.rs @@ -44,6 +44,13 @@ pub static ESP_ERROR_UNRESOLVED_RECORD_IDS: c_int = 13; #[unsafe(no_mangle)] pub static ESP_ERROR_PLUGIN_METADATA_NOT_FOUND: c_int = 14; +#[unsafe(no_mangle)] +pub static ESP_ERROR_UNKNOWN: c_int = c_int::MAX; + +#[expect( + clippy::wildcard_enum_match_arm, + reason = "It doesn't matter if other I/O error kinds are added in the future" +)] fn map_io_error(err: &std::io::Error) -> c_int { match err.kind() { std::io::ErrorKind::NotFound => ESP_ERROR_FILE_NOT_FOUND, @@ -52,12 +59,12 @@ fn map_io_error(err: &std::io::Error) -> c_int { } } +#[must_use] pub fn map_error(err: &Error) -> c_int { - match *err { - Error::IoError(ref x) => map_io_error(x), + match err { + Error::IoError(x) => map_io_error(x), Error::NoFilename(_) => ESP_ERROR_NO_FILENAME, - Error::ParsingIncomplete(_) => ESP_ERROR_PARSE_ERROR, - Error::ParsingError(_, _) => ESP_ERROR_PARSE_ERROR, + Error::ParsingIncomplete(_) | Error::ParsingError(_, _) => ESP_ERROR_PARSE_ERROR, Error::DecodeError(_) => ESP_ERROR_TEXT_DECODE_ERROR, Error::UnresolvedRecordIds(_) => ESP_ERROR_UNRESOLVED_RECORD_IDS, Error::PluginMetadataNotFound(_) => ESP_ERROR_PLUGIN_METADATA_NOT_FOUND, diff --git a/ffi-errors/src/lci.rs b/ffi-errors/src/lci.rs index d2475d50..4ffae62f 100644 --- a/ffi-errors/src/lci.rs +++ b/ffi-errors/src/lci.rs @@ -33,11 +33,15 @@ pub static LCI_ERROR_TEXT_ENCODE_FAIL: c_int = -7; #[unsafe(no_mangle)] pub static LCI_ERROR_INTERNAL_LOGIC_ERROR: c_int = -8; +#[unsafe(no_mangle)] +pub static LCI_ERROR_UNKNOWN: c_int = c_int::MAX; + +#[must_use] pub fn map_error(err: &Error) -> c_int { match err { - Error::ParsingIncomplete(_) => LCI_ERROR_PARSING_ERROR, - Error::UnconsumedInput(_) => LCI_ERROR_PARSING_ERROR, - Error::ParsingError(_, _) => LCI_ERROR_PARSING_ERROR, + Error::ParsingIncomplete(_) | Error::UnconsumedInput(_) | Error::ParsingError(_, _) => { + LCI_ERROR_PARSING_ERROR + } Error::PeParsingError(_, _) => LCI_ERROR_PE_PARSING_ERROR, Error::IoError(_, _) => LCI_ERROR_IO_ERROR, _ => LCI_ERROR_INTERNAL_LOGIC_ERROR, diff --git a/ffi-errors/src/lib.rs b/ffi-errors/src/lib.rs index d2c2d750..b3188028 100644 --- a/ffi-errors/src/lib.rs +++ b/ffi-errors/src/lib.rs @@ -1,8 +1,106 @@ +// Deny some rustc lints that are allow-by-default. +#![deny( + ambiguous_negative_literals, + impl_trait_overcaptures, + let_underscore_drop, + missing_copy_implementations, + missing_debug_implementations, + non_ascii_idents, + redundant_imports, + redundant_lifetimes, + trivial_casts, + trivial_numeric_casts, + unit_bindings, + unreachable_pub +)] +#![deny(clippy::pedantic)] +#![allow(clippy::missing_errors_doc)] +// Selectively deny clippy restriction lints. +#![deny( + clippy::allow_attributes, + clippy::as_conversions, + clippy::as_underscore, + clippy::assertions_on_result_states, + clippy::big_endian_bytes, + clippy::cfg_not_test, + clippy::clone_on_ref_ptr, + clippy::create_dir, + clippy::dbg_macro, + clippy::decimal_literal_representation, + clippy::default_numeric_fallback, + clippy::doc_include_without_cfg, + clippy::empty_drop, + clippy::error_impl_error, + clippy::exit, + clippy::exhaustive_enums, + clippy::expect_used, + clippy::filetype_is_file, + clippy::float_cmp_const, + clippy::fn_to_numeric_cast_any, + clippy::get_unwrap, + clippy::host_endian_bytes, + clippy::if_then_some_else_none, + clippy::indexing_slicing, + clippy::infinite_loop, + clippy::integer_division, + clippy::integer_division_remainder_used, + clippy::iter_over_hash_type, + clippy::let_underscore_must_use, + clippy::lossy_float_literal, + clippy::map_err_ignore, + clippy::map_with_unused_argument_over_ranges, + clippy::mem_forget, + clippy::missing_assert_message, + clippy::missing_asserts_for_indexing, + clippy::missing_asserts_for_indexing, + clippy::mixed_read_write_in_expression, + clippy::multiple_inherent_impl, + clippy::multiple_unsafe_ops_per_block, + clippy::mutex_atomic, + clippy::mutex_integer, + clippy::needless_raw_strings, + clippy::non_ascii_literal, + clippy::non_zero_suggestions, + clippy::panic, + clippy::panic_in_result_fn, + clippy::partial_pub_fields, + clippy::pathbuf_init_then_push, + clippy::precedence_bits, + clippy::print_stderr, + clippy::print_stdout, + clippy::rc_buffer, + clippy::rc_mutex, + clippy::redundant_type_annotations, + clippy::ref_patterns, + clippy::rest_pat_in_fully_bound_structs, + clippy::str_to_string, + clippy::string_lit_chars_any, + clippy::string_slice, + clippy::string_to_string, + clippy::suspicious_xor_used_as_pow, + clippy::tests_outside_test_module, + clippy::todo, + clippy::try_err, + clippy::undocumented_unsafe_blocks, + clippy::unimplemented, + clippy::unnecessary_safety_comment, + clippy::unneeded_field_pattern, + clippy::unreachable, + clippy::unused_result_ok, + clippy::unwrap_in_result, + clippy::unwrap_used, + clippy::use_debug, + clippy::verbose_file_reads, + clippy::wildcard_enum_match_arm +)] use std::{ error::Error, ffi::{c_int, c_uchar}, }; +use esplugin::ESP_ERROR_UNKNOWN; +use lci::LCI_ERROR_UNKNOWN; +use libloadorder::LIBLO_ERROR_UNKNOWN; use libloot::error::{ConditionEvaluationError, LoadOrderError, PluginDataError}; pub mod esplugin; @@ -23,6 +121,7 @@ pub static LIBLOOT_SYSTEM_ERROR_CATEGORY_LCI: c_uchar = 3; #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] #[repr(u8)] +#[non_exhaustive] pub enum SystemErrorCategory { Esplugin = LIBLOOT_SYSTEM_ERROR_CATEGORY_ESPLUGIN, Libloadorder = LIBLOOT_SYSTEM_ERROR_CATEGORY_LIBLOADORDER, @@ -49,14 +148,17 @@ pub struct SystemError { } impl SystemError { + #[must_use] pub fn code(&self) -> c_int { self.code } + #[must_use] pub fn category(&self) -> SystemErrorCategory { self.category } + #[must_use] pub fn message(&self) -> &str { &self.message } @@ -76,48 +178,66 @@ impl std::error::Error for SystemError {} impl From for SystemError { fn from(value: PluginDataError) -> Self { - let error = value + let (code, message) = if let Some(error) = value .source() - .expect("LoadOrderError has source") - .downcast_ref::<::esplugin::Error>() - .expect("LoadOrderError source is an esplugin::Error"); - let error_code = crate::esplugin::map_error(error); + .and_then(|s| s.downcast_ref::<::esplugin::Error>()) + { + let error_code = crate::esplugin::map_error(error); + (error_code, error.to_string()) + } else { + ( + ESP_ERROR_UNKNOWN, + "Could not retrieve esplugin error message".to_owned(), + ) + }; SystemError { - code: error_code, + code, category: SystemErrorCategory::Esplugin, - message: error.to_string(), + message, } } } impl From for SystemError { fn from(value: LoadOrderError) -> Self { - let error = value + let (code, message) = if let Some(error) = value .source() - .expect("LoadOrderError has source") - .downcast_ref::() - .expect("LoadOrderError source is a loadorder::Error"); - let error_code = crate::libloadorder::map_error(error); + .and_then(|s| s.downcast_ref::<::loadorder::Error>()) + { + let error_code = crate::libloadorder::map_error(error); + (error_code, error.to_string()) + } else { + ( + LIBLO_ERROR_UNKNOWN, + "Could not retrieve libloadorder error message".to_owned(), + ) + }; SystemError { - code: error_code, + code, category: SystemErrorCategory::Libloadorder, - message: error.to_string(), + message, } } } impl From for SystemError { fn from(value: ConditionEvaluationError) -> Self { - let error = value + let (code, message) = if let Some(error) = value .source() - .expect("ConditionEvaluationError has source") - .downcast_ref::() - .expect("ConditionEvaluationError source is a loot_condition_interpreter::Error"); - let error_code = crate::lci::map_error(error); + .and_then(|s| s.downcast_ref::<::loot_condition_interpreter::Error>()) + { + let error_code = crate::lci::map_error(error); + (error_code, error.to_string()) + } else { + ( + LCI_ERROR_UNKNOWN, + "Could not retrieve loot-condition-interpreter error message".to_owned(), + ) + }; SystemError { - code: error_code, + code, category: SystemErrorCategory::LootConditionInterpreter, - message: error.to_string(), + message, } } } @@ -137,9 +257,9 @@ pub fn fmt_error_chain( mut error: &dyn std::error::Error, f: &mut std::fmt::Formatter<'_>, ) -> std::fmt::Result { - write!(f, "{}", error)?; + write!(f, "{error}")?; while let Some(source) = error.source() { - write!(f, ": {}", source)?; + write!(f, ": {source}")?; error = source; } Ok(()) diff --git a/ffi-errors/src/libloadorder.rs b/ffi-errors/src/libloadorder.rs index dcd23a09..26b84c78 100644 --- a/ffi-errors/src/libloadorder.rs +++ b/ffi-errors/src/libloadorder.rs @@ -1,5 +1,5 @@ use loadorder::Error; -use std::ffi::c_int; +use std::{ffi::c_int, io::ErrorKind}; /// There is a mismatch between the files used to keep track of load order. /// @@ -70,49 +70,55 @@ pub static LIBLO_ERROR_SYSTEM_ERROR: c_int = 22; #[unsafe(no_mangle)] pub static LIBLO_ERROR_NO_PATH: c_int = 23; -/// Matches the value of the highest-numbered return code. +/// Matches the value of the highest-numbered return code, aside from `LIBLO_ERROR_UNKNOWN`. /// /// Provided in case clients wish to incorporate additional return codes in their implementation /// and desire some method of avoiding value conflicts. #[unsafe(no_mangle)] pub static LIBLO_RETURN_MAX: c_int = 23; +#[unsafe(no_mangle)] +pub static LIBLO_ERROR_UNKNOWN: c_int = c_int::MAX; + +#[expect( + clippy::wildcard_enum_match_arm, + reason = "It doesn't matter if other I/O error kinds are added in the future" +)] fn map_io_error(err: &std::io::Error) -> c_int { - use std::io::ErrorKind::*; match err.kind() { - NotFound => LIBLO_ERROR_FILE_NOT_FOUND, - AlreadyExists => LIBLO_ERROR_FILE_RENAME_FAIL, - PermissionDenied => LIBLO_ERROR_IO_PERMISSION_DENIED, + ErrorKind::NotFound => LIBLO_ERROR_FILE_NOT_FOUND, + ErrorKind::AlreadyExists => LIBLO_ERROR_FILE_RENAME_FAIL, + ErrorKind::PermissionDenied => LIBLO_ERROR_IO_PERMISSION_DENIED, _ => LIBLO_ERROR_IO_ERROR, } } +#[must_use] pub fn map_error(err: &Error) -> c_int { - use Error::*; - match *err { - InvalidPath(_) => LIBLO_ERROR_FILE_NOT_FOUND, - IoError(_, ref x) => map_io_error(x), - NoFilename(_) => LIBLO_ERROR_FILE_PARSE_FAIL, - DecodeError(_) => LIBLO_ERROR_TEXT_DECODE_FAIL, - EncodeError(_) => LIBLO_ERROR_TEXT_ENCODE_FAIL, - PluginParsingError(_, _) => LIBLO_ERROR_FILE_PARSE_FAIL, - PluginNotFound(_) => LIBLO_ERROR_INVALID_ARGS, - TooManyActivePlugins { .. } => LIBLO_ERROR_INVALID_ARGS, - DuplicatePlugin(_) => LIBLO_ERROR_INVALID_ARGS, - NonMasterBeforeMaster { .. } => LIBLO_ERROR_INVALID_ARGS, - InvalidEarlyLoadingPluginPosition { .. } => LIBLO_ERROR_INVALID_ARGS, - ImplicitlyActivePlugin(_) => LIBLO_ERROR_INVALID_ARGS, - NoLocalAppData => LIBLO_ERROR_INVALID_ARGS, - NoDocumentsPath => LIBLO_ERROR_INVALID_ARGS, - NoUserConfigPath => LIBLO_ERROR_NO_PATH, - NoUserDataPath => LIBLO_ERROR_NO_PATH, - NoProgramFilesPath => LIBLO_ERROR_NO_PATH, - UnrepresentedHoist { .. } => LIBLO_ERROR_INVALID_ARGS, - InstalledPlugin(_) => LIBLO_ERROR_INVALID_ARGS, - IniParsingError { .. } => LIBLO_ERROR_FILE_PARSE_FAIL, - VdfParsingError(_, _) => LIBLO_ERROR_FILE_PARSE_FAIL, - SystemError(_, _) => LIBLO_ERROR_SYSTEM_ERROR, - InvalidBlueprintPluginPosition { .. } => LIBLO_ERROR_INVALID_ARGS, + match err { + Error::InvalidPath(_) => LIBLO_ERROR_FILE_NOT_FOUND, + Error::IoError(_, x) => map_io_error(x), + Error::NoFilename(_) + | Error::PluginParsingError(_, _) + | Error::IniParsingError { .. } + | Error::VdfParsingError(_, _) => LIBLO_ERROR_FILE_PARSE_FAIL, + Error::DecodeError(_) => LIBLO_ERROR_TEXT_DECODE_FAIL, + Error::EncodeError(_) => LIBLO_ERROR_TEXT_ENCODE_FAIL, + Error::PluginNotFound(_) + | Error::TooManyActivePlugins { .. } + | Error::DuplicatePlugin(_) + | Error::NonMasterBeforeMaster { .. } + | Error::InvalidEarlyLoadingPluginPosition { .. } + | Error::ImplicitlyActivePlugin(_) + | Error::NoLocalAppData + | Error::NoDocumentsPath + | Error::UnrepresentedHoist { .. } + | Error::InstalledPlugin(_) + | Error::InvalidBlueprintPluginPosition { .. } => LIBLO_ERROR_INVALID_ARGS, + Error::NoUserConfigPath | Error::NoUserDataPath | Error::NoProgramFilesPath => { + LIBLO_ERROR_NO_PATH + } + Error::SystemError(_, _) => LIBLO_ERROR_SYSTEM_ERROR, _ => LIBLO_ERROR_INTERNAL_LOGIC_ERROR, } } From bd5eafb5e8dd6970b294f5782d4b20ca7461ddb3 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 22 Apr 2025 23:46:47 +0100 Subject: [PATCH 103/206] Deny various rustc and clippy lints in CXX wrapper --- cxx/src/database.rs | 34 +++++----- cxx/src/error.rs | 30 +++++---- cxx/src/game.rs | 10 ++- cxx/src/lib.rs | 147 +++++++++++++++++++++++++++++++++++++++----- cxx/src/metadata.rs | 4 +- cxx/src/plugin.rs | 4 +- 6 files changed, 179 insertions(+), 50 deletions(-) diff --git a/cxx/src/database.rs b/cxx/src/database.rs index 3b5e5eb9..3b17f2b7 100644 --- a/cxx/src/database.rs +++ b/cxx/src/database.rs @@ -25,7 +25,7 @@ impl Database { pub fn load_masterlist(&self, path: &str) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .load_masterlist(Path::new(path)) .map_err(Into::into) } @@ -37,7 +37,7 @@ impl Database { ) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .load_masterlist_with_prelude(Path::new(masterlist_path), Path::new(prelude_path)) .map_err(Into::into) } @@ -45,7 +45,7 @@ impl Database { pub fn load_userlist(&self, path: &str) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .load_userlist(Path::new(path)) .map_err(Into::into) } @@ -63,7 +63,7 @@ impl Database { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .write_user_metadata(Path::new(output_path), write_mode) .map_err(Into::into) } @@ -81,7 +81,7 @@ impl Database { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .write_minimal_list(Path::new(output_path), write_mode) .map_err(Into::into) } @@ -89,7 +89,7 @@ impl Database { pub fn evaluate(&self, condition: &str) -> Result { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .evaluate(condition) .map_err(Into::into) } @@ -98,7 +98,7 @@ impl Database { Ok(self .0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .known_bash_tags()) } @@ -108,7 +108,7 @@ impl Database { ) -> Result, VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .general_messages(evaluate_conditions) .map(|v| v.into_iter().map(Into::into).collect()) .map_err(Into::into) @@ -118,7 +118,7 @@ impl Database { Ok(self .0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .groups(include_user_metadata) .into_iter() .map(Into::into) @@ -130,7 +130,7 @@ impl Database { Ok(self .0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .user_groups() .iter() .cloned() @@ -143,7 +143,7 @@ impl Database { let groups = to_vec_of_unwrapped(groups); self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .set_user_groups(groups); Ok(()) } @@ -155,7 +155,7 @@ impl Database { ) -> Result, VerboseError> { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .groups_path(from_group_name, to_group_name) .map(|v| v.into_iter().map(Into::into).collect()) .map_err(Into::into) @@ -169,7 +169,7 @@ impl Database { ) -> Result, VerboseError> { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .plugin_metadata(plugin_name, include_user_metadata, evaluate_conditions) .map(|p| Box::new(p.map(Into::into).into())) .map_err(Into::into) @@ -182,7 +182,7 @@ impl Database { ) -> Result, VerboseError> { self.0 .read() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .plugin_user_metadata(plugin_name, evaluate_conditions) .map(|p| Box::new(p.map(Into::into).into())) .map_err(Into::into) @@ -194,7 +194,7 @@ impl Database { ) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .set_plugin_user_metadata(plugin_metadata.into()); Ok(()) } @@ -202,7 +202,7 @@ impl Database { pub fn discard_plugin_user_metadata(&self, plugin: &str) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .discard_plugin_user_metadata(plugin); Ok(()) } @@ -210,7 +210,7 @@ impl Database { pub fn discard_all_user_metadata(&self) -> Result<(), VerboseError> { self.0 .write() - .map_err(|_| DatabaseLockPoisonError)? + .map_err(DatabaseLockPoisonError::from)? .discard_all_user_metadata(); Ok(()) } diff --git a/cxx/src/error.rs b/cxx/src/error.rs index c9ab5196..c2fba11f 100644 --- a/cxx/src/error.rs +++ b/cxx/src/error.rs @@ -31,16 +31,16 @@ impl std::fmt::Display for VerboseError { Self::CyclicInteractionError(cycle) => { write!(f, "CyclicInteractionError: ")?; for vertex in cycle { - let name = vertex.name().replace("\\", "\\\\").replace("-", "\\-"); + let name = vertex.name().replace('\\', "\\\\").replace('-', "\\-"); match vertex.out_edge_type() { - Some(e) => write!(f, "{}--{}--", name, e)?, - None => write!(f, "{}", name)?, + Some(e) => write!(f, "{name}--{e}--")?, + None => write!(f, "{name}")?, } } Ok(()) } Self::UndefinedGroupError(group) => { - write!(f, "UndefinedGroupError: {}", group) + write!(f, "UndefinedGroupError: {group}",) } Self::SystemError(e) => { let prefix = match e.category() { @@ -51,8 +51,8 @@ impl std::fmt::Display for VerboseError { }; write!(f, "{}: {}: {}", prefix, e.code(), e.message()) } - Self::FileAccessError(s) => write!(f, "FileAccessError: {}", s), - Self::InvalidArgument(s) => write!(f, "InvalidArgument: {}", s), + Self::FileAccessError(s) => write!(f, "FileAccessError: {s}"), + Self::InvalidArgument(s) => write!(f, "InvalidArgument: {s}"), Self::Other(e) => fmt_error_chain(e.as_ref(), f), } } @@ -79,7 +79,9 @@ impl From for VerboseError { match value { LoadPluginsError::PluginDataError(e) => e.into(), LoadPluginsError::PluginValidationError(_) => Self::InvalidArgument(value.to_string()), - _ => Self::Other(Box::new(value)), + LoadPluginsError::DatabaseLockPoisoned | LoadPluginsError::IoError(_) | _ => { + Self::Other(Box::new(value)) + } } } } @@ -91,7 +93,11 @@ impl From for VerboseError { SortPluginsError::UndefinedGroup(g) => Self::UndefinedGroupError(g), SortPluginsError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), SortPluginsError::PluginDataError(e) => e.into(), - _ => Self::Other(Box::new(value)), + SortPluginsError::DatabaseLockPoisoned + | SortPluginsError::PluginNotLoaded(_) + | SortPluginsError::CycleFoundInvolving(_) + | SortPluginsError::PathfindingError(_) + | _ => Self::Other(Box::new(value)), } } } @@ -100,7 +106,7 @@ impl From for VerboseError { fn from(value: LoadOrderStateError) -> Self { match value { LoadOrderStateError::LoadOrderError(e) => e.into(), - _ => Self::Other(Box::new(value)), + LoadOrderStateError::DatabaseLockPoisoned | _ => Self::Other(Box::new(value)), } } } @@ -134,7 +140,7 @@ impl From for VerboseError { match value { GroupsPathError::UndefinedGroup(g) => Self::UndefinedGroupError(g), GroupsPathError::CycleFound(cycle) => Self::CyclicInteractionError(cycle), - _ => Self::Other(Box::new(value)), + GroupsPathError::PathfindingError(_) => Self::Other(Box::new(value)), } } } @@ -143,7 +149,7 @@ impl From for VerboseError { fn from(value: MetadataRetrievalError) -> Self { match value { MetadataRetrievalError::ConditionEvaluationError(e) => e.into(), - _ => Self::Other(Box::new(value)), + MetadataRetrievalError::RegexError(_) => Self::Other(Box::new(value)), } } } @@ -154,7 +160,7 @@ impl From for VerboseError { } } -#[derive(Debug)] +#[derive(Clone, Copy, Debug)] pub struct EmptyOptionalError; impl std::fmt::Display for EmptyOptionalError { diff --git a/cxx/src/game.rs b/cxx/src/game.rs index 95c507c3..bf216edf 100644 --- a/cxx/src/game.rs +++ b/cxx/src/game.rs @@ -59,7 +59,7 @@ impl From for Game { } } -#[derive(Debug)] +#[derive(Clone, Copy, Debug)] pub struct NotValidUtf8; impl std::fmt::Display for NotValidUtf8 { @@ -97,7 +97,7 @@ pub fn new_game_with_local_path( fn path_to_string(path: &Path) -> Result { path.to_str() - .map(|s| s.to_owned()) + .map(str::to_owned) .ok_or(NotValidUtf8) .map_err(Into::into) } @@ -177,7 +177,11 @@ impl Game { } pub fn load_order(&self) -> Vec { - self.0.load_order().iter().map(|s| s.to_string()).collect() + self.0 + .load_order() + .iter() + .map(ToString::to_string) + .collect() } pub fn set_load_order(&mut self, load_order: &[&str]) -> Result<(), VerboseError> { diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index eb87bb3d..81a790fa 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -1,3 +1,103 @@ +// Deny some rustc lints that are allow-by-default. +#![deny( + ambiguous_negative_literals, + impl_trait_overcaptures, + let_underscore_drop, + missing_copy_implementations, + missing_debug_implementations, + non_ascii_idents, + redundant_imports, + redundant_lifetimes, + trivial_casts, + trivial_numeric_casts, + unit_bindings +)] +#![deny(clippy::pedantic)] +// Allow a few clippy pedantic lints. +#![allow(clippy::must_use_candidate)] +#![allow(clippy::missing_errors_doc)] +#![allow( + clippy::unnecessary_box_returns, + reason = "CXX requires many returns to be boxed" +)] +// Selectively deny clippy restriction lints. +#![deny( + clippy::as_conversions, + clippy::as_underscore, + clippy::assertions_on_result_states, + clippy::big_endian_bytes, + clippy::cfg_not_test, + clippy::clone_on_ref_ptr, + clippy::create_dir, + clippy::dbg_macro, + clippy::decimal_literal_representation, + clippy::default_numeric_fallback, + clippy::doc_include_without_cfg, + clippy::empty_drop, + clippy::error_impl_error, + clippy::exit, + clippy::exhaustive_enums, + clippy::expect_used, + clippy::filetype_is_file, + clippy::float_cmp_const, + clippy::fn_to_numeric_cast_any, + clippy::get_unwrap, + clippy::host_endian_bytes, + clippy::if_then_some_else_none, + clippy::indexing_slicing, + clippy::infinite_loop, + clippy::integer_division, + clippy::integer_division_remainder_used, + clippy::iter_over_hash_type, + clippy::let_underscore_must_use, + clippy::lossy_float_literal, + clippy::map_err_ignore, + clippy::map_with_unused_argument_over_ranges, + clippy::mem_forget, + clippy::missing_assert_message, + clippy::missing_asserts_for_indexing, + clippy::missing_asserts_for_indexing, + clippy::mixed_read_write_in_expression, + clippy::multiple_inherent_impl, + clippy::multiple_unsafe_ops_per_block, + clippy::mutex_atomic, + clippy::mutex_integer, + clippy::needless_raw_strings, + clippy::non_ascii_literal, + clippy::non_zero_suggestions, + clippy::panic, + clippy::panic_in_result_fn, + clippy::partial_pub_fields, + clippy::pathbuf_init_then_push, + clippy::precedence_bits, + clippy::print_stderr, + clippy::print_stdout, + clippy::rc_buffer, + clippy::rc_mutex, + clippy::redundant_type_annotations, + clippy::ref_patterns, + clippy::rest_pat_in_fully_bound_structs, + clippy::str_to_string, + clippy::string_lit_chars_any, + clippy::string_slice, + clippy::string_to_string, + clippy::suspicious_xor_used_as_pow, + clippy::tests_outside_test_module, + clippy::todo, + clippy::try_err, + clippy::undocumented_unsafe_blocks, + clippy::unimplemented, + clippy::unnecessary_safety_comment, + clippy::unneeded_field_pattern, + clippy::unreachable, + clippy::unused_result_ok, + clippy::unwrap_in_result, + clippy::unwrap_used, + clippy::use_debug, + clippy::verbose_file_reads, + clippy::wildcard_enum_match_arm +)] + mod database; mod error; mod game; @@ -35,11 +135,12 @@ impl OptionalRef { /// # Safety /// - /// This is safe as long as the pointer in the OptionalRef is still valid. + /// This is safe as long as the pointer in the `OptionalRef` is still valid. pub unsafe fn as_ref(&self) -> Result<&T, EmptyOptionalError> { if self.0.is_null() { Err(EmptyOptionalError) } else { + // SAFETY: This is safe as long as self.0 is still valid. unsafe { Ok(&*self.0) } } } @@ -99,7 +200,13 @@ impl TryFrom for libloot::LogLevel { } } -#[allow(clippy::needless_lifetimes)] +#[allow( + let_underscore_drop, + missing_debug_implementations, + clippy::multiple_unsafe_ops_per_block, + clippy::needless_lifetimes, + reason = "Required by CXX" +)] #[cxx::bridge(namespace = "loot::rust")] mod ffi { @@ -581,22 +688,33 @@ pub static LIBLOOT_VERSION_MINOR: c_uint = libloot::LIBLOOT_VERSION_MINOR; pub static LIBLOOT_VERSION_PATCH: c_uint = libloot::LIBLOOT_VERSION_PATCH; #[unsafe(no_mangle)] -pub static LIBLOOT_LOG_LEVEL_TRACE: c_uchar = libloot::LogLevel::Trace as u8; +pub static LIBLOOT_LOG_LEVEL_TRACE: c_uchar = 0; #[unsafe(no_mangle)] -pub static LIBLOOT_LOG_LEVEL_DEBUG: c_uchar = libloot::LogLevel::Debug as u8; +pub static LIBLOOT_LOG_LEVEL_DEBUG: c_uchar = 1; #[unsafe(no_mangle)] -pub static LIBLOOT_LOG_LEVEL_INFO: c_uchar = libloot::LogLevel::Info as u8; +pub static LIBLOOT_LOG_LEVEL_INFO: c_uchar = 2; #[unsafe(no_mangle)] -pub static LIBLOOT_LOG_LEVEL_WARNING: c_uchar = libloot::LogLevel::Warning as u8; +pub static LIBLOOT_LOG_LEVEL_WARNING: c_uchar = 3; #[unsafe(no_mangle)] -pub static LIBLOOT_LOG_LEVEL_ERROR: c_uchar = libloot::LogLevel::Error as u8; +pub static LIBLOOT_LOG_LEVEL_ERROR: c_uchar = 4; #[unsafe(no_mangle)] -pub static LIBLOOT_LOG_LEVEL_FATAL: c_uchar = libloot::LogLevel::Fatal as u8; +pub static LIBLOOT_LOG_LEVEL_FATAL: c_uchar = 5; + +fn to_u8(value: libloot::LogLevel) -> u8 { + match value { + libloot::LogLevel::Trace => LIBLOOT_LOG_LEVEL_TRACE, + libloot::LogLevel::Debug => LIBLOOT_LOG_LEVEL_DEBUG, + libloot::LogLevel::Info => LIBLOOT_LOG_LEVEL_INFO, + libloot::LogLevel::Warning => LIBLOOT_LOG_LEVEL_WARNING, + libloot::LogLevel::Error => LIBLOOT_LOG_LEVEL_ERROR, + libloot::LogLevel::Fatal => LIBLOOT_LOG_LEVEL_FATAL, + } +} #[unsafe(no_mangle)] unsafe extern "C" fn libloot_set_logging_callback( @@ -606,9 +724,8 @@ unsafe extern "C" fn libloot_set_logging_callback( let mutex = Mutex::new(AtomicPtr::new(context)); set_logging_callback(move |level, message| { - let (level, c_string) = match CString::new(message) { - Ok(c) => (level, c), - Err(_) => { + let (level, c_string) = CString::new(message).map_or_else( + |_| { let c_string = CString::new(format!( "Attempted to log a message containing a null byte: {}", message.replace('\0', "\\0") @@ -617,8 +734,9 @@ unsafe extern "C" fn libloot_set_logging_callback( CString::from(c"Attempted to log a message containing a null byte") }); (libloot::LogLevel::Error, c_string) - } - }; + }, + |c| (level, c), + ); let mut context = match mutex.lock() { Ok(c) => c, @@ -629,8 +747,9 @@ unsafe extern "C" fn libloot_set_logging_callback( } }; + // SAFETY: This is safe so long as callback remains a valid function pointer. unsafe { - callback(level as u8, c_string.as_ptr(), *context.get_mut()); + callback(to_u8(level), c_string.as_ptr(), *context.get_mut()); } }); } diff --git a/cxx/src/metadata.rs b/cxx/src/metadata.rs index 7eff0ebb..4501d3ac 100644 --- a/cxx/src/metadata.rs +++ b/cxx/src/metadata.rs @@ -16,10 +16,10 @@ unsafe trait TransparentWrapper { where Self: Sized, { - let v = value as *const Self::Wrapped; + let v: *const Self::Wrapped = value; // SAFETY: Reinterpreting the pointer of a transparent wrapper to the type it wraps is safe. unsafe { - let v = v as *const Self; + let v: *const Self = v.cast(); &*v } } diff --git a/cxx/src/plugin.rs b/cxx/src/plugin.rs index 54365f84..a0ddd296 100644 --- a/cxx/src/plugin.rs +++ b/cxx/src/plugin.rs @@ -26,7 +26,7 @@ impl Plugin { } pub fn crc(&self) -> i64 { - self.0.crc().map(Into::into).unwrap_or(-1) + self.0.crc().map_or(-1, Into::into) } pub fn is_valid_as_light_plugin(&self) -> Result { @@ -46,7 +46,7 @@ impl Plugin { } pub fn boxed_clone(&self) -> Box { - Box::new(Self(self.0.clone())) + Box::new(Self(Arc::clone(&self.0))) } delegate! { From 2635a4d2adc698a4ec99d1fe568f7abcffafee6c Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 23 Apr 2025 08:27:39 +0100 Subject: [PATCH 104/206] Minor refactor --- src/metadata/plugin_metadata.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 71719fa4..98a4f201 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -362,15 +362,13 @@ fn replace_capturing_groups(regex_string: &str) -> Cow<'_, str> { let mut parts = Vec::new(); while let Some(before) = iter.next() { + parts.push(before); + let Some(after) = iter.peek() else { - parts.push(before); break; }; - if after.starts_with('?') || (before.ends_with("\\(") && !before.ends_with("\\\\(")) { - parts.push(before); - } else { - parts.push(before); + if !after.starts_with('?') && (!before.ends_with("\\(") || before.ends_with("\\\\(")) { parts.push("?:"); } } From 3140a292ddffa1110d23ab79369dcf94e1881753 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 23 Apr 2025 08:38:13 +0100 Subject: [PATCH 105/206] Improved some expect messages --- src/archive/parse.rs | 2 +- src/version.rs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/archive/parse.rs b/src/archive/parse.rs index c81249e0..de988b32 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -119,7 +119,7 @@ fn subarray( #[expect( clippy::as_conversions, - reason = "Made safe by a compile-time assertion" + reason = "A compile-time assertion ensures that this conversion will be lossless on all relevant target platforms" )] pub(super) const fn to_usize(value: u32) -> usize { // Error at compile time if this conversion isn't lossless. diff --git a/src/version.rs b/src/version.rs index 5965889e..54fca4fc 100644 --- a/src/version.rs +++ b/src/version.rs @@ -30,17 +30,18 @@ pub fn is_compatible(major: u32, minor: u32, _patch: u32) -> bool { #[expect( clippy::as_conversions, - reason = "Can't const-convert the byte to a u32 a safer way" + reason = "Can't convert the u8 to a u32 another way in a const context" )] #[expect( clippy::indexing_slicing, - reason = "Can't const-convert the byte to a u8 a safer way" + reason = "Can't iterate over the byte values another way in a const context" )] const fn parse_u32(value: &str) -> u32 { + let bytes = value.as_bytes(); let mut acc = 0; let mut i = 0; - while i < value.len() { - acc = acc * 10 + (value.as_bytes()[i] - b'0') as u32; + while i < bytes.len() { + acc = acc * 10 + (bytes[i] - b'0') as u32; i += 1; } acc From ad4e37cfb65b84f6db7bfb7d649afbf60f0c1836 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 23 Apr 2025 18:02:03 +0100 Subject: [PATCH 106/206] Remove duplicated lines --- cxx/src/lib.rs | 1 - ffi-errors/src/lib.rs | 1 - nodejs/src/lib.rs | 1 - pyo3/src/lib.rs | 1 - src/lib.rs | 1 - 5 files changed, 5 deletions(-) diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index 81a790fa..c6d33867 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -56,7 +56,6 @@ clippy::mem_forget, clippy::missing_assert_message, clippy::missing_asserts_for_indexing, - clippy::missing_asserts_for_indexing, clippy::mixed_read_write_in_expression, clippy::multiple_inherent_impl, clippy::multiple_unsafe_ops_per_block, diff --git a/ffi-errors/src/lib.rs b/ffi-errors/src/lib.rs index b3188028..bac37dd8 100644 --- a/ffi-errors/src/lib.rs +++ b/ffi-errors/src/lib.rs @@ -52,7 +52,6 @@ clippy::mem_forget, clippy::missing_assert_message, clippy::missing_asserts_for_indexing, - clippy::missing_asserts_for_indexing, clippy::mixed_read_write_in_expression, clippy::multiple_inherent_impl, clippy::multiple_unsafe_ops_per_block, diff --git a/nodejs/src/lib.rs b/nodejs/src/lib.rs index 1bdc7f41..73da32c8 100644 --- a/nodejs/src/lib.rs +++ b/nodejs/src/lib.rs @@ -54,7 +54,6 @@ clippy::mem_forget, clippy::missing_assert_message, clippy::missing_asserts_for_indexing, - clippy::missing_asserts_for_indexing, clippy::mixed_read_write_in_expression, clippy::multiple_inherent_impl, clippy::multiple_unsafe_ops_per_block, diff --git a/pyo3/src/lib.rs b/pyo3/src/lib.rs index 27c25a50..05584058 100644 --- a/pyo3/src/lib.rs +++ b/pyo3/src/lib.rs @@ -52,7 +52,6 @@ clippy::mem_forget, clippy::missing_assert_message, clippy::missing_asserts_for_indexing, - clippy::missing_asserts_for_indexing, clippy::mixed_read_write_in_expression, clippy::multiple_inherent_impl, clippy::multiple_unsafe_ops_per_block, diff --git a/src/lib.rs b/src/lib.rs index 77e327a6..02e698f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,7 +56,6 @@ clippy::mem_forget, clippy::missing_assert_message, clippy::missing_asserts_for_indexing, - clippy::missing_asserts_for_indexing, clippy::mixed_read_write_in_expression, clippy::multiple_inherent_impl, clippy::multiple_unsafe_ops_per_block, From aa5344ca925430190d9f68542e69054ac5912f58 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 23 Apr 2025 18:02:17 +0100 Subject: [PATCH 107/206] Replace names used for game types Use the game names rather than their acronyms (aside from suffixes, which are widely used). This also changes the CXX wrapper to use PascalCase for the game types it exposes for consistency. The outer C++ wrapper is unchanged. --- cxx/src/api/game.cpp | 44 +++--- cxx/src/game.rs | 44 +++--- cxx/src/lib.rs | 22 +-- cxx/src/tests/api/internals/main.cpp | 2 +- nodejs/src/game.rs | 36 ++--- pyo3/src/game.rs | 36 ++--- src/archive/find.rs | 31 +++-- src/database/conditions.rs | 4 +- src/database/mod.rs | 82 ++++++------ src/game.rs | 192 +++++++++++++-------------- src/plugin/mod.rs | 52 ++++---- src/tests.rs | 52 ++++---- 12 files changed, 305 insertions(+), 292 deletions(-) diff --git a/cxx/src/api/game.cpp b/cxx/src/api/game.cpp index e8d17c4e..50192171 100644 --- a/cxx/src/api/game.cpp +++ b/cxx/src/api/game.cpp @@ -7,27 +7,27 @@ namespace { loot::GameType convert(loot::rust::GameType gameType) { switch (gameType) { - case loot::rust::GameType::tes3: + case loot::rust::GameType::Morrowind: return loot::GameType::tes3; - case loot::rust::GameType::tes4: + case loot::rust::GameType::Oblivion: return loot::GameType::tes4; - case loot::rust::GameType::tes5: + case loot::rust::GameType::Skyrim: return loot::GameType::tes5; - case loot::rust::GameType::tes5se: + case loot::rust::GameType::SkyrimSE: return loot::GameType::tes5se; - case loot::rust::GameType::tes5vr: + case loot::rust::GameType::SkyrimVR: return loot::GameType::tes5vr; - case loot::rust::GameType::fo3: + case loot::rust::GameType::Fallout3: return loot::GameType::fo3; - case loot::rust::GameType::fonv: + case loot::rust::GameType::FalloutNV: return loot::GameType::fonv; - case loot::rust::GameType::fo4: + case loot::rust::GameType::Fallout4: return loot::GameType::fo4; - case loot::rust::GameType::fo4vr: + case loot::rust::GameType::Fallout4VR: return loot::GameType::fo4vr; - case loot::rust::GameType::starfield: + case loot::rust::GameType::Starfield: return loot::GameType::starfield; - case loot::rust::GameType::openmw: + case loot::rust::GameType::OpenMW: return loot::GameType::openmw; default: throw std::logic_error("Unsupported GameType value"); @@ -37,27 +37,27 @@ loot::GameType convert(loot::rust::GameType gameType) { loot::rust::GameType convert(loot::GameType gameType) { switch (gameType) { case loot::GameType::tes3: - return loot::rust::GameType::tes3; + return loot::rust::GameType::Morrowind; case loot::GameType::tes4: - return loot::rust::GameType::tes4; + return loot::rust::GameType::Oblivion; case loot::GameType::tes5: - return loot::rust::GameType::tes5; + return loot::rust::GameType::Skyrim; case loot::GameType::tes5se: - return loot::rust::GameType::tes5se; + return loot::rust::GameType::SkyrimSE; case loot::GameType::tes5vr: - return loot::rust::GameType::tes5vr; + return loot::rust::GameType::SkyrimVR; case loot::GameType::fo3: - return loot::rust::GameType::fo3; + return loot::rust::GameType::Fallout3; case loot::GameType::fonv: - return loot::rust::GameType::fonv; + return loot::rust::GameType::FalloutNV; case loot::GameType::fo4: - return loot::rust::GameType::fo4; + return loot::rust::GameType::Fallout4; case loot::GameType::fo4vr: - return loot::rust::GameType::fo4vr; + return loot::rust::GameType::Fallout4VR; case loot::GameType::starfield: - return loot::rust::GameType::starfield; + return loot::rust::GameType::Starfield; case loot::GameType::openmw: - return loot::rust::GameType::openmw; + return loot::rust::GameType::OpenMW; default: throw std::logic_error("Unsupported GameType value"); } diff --git a/cxx/src/game.rs b/cxx/src/game.rs index bf216edf..14261275 100644 --- a/cxx/src/game.rs +++ b/cxx/src/game.rs @@ -10,17 +10,17 @@ impl TryFrom for GameType { fn try_from(value: libloot::GameType) -> Result { match value { - libloot::GameType::TES4 => Ok(GameType::tes4), - libloot::GameType::TES5 => Ok(GameType::tes5), - libloot::GameType::FO3 => Ok(GameType::fo3), - libloot::GameType::FONV => Ok(GameType::fonv), - libloot::GameType::FO4 => Ok(GameType::fo4), - libloot::GameType::TES5SE => Ok(GameType::tes5se), - libloot::GameType::FO4VR => Ok(GameType::fo4vr), - libloot::GameType::TES5VR => Ok(GameType::tes5vr), - libloot::GameType::TES3 => Ok(GameType::tes3), - libloot::GameType::Starfield => Ok(GameType::starfield), - libloot::GameType::OpenMW => Ok(GameType::openmw), + libloot::GameType::Oblivion => Ok(GameType::Oblivion), + libloot::GameType::Skyrim => Ok(GameType::Skyrim), + libloot::GameType::Fallout3 => Ok(GameType::Fallout3), + libloot::GameType::FalloutNV => Ok(GameType::FalloutNV), + libloot::GameType::Fallout4 => Ok(GameType::Fallout4), + libloot::GameType::SkyrimSE => Ok(GameType::SkyrimSE), + libloot::GameType::Fallout4VR => Ok(GameType::Fallout4VR), + libloot::GameType::SkyrimVR => Ok(GameType::SkyrimVR), + libloot::GameType::Morrowind => Ok(GameType::Morrowind), + libloot::GameType::Starfield => Ok(GameType::Starfield), + libloot::GameType::OpenMW => Ok(GameType::OpenMW), _ => Err(UnsupportedEnumValueError), } } @@ -31,17 +31,17 @@ impl TryFrom for libloot::GameType { fn try_from(value: GameType) -> Result { match value { - GameType::tes4 => Ok(libloot::GameType::TES4), - GameType::tes5 => Ok(libloot::GameType::TES5), - GameType::fo3 => Ok(libloot::GameType::FO3), - GameType::fonv => Ok(libloot::GameType::FONV), - GameType::fo4 => Ok(libloot::GameType::FO4), - GameType::tes5se => Ok(libloot::GameType::TES5SE), - GameType::fo4vr => Ok(libloot::GameType::FO4VR), - GameType::tes5vr => Ok(libloot::GameType::TES5VR), - GameType::tes3 => Ok(libloot::GameType::TES3), - GameType::starfield => Ok(libloot::GameType::Starfield), - GameType::openmw => Ok(libloot::GameType::OpenMW), + GameType::Oblivion => Ok(libloot::GameType::Oblivion), + GameType::Skyrim => Ok(libloot::GameType::Skyrim), + GameType::Fallout3 => Ok(libloot::GameType::Fallout3), + GameType::FalloutNV => Ok(libloot::GameType::FalloutNV), + GameType::Fallout4 => Ok(libloot::GameType::Fallout4), + GameType::SkyrimSE => Ok(libloot::GameType::SkyrimSE), + GameType::Fallout4VR => Ok(libloot::GameType::Fallout4VR), + GameType::SkyrimVR => Ok(libloot::GameType::SkyrimVR), + GameType::Morrowind => Ok(libloot::GameType::Morrowind), + GameType::Starfield => Ok(libloot::GameType::Starfield), + GameType::OpenMW => Ok(libloot::GameType::OpenMW), _ => Err(UnsupportedEnumValueError), } } diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index c6d33867..27e64ba0 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -210,17 +210,17 @@ impl TryFrom for libloot::LogLevel { mod ffi { pub enum GameType { - tes4, - tes5, - fo3, - fonv, - fo4, - tes5se, - fo4vr, - tes5vr, - tes3, - starfield, - openmw, + Oblivion, + Skyrim, + Fallout3, + FalloutNV, + Fallout4, + SkyrimSE, + Fallout4VR, + SkyrimVR, + Morrowind, + Starfield, + OpenMW, } pub enum MessageType { diff --git a/cxx/src/tests/api/internals/main.cpp b/cxx/src/tests/api/internals/main.cpp index 9ecbe6a8..b7117d6b 100644 --- a/cxx/src/tests/api/internals/main.cpp +++ b/cxx/src/tests/api/internals/main.cpp @@ -39,7 +39,7 @@ TEST(libloot_revision, shouldReturnExpectedValue) { } TEST(new_game, shouldThrowIfGivenNonsense) { - EXPECT_THROW(new_game(GameType::fo3, "foo"), ::rust::Error); + EXPECT_THROW(new_game(GameType::Fallout3, "foo"), ::rust::Error); } TEST(Message, creation) { diff --git a/nodejs/src/game.rs b/nodejs/src/game.rs index 24e185dc..0932010f 100644 --- a/nodejs/src/game.rs +++ b/nodejs/src/game.rs @@ -26,15 +26,15 @@ impl TryFrom for GameType { fn try_from(value: libloot::GameType) -> Result { match value { - libloot::GameType::TES4 => Ok(GameType::Oblivion), - libloot::GameType::TES5 => Ok(GameType::Skyrim), - libloot::GameType::FO3 => Ok(GameType::Fallout3), - libloot::GameType::FONV => Ok(GameType::FalloutNV), - libloot::GameType::FO4 => Ok(GameType::Fallout4), - libloot::GameType::TES5SE => Ok(GameType::SkyrimSE), - libloot::GameType::FO4VR => Ok(GameType::Fallout4VR), - libloot::GameType::TES5VR => Ok(GameType::SkyrimVR), - libloot::GameType::TES3 => Ok(GameType::Morrowind), + libloot::GameType::Oblivion => Ok(GameType::Oblivion), + libloot::GameType::Skyrim => Ok(GameType::Skyrim), + libloot::GameType::Fallout3 => Ok(GameType::Fallout3), + libloot::GameType::FalloutNV => Ok(GameType::FalloutNV), + libloot::GameType::Fallout4 => Ok(GameType::Fallout4), + libloot::GameType::SkyrimSE => Ok(GameType::SkyrimSE), + libloot::GameType::Fallout4VR => Ok(GameType::Fallout4VR), + libloot::GameType::SkyrimVR => Ok(GameType::SkyrimVR), + libloot::GameType::Morrowind => Ok(GameType::Morrowind), libloot::GameType::Starfield => Ok(GameType::Starfield), libloot::GameType::OpenMW => Ok(GameType::OpenMW), _ => Err(UnsupportedEnumValueError), @@ -45,15 +45,15 @@ impl TryFrom for GameType { impl From for libloot::GameType { fn from(value: GameType) -> Self { match value { - GameType::Oblivion => libloot::GameType::TES4, - GameType::Skyrim => libloot::GameType::TES5, - GameType::Fallout3 => libloot::GameType::FO3, - GameType::FalloutNV => libloot::GameType::FONV, - GameType::Fallout4 => libloot::GameType::FO4, - GameType::SkyrimSE => libloot::GameType::TES5SE, - GameType::Fallout4VR => libloot::GameType::FO4VR, - GameType::SkyrimVR => libloot::GameType::TES5VR, - GameType::Morrowind => libloot::GameType::TES3, + GameType::Oblivion => libloot::GameType::Oblivion, + GameType::Skyrim => libloot::GameType::Skyrim, + GameType::Fallout3 => libloot::GameType::Fallout3, + GameType::FalloutNV => libloot::GameType::FalloutNV, + GameType::Fallout4 => libloot::GameType::Fallout4, + GameType::SkyrimSE => libloot::GameType::SkyrimSE, + GameType::Fallout4VR => libloot::GameType::Fallout4VR, + GameType::SkyrimVR => libloot::GameType::SkyrimVR, + GameType::Morrowind => libloot::GameType::Morrowind, GameType::Starfield => libloot::GameType::Starfield, GameType::OpenMW => libloot::GameType::OpenMW, } diff --git a/pyo3/src/game.rs b/pyo3/src/game.rs index 462e53fe..84b7699d 100644 --- a/pyo3/src/game.rs +++ b/pyo3/src/game.rs @@ -26,15 +26,15 @@ impl TryFrom for GameType { fn try_from(value: libloot::GameType) -> Result { match value { - libloot::GameType::TES4 => Ok(GameType::Oblivion), - libloot::GameType::TES5 => Ok(GameType::Skyrim), - libloot::GameType::FO3 => Ok(GameType::Fallout3), - libloot::GameType::FONV => Ok(GameType::FalloutNV), - libloot::GameType::FO4 => Ok(GameType::Fallout4), - libloot::GameType::TES5SE => Ok(GameType::SkyrimSE), - libloot::GameType::FO4VR => Ok(GameType::Fallout4VR), - libloot::GameType::TES5VR => Ok(GameType::SkyrimVR), - libloot::GameType::TES3 => Ok(GameType::Morrowind), + libloot::GameType::Oblivion => Ok(GameType::Oblivion), + libloot::GameType::Skyrim => Ok(GameType::Skyrim), + libloot::GameType::Fallout3 => Ok(GameType::Fallout3), + libloot::GameType::FalloutNV => Ok(GameType::FalloutNV), + libloot::GameType::Fallout4 => Ok(GameType::Fallout4), + libloot::GameType::SkyrimSE => Ok(GameType::SkyrimSE), + libloot::GameType::Fallout4VR => Ok(GameType::Fallout4VR), + libloot::GameType::SkyrimVR => Ok(GameType::SkyrimVR), + libloot::GameType::Morrowind => Ok(GameType::Morrowind), libloot::GameType::Starfield => Ok(GameType::Starfield), libloot::GameType::OpenMW => Ok(GameType::OpenMW), _ => Err(UnsupportedEnumValueError), @@ -47,15 +47,15 @@ impl TryFrom for libloot::GameType { fn try_from(value: GameType) -> Result { match value { - GameType::Oblivion => Ok(libloot::GameType::TES4), - GameType::Skyrim => Ok(libloot::GameType::TES5), - GameType::Fallout3 => Ok(libloot::GameType::FO3), - GameType::FalloutNV => Ok(libloot::GameType::FONV), - GameType::Fallout4 => Ok(libloot::GameType::FO4), - GameType::SkyrimSE => Ok(libloot::GameType::TES5SE), - GameType::Fallout4VR => Ok(libloot::GameType::FO4VR), - GameType::SkyrimVR => Ok(libloot::GameType::TES5VR), - GameType::Morrowind => Ok(libloot::GameType::TES3), + GameType::Oblivion => Ok(libloot::GameType::Oblivion), + GameType::Skyrim => Ok(libloot::GameType::Skyrim), + GameType::Fallout3 => Ok(libloot::GameType::Fallout3), + GameType::FalloutNV => Ok(libloot::GameType::FalloutNV), + GameType::Fallout4 => Ok(libloot::GameType::Fallout4), + GameType::SkyrimSE => Ok(libloot::GameType::SkyrimSE), + GameType::Fallout4VR => Ok(libloot::GameType::Fallout4VR), + GameType::SkyrimVR => Ok(libloot::GameType::SkyrimVR), + GameType::Morrowind => Ok(libloot::GameType::Morrowind), GameType::Starfield => Ok(libloot::GameType::Starfield), GameType::OpenMW => Ok(libloot::GameType::OpenMW), } diff --git a/src/archive/find.rs b/src/archive/find.rs index 03df9083..03c3497d 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -13,21 +13,21 @@ pub fn find_associated_archives( plugin_path: &Path, ) -> Vec { match game_type { - GameType::TES3 | GameType::OpenMW => Vec::new(), + GameType::Morrowind | GameType::OpenMW => Vec::new(), // Skyrim (non-SE) plugins can only load BSAs that have exactly the same // basename, ignoring file extensions. - GameType::TES5 => find_associated_archive(plugin_path), + GameType::Skyrim => find_associated_archive(plugin_path), // Skyrim SE can load BSAs that have exactly the same basename, ignoring // file extensions, and also BSAs with filenames of the form " // - Textures.bsa" (case-insensitively). This assumes that Skyrim VR // works the same way as Skyrim SE. - GameType::TES5SE | GameType::TES5VR => find_associated_archives_with_suffixes(plugin_path, BSA_FILE_EXTENSION, &["", " - Textures"]), + GameType::SkyrimSE | GameType::SkyrimVR => find_associated_archives_with_suffixes(plugin_path, BSA_FILE_EXTENSION, &["", " - Textures"]), // Oblivion .esp files can load archives which begin with the plugin // basename. - GameType::TES4 => { + GameType::Oblivion => { if has_ascii_extension(plugin_path, "esp") { find_associated_archives_with_arbitrary_suffixes(plugin_path, game_cache) } else { @@ -37,7 +37,7 @@ pub fn find_associated_archives( // FO3, FNV, FO4 plugins can load archives which begin with the plugin // basename. This assumes that FO4 VR works the same way as FO4. - GameType::FO3 | GameType::FONV | GameType::FO4 | GameType::FO4VR => + GameType::Fallout3 | GameType::FalloutNV | GameType::Fallout4 | GameType::Fallout4VR => find_associated_archives_with_arbitrary_suffixes(plugin_path, game_cache) , @@ -228,8 +228,8 @@ mod tests { let data_path = tmp_dir.path().to_path_buf(); match game_type { - GameType::TES3 | GameType::OpenMW => {} - GameType::FO4 | GameType::FO4VR | GameType::Starfield => { + GameType::Morrowind | GameType::OpenMW => {} + GameType::Fallout4 | GameType::Fallout4VR | GameType::Starfield => { let source = absolute("./testing-plugins/Fallout 4/Data").unwrap(); copy_file(&source, &data_path, "Blank - Main.ba2"); copy_file(&source, &data_path, "Blank - Textures.ba2"); @@ -308,7 +308,7 @@ mod tests { if matches!( game_type, - GameType::TES3 | GameType::OpenMW | GameType::TES4 + GameType::Morrowind | GameType::OpenMW | GameType::Oblivion ) { assert!(archives.is_empty()); } else { @@ -330,7 +330,7 @@ mod tests { if matches!( game_type, - GameType::TES3 | GameType::OpenMW | GameType::Starfield + GameType::Morrowind | GameType::OpenMW | GameType::Starfield ) { assert!(archives.is_empty()); } else { @@ -350,7 +350,7 @@ mod tests { &fixture.data_path.join(BLANK_ESP), ); - if matches!(game_type, GameType::TES3 | GameType::OpenMW) { + if matches!(game_type, GameType::Morrowind | GameType::OpenMW) { assert!(archives.is_empty()); } else { assert!(!archives.is_empty()); @@ -371,7 +371,10 @@ mod tests { if matches!( game_type, - GameType::FO3 | GameType::FONV | GameType::FO4 | GameType::FO4VR + GameType::Fallout3 + | GameType::FalloutNV + | GameType::Fallout4 + | GameType::Fallout4VR ) { assert!(!archives.is_empty()); } else { @@ -393,7 +396,11 @@ mod tests { if matches!( game_type, - GameType::TES4 | GameType::FO3 | GameType::FONV | GameType::FO4 | GameType::FO4VR + GameType::Oblivion + | GameType::Fallout3 + | GameType::FalloutNV + | GameType::Fallout4 + | GameType::Fallout4VR ) { assert!(!archives.is_empty()); } else { diff --git a/src/database/conditions.rs b/src/database/conditions.rs index ad43b79d..0c86d7df 100644 --- a/src/database/conditions.rs +++ b/src/database/conditions.rs @@ -157,7 +157,7 @@ mod tests { let state = loot_condition_interpreter::State::new( loot_condition_interpreter::GameType::Oblivion, - source_plugins_path(crate::GameType::TES4), + source_plugins_path(crate::GameType::Oblivion), ); let result = evaluate_all_conditions(plugin, &state).unwrap().unwrap(); @@ -183,7 +183,7 @@ mod tests { let state = loot_condition_interpreter::State::new( loot_condition_interpreter::GameType::Oblivion, - source_plugins_path(crate::GameType::TES4), + source_plugins_path(crate::GameType::Oblivion), ); assert!(evaluate_all_conditions(plugin, &state).unwrap().is_none()); } diff --git a/src/database/mod.rs b/src/database/mod.rs index ca2a6135..f132fa02 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -456,7 +456,7 @@ plugins: #[test] fn load_masterlist_should_succeed_if_given_a_valid_path() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -466,7 +466,7 @@ plugins: #[test] fn load_masterlist_with_prelude_should_succeed_if_given_valid_paths() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database @@ -478,7 +478,7 @@ plugins: #[test] fn load_userlist_should_succeed_if_given_a_valid_path() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_userlist(&fixture.metadata_path).unwrap(); @@ -499,7 +499,7 @@ plugins: #[test] fn should_write_only_user_metadata() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -518,7 +518,7 @@ plugins: #[test] fn should_succeed_if_the_path_does_not_exist() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); let output_path = fixture.inner.local_path.join("userlist.yaml"); @@ -531,7 +531,7 @@ plugins: #[test] fn should_succeed_if_the_path_does_not_exist_and_truncation_is_allowed() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); let output_path = fixture.inner.local_path.join("userlist.yaml"); @@ -544,7 +544,7 @@ plugins: #[test] fn should_succeed_if_the_path_exists_and_truncation_is_allowed() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); let output_path = fixture.inner.local_path.join("userlist.yaml"); @@ -559,7 +559,7 @@ plugins: #[test] fn should_error_if_the_parent_path_does_not_exist() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); let output_path = fixture.inner.local_path.join("missing/userlist.yaml"); @@ -572,7 +572,7 @@ plugins: #[test] fn should_error_if_the_path_is_read_only() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); let output_path = fixture.inner.local_path.join("userlist.yaml"); @@ -591,7 +591,7 @@ plugins: #[test] fn should_error_if_the_path_exists_and_truncation_is_not_allowed() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); let output_path = fixture.inner.local_path.join("userlist.yaml"); @@ -610,7 +610,7 @@ plugins: #[test] fn should_only_write_plugin_bash_tags_and_dirty_info() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); let output_path = fixture.inner.local_path.join("minimal.yaml"); @@ -656,7 +656,7 @@ plugins: #[test] fn should_succeed_if_the_path_does_not_exist() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); let output_path = fixture.inner.local_path.join("minimal.yaml"); @@ -669,7 +669,7 @@ plugins: #[test] fn should_succeed_if_the_path_does_not_exist_and_truncation_is_allowed() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); let output_path = fixture.inner.local_path.join("minimal.yaml"); @@ -682,7 +682,7 @@ plugins: #[test] fn should_succeed_if_the_path_exists_and_truncation_is_allowed() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); let output_path = fixture.inner.local_path.join("minimal.yaml"); @@ -697,7 +697,7 @@ plugins: #[test] fn should_error_if_the_parent_path_does_not_exist() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); let output_path = fixture.inner.local_path.join("missing/minimal.yaml"); @@ -710,7 +710,7 @@ plugins: #[test] fn should_error_if_the_path_is_read_only() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); let output_path = fixture.inner.local_path.join("minimal.yaml"); @@ -729,7 +729,7 @@ plugins: #[test] fn should_error_if_the_path_exists_and_truncation_is_not_allowed() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); let output_path = fixture.inner.local_path.join("minimal.yaml"); @@ -745,7 +745,7 @@ plugins: #[test] fn known_bash_tags_should_append_userlist_tags_to_masterlist_tags() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -766,7 +766,7 @@ plugins: #[test] fn should_append_userlist_messages_to_masterlist_messages() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -792,7 +792,7 @@ plugins: #[test] fn should_filter_out_messages_with_false_conditions_when_evaluating_conditions() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -818,7 +818,7 @@ plugins: #[test] fn should_return_true_if_the_condition_is_true() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); assert!(database.evaluate("file(\"Blank.esp\")").unwrap()); @@ -826,7 +826,7 @@ plugins: #[test] fn should_return_false_if_the_condition_is_false() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); assert!(!database.evaluate("file(\"missing.esp\")").unwrap()); @@ -838,7 +838,7 @@ plugins: #[test] fn should_return_default_group_before_metadata_has_been_loaded() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); assert_eq!(&[Group::default(),], database.groups(true).as_slice()); @@ -846,7 +846,7 @@ plugins: #[test] fn should_not_include_user_groups_if_param_is_false() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -872,7 +872,7 @@ plugins: #[test] fn should_merge_masterlist_and_userlist_groups() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -901,7 +901,7 @@ plugins: #[test] fn user_groups_should_not_include_masterlist_groups() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -911,7 +911,7 @@ plugins: #[test] fn set_user_groups_should_replace_existing_user_groups() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -944,7 +944,7 @@ plugins: #[test] fn groups_path_should_find_path_using_masterlist_and_user_metadata() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -970,7 +970,7 @@ plugins: #[test] fn should_return_none_if_plugin_has_no_metadata_set() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); assert!( @@ -983,7 +983,7 @@ plugins: #[test] fn should_return_none_if_plugin_metadata_has_only_name() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.set_plugin_user_metadata(PluginMetadata::new(BLANK_ESM).unwrap()); @@ -998,7 +998,7 @@ plugins: #[test] fn should_prefer_user_metadata_when_merging_metadata() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -1023,7 +1023,7 @@ plugins: #[test] fn should_return_only_masterlist_metadata_if_include_user_metadata_is_false() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -1045,7 +1045,7 @@ plugins: #[test] fn should_filter_out_metadata_with_false_conditions_when_evaluating_conditions() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -1074,7 +1074,7 @@ plugins: #[test] fn should_return_none_if_plugin_has_no_user_metadata_set() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let database = fixture.database(); assert!( @@ -1087,7 +1087,7 @@ plugins: #[test] fn should_return_none_if_plugin_user_metadata_has_only_name() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.set_plugin_user_metadata(PluginMetadata::new(BLANK_ESM).unwrap()); @@ -1102,7 +1102,7 @@ plugins: #[test] fn should_return_only_user_metadata() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -1124,7 +1124,7 @@ plugins: #[test] fn should_filter_out_metadata_with_false_conditions_when_evaluating_conditions() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -1151,7 +1151,7 @@ plugins: #[test] fn should_replace_existing_user_metadata_for_the_plugin() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -1177,7 +1177,7 @@ plugins: #[test] fn should_not_modify_masterlist_metadata_for_the_plugin() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -1203,7 +1203,7 @@ plugins: #[test] fn discard_plugin_user_metadata_should_discard_only_user_metadata_for_only_the_given_plugin() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); @@ -1242,7 +1242,7 @@ plugins: #[test] fn discard_all_user_metadata_should_not_remove_masterlist_metadata() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut database = fixture.database(); database.load_masterlist(&fixture.metadata_path).unwrap(); diff --git a/src/game.rs b/src/game.rs index 297d62b2..63ed2d04 100644 --- a/src/game.rs +++ b/src/game.rs @@ -36,23 +36,23 @@ use crate::{ #[non_exhaustive] pub enum GameType { /// The Elder Scrolls IV: Oblivion - TES4, + Oblivion, /// The Elder Scrolls V: Skyrim - TES5, + Skyrim, /// Fallout 3 - FO3, + Fallout3, /// Fallout: New Vegas - FONV, + FalloutNV, /// Fallout 4 - FO4, + Fallout4, /// The Elder Scrolls V: Skyrim Special Edition - TES5SE, + SkyrimSE, /// Fallout 4 VR - FO4VR, + Fallout4VR, /// Skyrim VR - TES5VR, + SkyrimVR, /// The Elder Scrolls III: Morrowind - TES3, + Morrowind, /// Starfield Starfield, /// OpenMW @@ -62,15 +62,15 @@ pub enum GameType { impl Display for GameType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - GameType::TES4 => write!(f, "The Elder Scrolls IV: Oblivion"), - GameType::TES5 => write!(f, "The Elder Scrolls V: Skyrim"), - GameType::FO3 => write!(f, "Fallout 3"), - GameType::FONV => write!(f, "Fallout: New Vegas"), - GameType::FO4 => write!(f, "Fallout 4"), - GameType::TES5SE => write!(f, "The Elder Scrolls V: Skyrim Special Edition"), - GameType::FO4VR => write!(f, "Fallout 4 VR"), - GameType::TES5VR => write!(f, "The Elder Scrolls V: Skyrim VR"), - GameType::TES3 => write!(f, "The Elder Scrolls III: Morrowind"), + GameType::Oblivion => write!(f, "The Elder Scrolls IV: Oblivion"), + GameType::Skyrim => write!(f, "The Elder Scrolls V: Skyrim"), + GameType::Fallout3 => write!(f, "Fallout 3"), + GameType::FalloutNV => write!(f, "Fallout: New Vegas"), + GameType::Fallout4 => write!(f, "Fallout 4"), + GameType::SkyrimSE => write!(f, "The Elder Scrolls V: Skyrim Special Edition"), + GameType::Fallout4VR => write!(f, "Fallout 4 VR"), + GameType::SkyrimVR => write!(f, "The Elder Scrolls V: Skyrim VR"), + GameType::Morrowind => write!(f, "The Elder Scrolls III: Morrowind"), GameType::Starfield => write!(f, "Starfield"), GameType::OpenMW => write!(f, "OpenMW"), } @@ -80,15 +80,15 @@ impl Display for GameType { impl From for loadorder::GameId { fn from(value: GameType) -> Self { match value { - GameType::TES4 => loadorder::GameId::Oblivion, - GameType::TES5 => loadorder::GameId::Skyrim, - GameType::FO3 => loadorder::GameId::Fallout3, - GameType::FONV => loadorder::GameId::FalloutNV, - GameType::FO4 => loadorder::GameId::Fallout4, - GameType::TES5SE => loadorder::GameId::SkyrimSE, - GameType::FO4VR => loadorder::GameId::Fallout4VR, - GameType::TES5VR => loadorder::GameId::SkyrimVR, - GameType::TES3 => loadorder::GameId::Morrowind, + GameType::Oblivion => loadorder::GameId::Oblivion, + GameType::Skyrim => loadorder::GameId::Skyrim, + GameType::Fallout3 => loadorder::GameId::Fallout3, + GameType::FalloutNV => loadorder::GameId::FalloutNV, + GameType::Fallout4 => loadorder::GameId::Fallout4, + GameType::SkyrimSE => loadorder::GameId::SkyrimSE, + GameType::Fallout4VR => loadorder::GameId::Fallout4VR, + GameType::SkyrimVR => loadorder::GameId::SkyrimVR, + GameType::Morrowind => loadorder::GameId::Morrowind, GameType::Starfield => loadorder::GameId::Starfield, GameType::OpenMW => loadorder::GameId::OpenMW, } @@ -98,15 +98,15 @@ impl From for loadorder::GameId { impl From for loot_condition_interpreter::GameType { fn from(value: GameType) -> Self { match value { - GameType::TES4 => loot_condition_interpreter::GameType::Oblivion, - GameType::TES5 => loot_condition_interpreter::GameType::Skyrim, - GameType::FO3 => loot_condition_interpreter::GameType::Fallout3, - GameType::FONV => loot_condition_interpreter::GameType::FalloutNV, - GameType::FO4 => loot_condition_interpreter::GameType::Fallout4, - GameType::TES5SE => loot_condition_interpreter::GameType::SkyrimSE, - GameType::FO4VR => loot_condition_interpreter::GameType::Fallout4VR, - GameType::TES5VR => loot_condition_interpreter::GameType::SkyrimVR, - GameType::TES3 => loot_condition_interpreter::GameType::Morrowind, + GameType::Oblivion => loot_condition_interpreter::GameType::Oblivion, + GameType::Skyrim => loot_condition_interpreter::GameType::Skyrim, + GameType::Fallout3 => loot_condition_interpreter::GameType::Fallout3, + GameType::FalloutNV => loot_condition_interpreter::GameType::FalloutNV, + GameType::Fallout4 => loot_condition_interpreter::GameType::Fallout4, + GameType::SkyrimSE => loot_condition_interpreter::GameType::SkyrimSE, + GameType::Fallout4VR => loot_condition_interpreter::GameType::Fallout4VR, + GameType::SkyrimVR => loot_condition_interpreter::GameType::SkyrimVR, + GameType::Morrowind => loot_condition_interpreter::GameType::Morrowind, GameType::Starfield => loot_condition_interpreter::GameType::Starfield, GameType::OpenMW => loot_condition_interpreter::GameType::OpenMW, } @@ -116,13 +116,13 @@ impl From for loot_condition_interpreter::GameType { impl From for esplugin::GameId { fn from(value: GameType) -> Self { match value { - GameType::TES4 => esplugin::GameId::Oblivion, - GameType::TES5 => esplugin::GameId::Skyrim, - GameType::FO3 => esplugin::GameId::Fallout3, - GameType::FONV => esplugin::GameId::FalloutNV, - GameType::FO4 | GameType::FO4VR => esplugin::GameId::Fallout4, - GameType::TES5SE | GameType::TES5VR => esplugin::GameId::SkyrimSE, - GameType::TES3 | GameType::OpenMW => esplugin::GameId::Morrowind, + GameType::Oblivion => esplugin::GameId::Oblivion, + GameType::Skyrim => esplugin::GameId::Skyrim, + GameType::Fallout3 => esplugin::GameId::Fallout3, + GameType::FalloutNV => esplugin::GameId::FalloutNV, + GameType::Fallout4 | GameType::Fallout4VR => esplugin::GameId::Fallout4, + GameType::SkyrimSE | GameType::SkyrimVR => esplugin::GameId::SkyrimSE, + GameType::Morrowind | GameType::OpenMW => esplugin::GameId::Morrowind, GameType::Starfield => esplugin::GameId::Starfield, } } @@ -329,7 +329,7 @@ impl Game { if matches!( self.game_type, - GameType::TES3 | GameType::OpenMW | GameType::Starfield + GameType::Morrowind | GameType::OpenMW | GameType::Starfield ) { let mut loaded_plugins: HashMap = self .cache @@ -552,7 +552,7 @@ fn resolve_path(path: &Path) -> PathBuf { fn data_path(game_type: GameType, game_path: &Path) -> PathBuf { match game_type { - GameType::TES3 => game_path.join("Data Files"), + GameType::Morrowind => game_path.join("Data Files"), GameType::OpenMW => game_path.join("resources/vfs"), _ => game_path.join("Data"), } @@ -632,7 +632,7 @@ fn find_archives( fn archive_file_extension(game_type: GameType) -> &'static str { match game_type { - GameType::FO4 | GameType::FO4VR | GameType::Starfield => ".ba2", + GameType::Fallout4 | GameType::Fallout4VR | GameType::Starfield => ".ba2", _ => ".bsa", } } @@ -915,7 +915,7 @@ mod tests { fn should_succeed_for_morrowind_if_given_valid_game_path(game_type: GameType) { let fixture = Fixture::new(game_type); - if matches!(game_type, GameType::TES3 | GameType::OpenMW) { + if matches!(game_type, GameType::Morrowind | GameType::OpenMW) { assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); } else { assert!(Game::new(fixture.game_type, &fixture.game_path).is_err()); @@ -924,7 +924,7 @@ mod tests { #[test] fn should_succeed_if_given_a_relative_game_path() { - let fixture = Fixture::in_path(GameType::TES3, Path::new("target")); + let fixture = Fixture::in_path(GameType::Morrowind, Path::new("target")); let game_path = make_relative(&fixture.game_path); assert!(game_path.is_relative()); @@ -934,7 +934,7 @@ mod tests { #[test] fn should_succeed_if_given_an_absolute_game_path() { - let fixture = Fixture::new(GameType::TES3); + let fixture = Fixture::new(GameType::Morrowind); assert!(fixture.game_path.is_absolute()); assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); @@ -942,7 +942,7 @@ mod tests { #[test] fn should_succeed_if_given_a_symlink_path() { - let fixture = Fixture::new(GameType::TES3); + let fixture = Fixture::new(GameType::Morrowind); let game_path = fixture.game_path.with_extension("symlink"); symlink_dir(&fixture.game_path, &game_path); @@ -954,7 +954,7 @@ mod tests { #[cfg(windows)] #[test] fn should_succeed_if_given_a_junction_link_path() { - let fixture = Fixture::new(GameType::TES3); + let fixture = Fixture::new(GameType::Morrowind); let game_path = fixture.game_path.with_extension("junction"); junction_link(&fixture.game_path, &game_path); @@ -965,7 +965,7 @@ mod tests { #[test] fn should_error_if_given_a_game_path_that_does_not_exist() { let game_path = Path::new("missing"); - match Game::new(GameType::TES3, game_path) { + match Game::new(GameType::Morrowind, game_path) { Err(GameHandleCreationError::NotADirectory(p)) => assert_eq!(game_path, p), _ => panic!("Expected a not-a-directory error"), } @@ -990,7 +990,7 @@ mod tests { #[test] fn should_succeed_if_given_relative_paths() { - let fixture = Fixture::in_path(GameType::TES3, Path::new("target")); + let fixture = Fixture::in_path(GameType::Morrowind, Path::new("target")); let game_path = make_relative(&fixture.game_path); assert!(game_path.is_relative()); @@ -1005,7 +1005,7 @@ mod tests { #[test] fn should_succeed_if_given_absolute_paths() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); assert!(fixture.game_path.is_absolute()); assert!(fixture.local_path.is_absolute()); @@ -1021,7 +1021,7 @@ mod tests { #[test] fn should_succeed_if_given_symlink_paths() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let game_path = fixture.game_path.with_extension("symlink"); symlink_dir(&fixture.game_path, &game_path); @@ -1039,7 +1039,7 @@ mod tests { #[cfg(windows)] #[test] fn should_succeed_if_given_junction_link_paths() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let game_path = fixture.game_path.with_extension("junction"); junction_link(&fixture.game_path, &game_path); @@ -1054,7 +1054,7 @@ mod tests { #[test] fn should_error_if_given_a_game_path_that_does_not_exist() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let game_path = Path::new("missing"); let game = Game::with_local_path(fixture.game_type, game_path, &fixture.local_path); @@ -1067,7 +1067,7 @@ mod tests { #[test] fn should_succeed_if_given_a_local_path_that_does_not_exist() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let local_path = Path::new("missing"); let game = Game::with_local_path(fixture.game_type, &fixture.game_path, local_path); @@ -1077,7 +1077,7 @@ mod tests { #[test] fn should_error_if_given_a_local_path_that_is_not_a_directory() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let local_path = Path::new("README.md"); assert!(local_path.exists()); @@ -1095,7 +1095,7 @@ mod tests { let fixture = Fixture::new(game_type); match game_type { - GameType::FO4 => { + GameType::Fallout4 => { std::fs::File::create(fixture.game_path.join("appxmanifest.xml")).unwrap(); } GameType::OpenMW => { @@ -1117,7 +1117,7 @@ mod tests { .unwrap(); match game_type { - GameType::FO4 => { + GameType::Fallout4 => { let base_path = fixture.game_path.join("../.."); assert_eq!( &[ @@ -1180,7 +1180,7 @@ mod tests { #[test] fn should_clear_the_condition_cache() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut game = Game::with_local_path( fixture.game_type, @@ -1223,7 +1223,7 @@ mod tests { #[test] fn should_update_where_load_order_plugins_are_found() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut game = Game::with_local_path( fixture.game_type, @@ -1375,7 +1375,7 @@ mod tests { #[test] fn should_resolve_relative_paths_relative_to_the_data_path() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let game = Game::with_local_path( fixture.game_type, @@ -1393,7 +1393,7 @@ mod tests { #[test] fn should_use_absolute_paths_as_given() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let game = Game::with_local_path( fixture.game_type, @@ -1441,7 +1441,7 @@ mod tests { #[test] fn should_not_modify_loaded_plugins_storage_if_given_a_non_plugin() { - let fixture = Fixture::new(GameType::TES3); + let fixture = Fixture::new(GameType::Morrowind); let mut game = Game::with_local_path( fixture.game_type, @@ -1464,7 +1464,7 @@ mod tests { #[test] fn should_not_clear_the_plugins_cache() { - let fixture = Fixture::new(GameType::TES3); + let fixture = Fixture::new(GameType::Morrowind); let mut game = Game::with_local_path( fixture.game_type, @@ -1484,7 +1484,7 @@ mod tests { #[test] fn should_replace_an_existing_cache_entry_for_the_same_plugin() { - let fixture = Fixture::new(GameType::TES3); + let fixture = Fixture::new(GameType::Morrowind); let mut game = Game::with_local_path( fixture.game_type, @@ -1542,7 +1542,7 @@ mod tests { #[test] fn should_not_clear_the_plugins_cache() { - let fixture = Fixture::new(GameType::TES3); + let fixture = Fixture::new(GameType::Morrowind); let mut game = Game::with_local_path( fixture.game_type, @@ -1562,7 +1562,7 @@ mod tests { #[test] fn should_replace_an_existing_cache_entry_for_the_same_plugin() { - let fixture = Fixture::new(GameType::TES3); + let fixture = Fixture::new(GameType::Morrowind); let mut game = Game::with_local_path( fixture.game_type, @@ -1601,7 +1601,7 @@ mod tests { if matches!( game_type, - GameType::TES3 | GameType::OpenMW | GameType::Starfield + GameType::Morrowind | GameType::OpenMW | GameType::Starfield ) { match game.load_plugins(paths) { Err(LoadPluginsError::PluginDataError(e)) => { @@ -1690,7 +1690,7 @@ mod tests { let extension = if matches!( game_type, - GameType::FO4 | GameType::FO4VR | GameType::Starfield + GameType::Fallout4 | GameType::Fallout4VR | GameType::Starfield ) { ".ba2" } else { @@ -1730,7 +1730,7 @@ mod tests { #[test] fn should_clear_the_archive_cache_before_finding_archives() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut game = Game::with_local_path( fixture.game_type, @@ -1752,7 +1752,7 @@ mod tests { #[test] fn should_not_error_if_an_installed_filename_has_non_windows_1252_encodable_characters() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut game = Game::with_local_path( fixture.game_type, @@ -1770,7 +1770,7 @@ mod tests { #[test] fn should_error_given_duplicate_filenames() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut game = Game::with_local_path( fixture.game_type, @@ -1800,7 +1800,7 @@ mod tests { #[test] fn should_resolve_relative_paths_relative_to_the_data_path() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut game = Game::with_local_path( fixture.game_type, @@ -1823,7 +1823,7 @@ mod tests { #[test] fn should_use_absolute_paths_as_given() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut game = Game::with_local_path( fixture.game_type, @@ -1844,7 +1844,7 @@ mod tests { #[test] fn should_trim_ghost_extensions_from_loaded_plugin_names() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut game = Game::with_local_path( fixture.game_type, @@ -1868,7 +1868,7 @@ mod tests { #[test] fn clear_loaded_plugins_should_clear_the_plugins_cache() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut game = Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) @@ -1899,7 +1899,7 @@ mod tests { #[test] fn should_return_an_empty_list_if_given_an_empty_list() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let game = Game::with_local_path( fixture.game_type, @@ -1913,7 +1913,7 @@ mod tests { #[test] fn should_only_sort_the_given_plugins() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut game = Game::with_local_path( fixture.game_type, @@ -1932,7 +1932,7 @@ mod tests { #[test] fn should_error_if_a_given_plugin_is_not_loaded() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let game = Game::with_local_path( fixture.game_type, @@ -1950,7 +1950,7 @@ mod tests { #[test] fn should_be_independent_of_plugins_being_loaded() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut game = Game::with_local_path( fixture.game_type, @@ -1979,7 +1979,7 @@ mod tests { #[test] fn set_load_order_should_persist_the_given_load_order() { - let fixture = Fixture::new(GameType::TES4); + let fixture = Fixture::new(GameType::Oblivion); let mut game = Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) @@ -2005,7 +2005,7 @@ mod tests { #[test] fn should_support_loading_plugins_and_metadata_in_parallel() { - let fixture = Fixture::new(GameType::TES3); + let fixture = Fixture::new(GameType::Morrowind); let mut game = Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) @@ -2030,7 +2030,7 @@ mod tests { #[test] fn to_plugin_sorting_data_should_filter_out_files_with_false_constraints() { - let game_type = GameType::TES4; + let game_type = GameType::Oblivion; let true_constraint = "file(\"Blank.esm\")"; let false_constraint = "file(\"missing.esm\")"; @@ -2095,9 +2095,9 @@ mod tests { cache.insert_plugins(vec![ Plugin::new( - GameType::TES4, + GameType::Oblivion, &cache, - &source_plugins_path(GameType::TES4).join(BLANK_ESM), + &source_plugins_path(GameType::Oblivion).join(BLANK_ESM), LoadScope::HeaderOnly, ) .unwrap(), @@ -2112,9 +2112,9 @@ mod tests { cache.insert_plugins(vec![ Plugin::new( - GameType::TES4, + GameType::Oblivion, &cache, - &source_plugins_path(GameType::TES4).join(BLANK_ESM), + &source_plugins_path(GameType::Oblivion).join(BLANK_ESM), LoadScope::HeaderOnly, ) .unwrap(), @@ -2124,9 +2124,9 @@ mod tests { cache.insert_plugins(vec![ Plugin::new( - GameType::TES4, + GameType::Oblivion, &cache, - &source_plugins_path(GameType::TES4).join(BLANK_ESM), + &source_plugins_path(GameType::Oblivion).join(BLANK_ESM), LoadScope::WholePlugin, ) .unwrap(), @@ -2145,9 +2145,9 @@ mod tests { cache.insert_plugins(vec![ Plugin::new( - GameType::TES4, + GameType::Oblivion, &cache, - &source_plugins_path(GameType::TES4).join(BLANK_ESM), + &source_plugins_path(GameType::Oblivion).join(BLANK_ESM), LoadScope::HeaderOnly, ) .unwrap(), @@ -2173,9 +2173,9 @@ mod tests { cache.insert_plugins(vec![ Plugin::new( - GameType::TES4, + GameType::Oblivion, &cache, - &source_plugins_path(GameType::TES4).join(BLANK_ESM), + &source_plugins_path(GameType::Oblivion).join(BLANK_ESM), LoadScope::HeaderOnly, ) .unwrap(), diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index d98da511..e625bd1b 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -331,10 +331,10 @@ fn has_plugin_file_extension(game_type: GameType, plugin_path: &Path) -> bool { } else { matches!( game_type, - GameType::FO4 - | GameType::FO4VR - | GameType::TES5SE - | GameType::TES5VR + GameType::Fallout4 + | GameType::Fallout4VR + | GameType::SkyrimSE + | GameType::SkyrimVR | GameType::Starfield ) && extension.eq_ignore_ascii_case("esl") } @@ -510,7 +510,7 @@ mod tests { #[test] fn new_should_handle_non_ascii_filenames_correctly() { let tmp_dir = tempdir().unwrap(); - let source_path = source_plugins_path(GameType::TES4).join(BLANK_ESM); + let source_path = source_plugins_path(GameType::Oblivion).join(BLANK_ESM); let path = tmp_dir.path().join(NON_ASCII_ESM); std::fs::copy(source_path, &path).unwrap(); @@ -539,10 +539,10 @@ mod tests { #[expect(clippy::float_cmp, reason = "float values should be exactly equal")] match game_type { - GameType::TES3 | GameType::OpenMW => { + GameType::Morrowind | GameType::OpenMW => { assert_eq!(1.2, plugin.header_version().unwrap()); } - GameType::TES4 => assert_eq!(0.8, plugin.header_version().unwrap()), + GameType::Oblivion => assert_eq!(0.8, plugin.header_version().unwrap()), GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), _ => assert_eq!(0.94, plugin.header_version().unwrap()), } @@ -610,18 +610,18 @@ mod tests { #[expect(clippy::float_cmp, reason = "float values should be exactly equal")] match game_type { - GameType::TES3 | GameType::OpenMW => { + GameType::Morrowind | GameType::OpenMW => { assert_eq!(1.2, plugin.header_version().unwrap()); } - GameType::TES4 => assert_eq!(0.8, plugin.header_version().unwrap()), + GameType::Oblivion => assert_eq!(0.8, plugin.header_version().unwrap()), GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), _ => assert_eq!(0.94, plugin.header_version().unwrap()), } let expected_crc = match game_type { - GameType::TES3 | GameType::OpenMW => 3_317_676_987, + GameType::Morrowind | GameType::OpenMW => 3_317_676_987, GameType::Starfield => 1_422_425_298, - GameType::TES4 => 3_759_349_588, + GameType::Oblivion => 3_759_349_588, _ => 3_000_242_590, }; @@ -629,7 +629,7 @@ mod tests { assert!(!plugin.do_assets_overlap(&plugin)); assert_eq!(0, plugin.asset_count()); - if matches!(game_type, GameType::TES3 | GameType::OpenMW) { + if matches!(game_type, GameType::Morrowind | GameType::OpenMW) { let master = Plugin::new( game_type, &GameCache::default(), @@ -678,7 +678,7 @@ mod tests { if matches!( game_type, - GameType::TES3 | GameType::OpenMW | GameType::Starfield + GameType::Morrowind | GameType::OpenMW | GameType::Starfield ) { // The Starfield test data doesn't include a BA2 file. assert!(!plugin.loads_archive()); @@ -742,7 +742,7 @@ mod tests { assert!( Plugin::new( - GameType::TES4, + GameType::Oblivion, &GameCache::default(), path, LoadScope::HeaderOnly @@ -802,10 +802,10 @@ mod tests { if matches!( game_type, - GameType::FO4 - | GameType::FO4VR - | GameType::TES5SE - | GameType::TES5VR + GameType::Fallout4 + | GameType::Fallout4VR + | GameType::SkyrimSE + | GameType::SkyrimVR | GameType::Starfield ) { assert!(light.is_light_plugin()); @@ -948,10 +948,10 @@ mod tests { if matches!( game_type, - GameType::FO4 - | GameType::FO4VR - | GameType::TES5SE - | GameType::TES5VR + GameType::Fallout4 + | GameType::Fallout4VR + | GameType::SkyrimSE + | GameType::SkyrimVR | GameType::Starfield ) { assert!(result); @@ -1053,10 +1053,10 @@ mod tests { let result = has_plugin_file_extension(game_type, Path::new("file.esl")); if matches!( game_type, - GameType::FO4 - | GameType::TES5SE - | GameType::FO4VR - | GameType::TES5VR + GameType::Fallout4 + | GameType::SkyrimSE + | GameType::Fallout4VR + | GameType::SkyrimVR | GameType::Starfield ) { assert!(result); diff --git a/src/tests.rs b/src/tests.rs index 32e6ff44..e2af230c 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -28,10 +28,12 @@ pub const NON_ASCII_ESM: &str = "non\u{00C1}scii.esm"; pub fn source_plugins_path(game_type: GameType) -> PathBuf { match game_type { - GameType::TES3 | GameType::OpenMW => absolute("./testing-plugins/Morrowind/Data Files"), - GameType::TES4 => absolute("./testing-plugins/Oblivion/Data"), + GameType::Morrowind | GameType::OpenMW => { + absolute("./testing-plugins/Morrowind/Data Files") + } + GameType::Oblivion => absolute("./testing-plugins/Oblivion/Data"), GameType::Starfield => absolute("./testing-plugins/Starfield/Data"), - GameType::FO3 | GameType::FONV | GameType::TES5 => { + GameType::Fallout3 | GameType::FalloutNV | GameType::Skyrim => { absolute("./testing-plugins/Skyrim/Data") } _ => absolute("./testing-plugins/SkyrimSE/Data"), @@ -41,12 +43,12 @@ pub fn source_plugins_path(game_type: GameType) -> PathBuf { fn master_file(game_type: GameType) -> &'static str { match game_type { - GameType::TES3 | GameType::OpenMW => "Morrowind.esm", - GameType::TES4 => "Oblivion.esm", - GameType::TES5 | GameType::TES5SE | GameType::TES5VR => "Skyrim.esm", - GameType::FO3 => "Fallout3.esm", - GameType::FONV => "FalloutNV.esm", - GameType::FO4 | GameType::FO4VR => "Fallout4.esm", + GameType::Morrowind | GameType::OpenMW => "Morrowind.esm", + GameType::Oblivion => "Oblivion.esm", + GameType::Skyrim | GameType::SkyrimSE | GameType::SkyrimVR => "Skyrim.esm", + GameType::Fallout3 => "Fallout3.esm", + GameType::FalloutNV => "FalloutNV.esm", + GameType::Fallout4 | GameType::Fallout4VR => "Fallout4.esm", GameType::Starfield => "Starfield.esm", } } @@ -62,14 +64,18 @@ fn touch(file_path: &Path) { fn supports_light_plugins(game_type: GameType) -> bool { matches!( game_type, - GameType::TES5SE | GameType::TES5VR | GameType::FO4 | GameType::FO4VR | GameType::Starfield + GameType::SkyrimSE + | GameType::SkyrimVR + | GameType::Fallout4 + | GameType::Fallout4VR + | GameType::Starfield ) } fn is_load_order_timestamp_based(game_type: GameType) -> bool { matches!( game_type, - GameType::TES3 | GameType::TES4 | GameType::FO3 | GameType::FONV + GameType::Morrowind | GameType::Oblivion | GameType::Fallout3 | GameType::FalloutNV ) } @@ -119,7 +125,7 @@ fn set_load_order( use std::io::Write; match game_type { - GameType::TES3 | GameType::OpenMW => {} + GameType::Morrowind | GameType::OpenMW => {} _ => { let mut file = File::create(local_path.join("Plugins.txt")).unwrap(); for (plugin, is_active) in load_order { @@ -149,7 +155,7 @@ fn set_load_order( mod_time += Duration::from_secs(60); } - } else if game_type == GameType::TES5 { + } else if game_type == GameType::Skyrim { let mut file = File::create(local_path.join("loadorder.txt")).unwrap(); for (plugin, _) in load_order { writeln!(file, "{plugin}").unwrap(); @@ -160,7 +166,7 @@ fn set_load_order( fn data_path(game_type: GameType, game_path: &Path) -> PathBuf { match game_type { GameType::OpenMW => game_path.join("resources/vfs"), - GameType::TES3 => game_path.join("Data Files"), + GameType::Morrowind => game_path.join("Data Files"), _ => game_path.join("Data"), } } @@ -312,15 +318,15 @@ impl Fixture { #[rstest::rstest] pub fn all_game_types( #[values( - GameType::TES4, - GameType::TES5, - GameType::FO3, - GameType::FONV, - GameType::FO4, - GameType::TES5SE, - GameType::FO4VR, - GameType::TES5VR, - GameType::TES3, + GameType::Oblivion, + GameType::Skyrim, + GameType::Fallout3, + GameType::FalloutNV, + GameType::Fallout4, + GameType::SkyrimSE, + GameType::Fallout4VR, + GameType::SkyrimVR, + GameType::Morrowind, GameType::Starfield, GameType::OpenMW )] From 0c14df6fbde01bf646b1bf7fc4191775f308c759 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 23 Apr 2025 18:02:29 +0100 Subject: [PATCH 108/206] Use PascalCase for EdgeType variants in CXX wrapper --- cxx/src/api/convert.cpp | 26 ++++++++++----------- cxx/src/database.rs | 52 ++++++++++++++++++++--------------------- cxx/src/lib.rs | 26 ++++++++++----------- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/cxx/src/api/convert.cpp b/cxx/src/api/convert.cpp index 2cd36fe9..d53a0c3c 100644 --- a/cxx/src/api/convert.cpp +++ b/cxx/src/api/convert.cpp @@ -95,31 +95,31 @@ loot::PluginMetadata convert(const loot::rust::PluginMetadata& metadata) { std::optional convert(uint8_t edgeType) { switch (edgeType) { - case static_cast(loot::rust::EdgeType::hardcoded): + case static_cast(loot::rust::EdgeType::Hardcoded): return loot::EdgeType::hardcoded; - case static_cast(loot::rust::EdgeType::masterFlag): + case static_cast(loot::rust::EdgeType::MasterFlag): return loot::EdgeType::masterFlag; - case static_cast(loot::rust::EdgeType::master): + case static_cast(loot::rust::EdgeType::Master): return loot::EdgeType::master; - case static_cast(loot::rust::EdgeType::masterlistRequirement): + case static_cast(loot::rust::EdgeType::MasterlistRequirement): return loot::EdgeType::masterlistRequirement; - case static_cast(loot::rust::EdgeType::userRequirement): + case static_cast(loot::rust::EdgeType::UserRequirement): return loot::EdgeType::userRequirement; - case static_cast(loot::rust::EdgeType::masterlistLoadAfter): + case static_cast(loot::rust::EdgeType::MasterlistLoadAfter): return loot::EdgeType::masterlistLoadAfter; - case static_cast(loot::rust::EdgeType::userLoadAfter): + case static_cast(loot::rust::EdgeType::UserLoadAfter): return loot::EdgeType::userLoadAfter; - case static_cast(loot::rust::EdgeType::masterlistGroup): + case static_cast(loot::rust::EdgeType::MasterlistGroup): return loot::EdgeType::masterlistGroup; - case static_cast(loot::rust::EdgeType::userGroup): + case static_cast(loot::rust::EdgeType::UserGroup): return loot::EdgeType::userGroup; - case static_cast(loot::rust::EdgeType::recordOverlap): + case static_cast(loot::rust::EdgeType::RecordOverlap): return loot::EdgeType::recordOverlap; - case static_cast(loot::rust::EdgeType::assetOverlap): + case static_cast(loot::rust::EdgeType::AssetOverlap): return loot::EdgeType::assetOverlap; - case static_cast(loot::rust::EdgeType::tieBreak): + case static_cast(loot::rust::EdgeType::TieBreak): return loot::EdgeType::tieBreak; - case static_cast(loot::rust::EdgeType::blueprintMaster): + case static_cast(loot::rust::EdgeType::BlueprintMaster): return loot::EdgeType::blueprintMaster; default: return std::nullopt; diff --git a/cxx/src/database.rs b/cxx/src/database.rs index 3b17f2b7..3da75bda 100644 --- a/cxx/src/database.rs +++ b/cxx/src/database.rs @@ -260,19 +260,19 @@ impl TryFrom for EdgeType { fn try_from(value: libloot::EdgeType) -> Result { match value { - libloot::EdgeType::Hardcoded => Ok(EdgeType::hardcoded), - libloot::EdgeType::MasterFlag => Ok(EdgeType::masterFlag), - libloot::EdgeType::Master => Ok(EdgeType::master), - libloot::EdgeType::MasterlistRequirement => Ok(EdgeType::masterlistRequirement), - libloot::EdgeType::UserRequirement => Ok(EdgeType::userRequirement), - libloot::EdgeType::MasterlistLoadAfter => Ok(EdgeType::masterlistLoadAfter), - libloot::EdgeType::UserLoadAfter => Ok(EdgeType::userLoadAfter), - libloot::EdgeType::MasterlistGroup => Ok(EdgeType::masterlistGroup), - libloot::EdgeType::UserGroup => Ok(EdgeType::userGroup), - libloot::EdgeType::RecordOverlap => Ok(EdgeType::recordOverlap), - libloot::EdgeType::AssetOverlap => Ok(EdgeType::assetOverlap), - libloot::EdgeType::TieBreak => Ok(EdgeType::tieBreak), - libloot::EdgeType::BlueprintMaster => Ok(EdgeType::blueprintMaster), + libloot::EdgeType::Hardcoded => Ok(EdgeType::Hardcoded), + libloot::EdgeType::MasterFlag => Ok(EdgeType::MasterFlag), + libloot::EdgeType::Master => Ok(EdgeType::Master), + libloot::EdgeType::MasterlistRequirement => Ok(EdgeType::MasterlistRequirement), + libloot::EdgeType::UserRequirement => Ok(EdgeType::UserRequirement), + libloot::EdgeType::MasterlistLoadAfter => Ok(EdgeType::MasterlistLoadAfter), + libloot::EdgeType::UserLoadAfter => Ok(EdgeType::UserLoadAfter), + libloot::EdgeType::MasterlistGroup => Ok(EdgeType::MasterlistGroup), + libloot::EdgeType::UserGroup => Ok(EdgeType::UserGroup), + libloot::EdgeType::RecordOverlap => Ok(EdgeType::RecordOverlap), + libloot::EdgeType::AssetOverlap => Ok(EdgeType::AssetOverlap), + libloot::EdgeType::TieBreak => Ok(EdgeType::TieBreak), + libloot::EdgeType::BlueprintMaster => Ok(EdgeType::BlueprintMaster), _ => Err(UnsupportedEnumValueError), } } @@ -283,19 +283,19 @@ impl TryFrom for libloot::EdgeType { fn try_from(value: EdgeType) -> Result { match value { - EdgeType::hardcoded => Ok(libloot::EdgeType::Hardcoded), - EdgeType::masterFlag => Ok(libloot::EdgeType::MasterFlag), - EdgeType::master => Ok(libloot::EdgeType::Master), - EdgeType::masterlistRequirement => Ok(libloot::EdgeType::MasterlistRequirement), - EdgeType::userRequirement => Ok(libloot::EdgeType::UserRequirement), - EdgeType::masterlistLoadAfter => Ok(libloot::EdgeType::MasterlistLoadAfter), - EdgeType::userLoadAfter => Ok(libloot::EdgeType::UserLoadAfter), - EdgeType::masterlistGroup => Ok(libloot::EdgeType::MasterlistGroup), - EdgeType::userGroup => Ok(libloot::EdgeType::UserGroup), - EdgeType::recordOverlap => Ok(libloot::EdgeType::RecordOverlap), - EdgeType::assetOverlap => Ok(libloot::EdgeType::AssetOverlap), - EdgeType::tieBreak => Ok(libloot::EdgeType::TieBreak), - EdgeType::blueprintMaster => Ok(libloot::EdgeType::BlueprintMaster), + EdgeType::Hardcoded => Ok(libloot::EdgeType::Hardcoded), + EdgeType::MasterFlag => Ok(libloot::EdgeType::MasterFlag), + EdgeType::Master => Ok(libloot::EdgeType::Master), + EdgeType::MasterlistRequirement => Ok(libloot::EdgeType::MasterlistRequirement), + EdgeType::UserRequirement => Ok(libloot::EdgeType::UserRequirement), + EdgeType::MasterlistLoadAfter => Ok(libloot::EdgeType::MasterlistLoadAfter), + EdgeType::UserLoadAfter => Ok(libloot::EdgeType::UserLoadAfter), + EdgeType::MasterlistGroup => Ok(libloot::EdgeType::MasterlistGroup), + EdgeType::UserGroup => Ok(libloot::EdgeType::UserGroup), + EdgeType::RecordOverlap => Ok(libloot::EdgeType::RecordOverlap), + EdgeType::AssetOverlap => Ok(libloot::EdgeType::AssetOverlap), + EdgeType::TieBreak => Ok(libloot::EdgeType::TieBreak), + EdgeType::BlueprintMaster => Ok(libloot::EdgeType::BlueprintMaster), _ => Err(UnsupportedEnumValueError), } } diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index 27e64ba0..7a72ee4b 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -235,19 +235,19 @@ mod ffi { } pub enum EdgeType { - hardcoded, - masterFlag, - master, - masterlistRequirement, - userRequirement, - masterlistLoadAfter, - userLoadAfter, - masterlistGroup, - userGroup, - recordOverlap, - assetOverlap, - tieBreak, - blueprintMaster, + Hardcoded, + MasterFlag, + Master, + MasterlistRequirement, + UserRequirement, + MasterlistLoadAfter, + UserLoadAfter, + MasterlistGroup, + UserGroup, + RecordOverlap, + AssetOverlap, + TieBreak, + BlueprintMaster, } pub enum LogLevel { From 2ef0cc8083dbc5640d5abfc174964288634de642 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 23 Apr 2025 18:02:39 +0100 Subject: [PATCH 109/206] Use PascalCase for MessageType variants in CXX wrapper --- cxx/src/api/convert.cpp | 12 ++++++------ cxx/src/lib.rs | 6 +++--- cxx/src/metadata.rs | 12 ++++++------ cxx/src/tests/api/internals/main.cpp | 6 +++--- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cxx/src/api/convert.cpp b/cxx/src/api/convert.cpp index d53a0c3c..f2eeae7d 100644 --- a/cxx/src/api/convert.cpp +++ b/cxx/src/api/convert.cpp @@ -33,11 +33,11 @@ loot::File convert(const loot::rust::File& file) { loot::MessageType convert(loot::rust::MessageType messageType) { switch (messageType) { - case loot::rust::MessageType::say: + case loot::rust::MessageType::Say: return loot::MessageType::say; - case loot::rust::MessageType::warn: + case loot::rust::MessageType::Warn: return loot::MessageType::warn; - case loot::rust::MessageType::error: + case loot::rust::MessageType::Error: return loot::MessageType::error; default: throw std::logic_error("Unsupported MessageType value"); @@ -182,11 +182,11 @@ loot::Vertex convert(const loot::rust::Vertex& vertex) { loot::rust::MessageType convert(loot::MessageType messageType) { switch (messageType) { case loot::MessageType::say: - return loot::rust::MessageType::say; + return loot::rust::MessageType::Say; case loot::MessageType::warn: - return loot::rust::MessageType::warn; + return loot::rust::MessageType::Warn; case loot::MessageType::error: - return loot::rust::MessageType::error; + return loot::rust::MessageType::Error; default: throw std::logic_error("Unsupported MessageType value"); } diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index 7a72ee4b..955cf93c 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -224,9 +224,9 @@ mod ffi { } pub enum MessageType { - say, - warn, - error, + Say, + Warn, + Error, } pub enum TagSuggestion { diff --git a/cxx/src/metadata.rs b/cxx/src/metadata.rs index 4501d3ac..6758bb94 100644 --- a/cxx/src/metadata.rs +++ b/cxx/src/metadata.rs @@ -44,9 +44,9 @@ unsafe trait TransparentWrapper { impl From for MessageType { fn from(value: libloot::metadata::MessageType) -> Self { match value { - libloot::metadata::MessageType::Say => MessageType::say, - libloot::metadata::MessageType::Warn => MessageType::warn, - libloot::metadata::MessageType::Error => MessageType::error, + libloot::metadata::MessageType::Say => MessageType::Say, + libloot::metadata::MessageType::Warn => MessageType::Warn, + libloot::metadata::MessageType::Error => MessageType::Error, } } } @@ -56,9 +56,9 @@ impl TryFrom for libloot::metadata::MessageType { fn try_from(value: MessageType) -> Result { match value { - MessageType::say => Ok(libloot::metadata::MessageType::Say), - MessageType::warn => Ok(libloot::metadata::MessageType::Warn), - MessageType::error => Ok(libloot::metadata::MessageType::Error), + MessageType::Say => Ok(libloot::metadata::MessageType::Say), + MessageType::Warn => Ok(libloot::metadata::MessageType::Warn), + MessageType::Error => Ok(libloot::metadata::MessageType::Error), _ => Err(UnsupportedEnumValueError), } } diff --git a/cxx/src/tests/api/internals/main.cpp b/cxx/src/tests/api/internals/main.cpp index b7117d6b..dbcf9c75 100644 --- a/cxx/src/tests/api/internals/main.cpp +++ b/cxx/src/tests/api/internals/main.cpp @@ -47,20 +47,20 @@ TEST(Message, creation) { content->set_language( ::rust::String(std::string(message_content_default_language()))); - auto message = new_message(MessageType::say, "message2"); + auto message = new_message(MessageType::Say, "message2"); message->set_condition("invalid condition"); std::vector<::rust::Box> contents; contents.push_back(std::move(content)); auto multi_message = multilingual_message( - MessageType::say, + MessageType::Say, ::rust::Slice>(contents)); multi_message->set_condition("invalid condition"); EXPECT_EQ(multi_message->content()[0].text(), "a message"); EXPECT_EQ(multi_message->content()[0].language(), "en"); - EXPECT_EQ(MessageType::say, multi_message->message_type()); + EXPECT_EQ(MessageType::Say, multi_message->message_type()); EXPECT_EQ(multi_message->condition(), "invalid condition"); } } From a08d53ea521c9854206601b7bec09de51a932da5 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 24 Apr 2025 19:14:12 +0100 Subject: [PATCH 110/206] Make most metadata structs immutable again --- cxx/src/api/convert.cpp | 81 ++++--------- cxx/src/lib.rs | 61 +++++----- cxx/src/metadata.rs | 175 +++++++++++++++++---------- cxx/src/tests/api/internals/main.cpp | 13 +- nodejs/src/metadata.rs | 102 +++------------- pyo3/src/metadata.rs | 166 ++++--------------------- src/metadata/file.rs | 40 +----- src/metadata/group.rs | 16 +-- src/metadata/location.rs | 8 +- src/metadata/message.rs | 16 +-- src/metadata/plugin_cleaning_data.rs | 39 +----- src/metadata/tag.rs | 8 +- 12 files changed, 223 insertions(+), 502 deletions(-) diff --git a/cxx/src/api/convert.cpp b/cxx/src/api/convert.cpp index f2eeae7d..ee48b0b2 100644 --- a/cxx/src/api/convert.cpp +++ b/cxx/src/api/convert.cpp @@ -147,36 +147,21 @@ loot::Vertex convert(const loot::rust::Vertex& vertex) { } ::rust::Box convert(const loot::Group& group) { - auto output = loot::rust::new_group(group.GetName()); - output->set_after_groups(convert(group.GetAfterGroups())); - output->set_description(group.GetDescription()); - - return output; + return loot::rust::new_group( + group.GetName(), group.GetDescription(), convert(group.GetAfterGroups())); } ::rust::Box convert(const loot::File& file) { - auto output = loot::rust::new_file(std::string(file.GetName())); - - if (!file.GetDisplayName().empty()) { - output->set_display_name(file.GetDisplayName()); - } - try { - output->set_detail( - ::rust::Slice(convert(file.GetDetail()))); + return loot::rust::new_file( + std::string(file.GetName()), + file.GetDisplayName(), + file.GetCondition(), + ::rust::Slice(convert(file.GetDetail())), + file.GetConstraint()); } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); } - - if (file.IsConditional()) { - output->set_condition(file.GetCondition()); - } - - if (!file.GetConstraint().empty()) { - output->set_constraint(file.GetConstraint()); - } - - return output; } loot::rust::MessageType convert(loot::MessageType messageType) { @@ -194,24 +179,17 @@ loot::rust::MessageType convert(loot::MessageType messageType) { ::rust::Box convert( const loot::MessageContent& content) { - auto output = loot::rust::new_message_content(content.GetText()); - output->set_language(content.GetLanguage()); - - return output; + return loot::rust::new_message_content(content.GetText(), + content.GetLanguage()); } ::rust::Box convert(const loot::Message& message) { try { - auto output = loot::rust::multilingual_message( + return loot::rust::multilingual_message( convert(message.GetType()), ::rust::Slice( - convert(message.GetContent()))); - - if (message.IsConditional()) { - output->set_condition(message.GetCondition()); - } - - return output; + convert(message.GetContent())), + message.GetCondition()); } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); } @@ -222,13 +200,7 @@ loot::rust::MessageType convert(loot::MessageType messageType) { const auto suggestion = tag.IsAddition() ? loot::rust::TagSuggestion::Addition : loot::rust::TagSuggestion::Removal; - auto output = loot::rust::new_tag(tag.GetName(), suggestion); - - if (tag.IsConditional()) { - output->set_condition(tag.GetCondition()); - } - - return output; + return loot::rust::new_tag(tag.GetName(), suggestion, tag.GetCondition()); } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); } @@ -236,30 +208,21 @@ loot::rust::MessageType convert(loot::MessageType messageType) { ::rust::Box convert( const loot::PluginCleaningData& data) { - auto output = loot::rust::new_plugin_cleaning_data(data.GetCRC(), - data.GetCleaningUtility()); try { - output->set_detail( - ::rust::Slice(convert(data.GetDetail()))); + return loot::rust::new_plugin_cleaning_data( + data.GetCRC(), + data.GetCleaningUtility(), + ::rust::Slice(convert(data.GetDetail())), + data.GetITMCount(), + data.GetDeletedReferenceCount(), + data.GetDeletedNavmeshCount()); } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); } - - output->set_itm_count(data.GetITMCount()); - output->set_deleted_reference_count(data.GetDeletedReferenceCount()); - output->set_deleted_navmesh_count(data.GetDeletedNavmeshCount()); - - return output; } ::rust::Box convert(const loot::Location& location) { - auto output = loot::rust::new_location(location.GetURL()); - - if (!location.GetName().empty()) { - output->set_name(location.GetName()); - } - - return output; + return loot::rust::new_location(location.GetURL(), location.GetName()); } ::rust::Box convert( diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index 955cf93c..35d52565 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -383,11 +383,16 @@ mod ffi { extern "Rust" { type Message; - pub fn new_message(message_type: MessageType, content: String) -> Result>; + pub fn new_message( + message_type: MessageType, + content: String, + condition: &str, + ) -> Result>; pub fn multilingual_message( message_type: MessageType, contents: &[Box], + condition: &str, ) -> Result>; pub fn message_type(&self) -> MessageType; @@ -396,8 +401,6 @@ mod ffi { pub fn condition(&self) -> &str; - pub fn set_condition(&mut self, condition: String); - pub fn boxed_clone(&self) -> Box; } @@ -406,21 +409,19 @@ mod ffi { pub fn message_content_default_language() -> &'static str; - pub fn new_message_content(text: String) -> Box; + pub fn new_message_content(text: String, language: &str) -> Box; pub fn text(&self) -> &str; pub fn language(&self) -> &str; - pub fn set_language(&mut self, language: String); - pub fn boxed_clone(&self) -> Box; } extern "Rust" { type Group; - pub fn new_group(name: String) -> Box; + pub fn new_group(name: String, description: &str, after_groups: Vec) -> Box; pub fn group_default_name() -> &'static str; @@ -430,11 +431,7 @@ mod ffi { pub fn description(&self) -> &str; - pub fn set_description(&mut self, description: String); - pub fn after_groups(&self) -> &[String]; - - pub fn set_after_groups(&mut self, groups: Vec); } extern "Rust" { @@ -582,25 +579,31 @@ mod ffi { extern "Rust" { type File; - pub fn new_file(name: String) -> Box; + pub fn new_file( + name: String, + display_name: &str, + condition: &str, + detail: &[Box], + constraint: &str, + ) -> Result>; pub fn filename(&self) -> &Filename; pub fn display_name(&self) -> &str; - pub fn set_display_name(&mut self, display_name: String); + // pub fn set_display_name(&mut self, display_name: String); pub fn detail(&self) -> &[MessageContent]; - pub fn set_detail(&mut self, detail: &[Box]) -> Result<()>; + // pub fn set_detail(&mut self, detail: &[Box]) -> Result<()>; pub fn condition(&self) -> &str; - pub fn set_condition(&mut self, condition: String); + // pub fn set_condition(&mut self, condition: String); pub fn constraint(&self) -> &str; - pub fn set_constraint(&mut self, constraint: String); + // pub fn set_constraint(&mut self, constraint: String); pub fn boxed_clone(&self) -> Box; } @@ -618,7 +621,11 @@ mod ffi { extern "Rust" { type Tag; - pub fn new_tag(name: String, suggestion: TagSuggestion) -> Result>; + pub fn new_tag( + name: String, + suggestion: TagSuggestion, + condition: &str, + ) -> Result>; pub fn name(&self) -> &str; @@ -626,8 +633,6 @@ mod ffi { pub fn condition(&self) -> &str; - pub fn set_condition(&mut self, condition: String); - pub fn boxed_clone(&self) -> Box; } @@ -637,42 +642,36 @@ mod ffi { pub fn new_plugin_cleaning_data( crc: u32, cleaning_utility: String, - ) -> Box; + detail: &[Box], + itm_count: u32, + deleted_reference_count: u32, + deleted_navmesh_count: u32, + ) -> Result>; pub fn crc(&self) -> u32; pub fn itm_count(&self) -> u32; - pub fn set_itm_count(&mut self, count: u32); - pub fn deleted_reference_count(&self) -> u32; - pub fn set_deleted_reference_count(&mut self, count: u32); - pub fn deleted_navmesh_count(&self) -> u32; - pub fn set_deleted_navmesh_count(&mut self, count: u32); - pub fn cleaning_utility(&self) -> &str; pub fn detail(&self) -> &[MessageContent]; - pub fn set_detail(&mut self, detail: &[Box]) -> Result<()>; - pub fn boxed_clone(&self) -> Box; } extern "Rust" { type Location; - pub fn new_location(url: String) -> Box; + pub fn new_location(url: String, name: &str) -> Box; pub fn url(&self) -> &str; pub fn name(&self) -> &str; - pub fn set_name(&mut self, name: String); - pub fn boxed_clone(&self) -> Box; } } diff --git a/cxx/src/metadata.rs b/cxx/src/metadata.rs index 6758bb94..e2974289 100644 --- a/cxx/src/metadata.rs +++ b/cxx/src/metadata.rs @@ -68,8 +68,14 @@ impl TryFrom for libloot::metadata::MessageType { #[repr(transparent)] pub struct MessageContent(libloot::metadata::MessageContent); -pub fn new_message_content(text: String) -> Box { - Box::new(MessageContent(libloot::metadata::MessageContent::new(text))) +pub fn new_message_content(text: String, language: &str) -> Box { + let mut content = libloot::metadata::MessageContent::new(text); + + if !language.is_empty() { + content = content.with_language(language.to_owned()); + } + + Box::new(MessageContent(content)) } pub fn message_content_default_language() -> &'static str { @@ -86,8 +92,6 @@ impl MessageContent { pub fn text(&self) -> &str; pub fn language(&self) -> &str; - - pub fn set_language(&mut self, language: String); } } } @@ -128,22 +132,31 @@ pub struct Message(libloot::metadata::Message); pub fn new_message( message_type: MessageType, content: String, + condition: &str, ) -> Result, VerboseError> { - Ok(Box::new(Message(libloot::metadata::Message::new( - message_type.try_into()?, - content, - )))) + let mut message = libloot::metadata::Message::new(message_type.try_into()?, content); + + if !condition.is_empty() { + message = message.with_condition(condition.to_owned()); + } + + Ok(Box::new(Message(message))) } pub fn multilingual_message( message_type: MessageType, contents: &[Box], + condition: &str, ) -> Result, VerboseError> { let contents = to_vec_of_unwrapped(contents); - Ok(Box::new(Message(libloot::metadata::Message::multilingual( - message_type.try_into()?, - contents, - )?))) + + let mut message = libloot::metadata::Message::multilingual(message_type.try_into()?, contents)?; + + if !condition.is_empty() { + message = message.with_condition(condition.to_owned()); + } + + Ok(Box::new(Message(message))) } impl Message { @@ -163,8 +176,6 @@ impl Message { to self.0 { #[into] pub fn message_type(&self) -> MessageType; - - pub fn set_condition(&mut self, condition: String); } } } @@ -190,8 +201,18 @@ impl From> for libloot::metadata::Message { #[repr(transparent)] pub struct Group(libloot::metadata::Group); -pub fn new_group(name: String) -> Box { - Box::new(Group(libloot::metadata::Group::new(name))) +pub fn new_group(name: String, description: &str, after_groups: Vec) -> Box { + let mut group = libloot::metadata::Group::new(name); + + if !description.is_empty() { + group = group.with_description(description.to_owned()); + } + + if !after_groups.is_empty() { + group = group.with_after_groups(after_groups); + } + + Box::new(Group(group)) } pub fn group_default_name() -> &'static str { @@ -211,11 +232,7 @@ impl Group { to self.0 { pub fn name(&self) -> &str; - pub fn set_description(&mut self, description: String); - pub fn after_groups(&self) -> &[String]; - - pub fn set_after_groups(&mut self, groups: Vec); } } } @@ -374,8 +391,32 @@ impl From> for Optional { #[repr(transparent)] pub struct File(libloot::metadata::File); -pub fn new_file(name: String) -> Box { - Box::new(File(libloot::metadata::File::new(name))) +pub fn new_file( + name: String, + display_name: &str, + condition: &str, + detail: &[Box], + constraint: &str, +) -> Result, VerboseError> { + let mut file = libloot::metadata::File::new(name); + + if !display_name.is_empty() { + file = file.with_display_name(display_name.to_owned()); + } + + if !condition.is_empty() { + file = file.with_condition(condition.to_owned()); + } + + if !detail.is_empty() { + file = file.with_detail(to_vec_of_unwrapped(detail))?; + } + + if !constraint.is_empty() { + file = file.with_constraint(constraint.to_owned()); + } + + Ok(Box::new(File(file))) } impl File { @@ -387,34 +428,34 @@ impl File { self.0.display_name().unwrap_or("") } - pub fn set_display_name(&mut self, display_name: String) { - self.0.set_display_name(display_name); - } + // pub fn set_display_name(&mut self, display_name: String) { + // self.0.set_display_name(display_name); + // } pub fn detail(&self) -> &[MessageContent] { MessageContent::wrap_slice(self.0.detail()) } - pub fn set_detail(&mut self, detail: &[Box]) -> Result<(), VerboseError> { - self.0.set_detail(to_vec_of_unwrapped(detail))?; - Ok(()) - } + // pub fn set_detail(&mut self, detail: &[Box]) -> Result<(), VerboseError> { + // self.0.set_detail(to_vec_of_unwrapped(detail))?; + // Ok(()) + // } pub fn condition(&self) -> &str { self.0.condition().unwrap_or("") } - pub fn set_condition(&mut self, condition: String) { - self.0.set_condition(condition); - } + // pub fn set_condition(&mut self, condition: String) { + // self.0.set_condition(condition); + // } pub fn constraint(&self) -> &str { self.0.constraint().unwrap_or("") } - pub fn set_constraint(&mut self, constraint: String) { - self.0.set_constraint(constraint); - } + // pub fn set_constraint(&mut self, constraint: String) { + // self.0.set_constraint(constraint); + // } pub fn boxed_clone(&self) -> Box { Box::new(Self(self.0.clone())) @@ -461,11 +502,18 @@ unsafe impl TransparentWrapper for Filename { #[repr(transparent)] pub struct Tag(libloot::metadata::Tag); -pub fn new_tag(name: String, suggestion: TagSuggestion) -> Result, VerboseError> { - Ok(Box::new(Tag(libloot::metadata::Tag::new( - name, - suggestion.try_into()?, - )))) +pub fn new_tag( + name: String, + suggestion: TagSuggestion, + condition: &str, +) -> Result, VerboseError> { + let mut tag = libloot::metadata::Tag::new(name, suggestion.try_into()?); + + if !condition.is_empty() { + tag = tag.with_condition(condition.to_owned()); + } + + Ok(Box::new(Tag(tag))) } impl Tag { @@ -482,8 +530,6 @@ impl Tag { pub fn name(&self) -> &str; pub fn is_addition(&self) -> bool; - - pub fn set_condition(&mut self, condition: String); } } } @@ -515,10 +561,24 @@ impl TryFrom for libloot::metadata::TagSuggestion { #[repr(transparent)] pub struct PluginCleaningData(libloot::metadata::PluginCleaningData); -pub fn new_plugin_cleaning_data(crc: u32, cleaning_utility: String) -> Box { - Box::new(PluginCleaningData( - libloot::metadata::PluginCleaningData::new(crc, cleaning_utility), - )) +pub fn new_plugin_cleaning_data( + crc: u32, + cleaning_utility: String, + detail: &[Box], + itm_count: u32, + deleted_reference_count: u32, + deleted_navmesh_count: u32, +) -> Result, VerboseError> { + let mut data = libloot::metadata::PluginCleaningData::new(crc, cleaning_utility) + .with_itm_count(itm_count) + .with_deleted_reference_count(deleted_reference_count) + .with_deleted_navmesh_count(deleted_navmesh_count); + + if !detail.is_empty() { + data = data.with_detail(to_vec_of_unwrapped(detail))?; + } + + Ok(Box::new(PluginCleaningData(data))) } impl PluginCleaningData { @@ -526,11 +586,6 @@ impl PluginCleaningData { MessageContent::wrap_slice(self.0.detail()) } - pub fn set_detail(&mut self, detail: &[Box]) -> Result<(), VerboseError> { - self.0.set_detail(to_vec_of_unwrapped(detail))?; - Ok(()) - } - pub fn boxed_clone(&self) -> Box { Box::new(Self(self.0.clone())) } @@ -541,16 +596,10 @@ impl PluginCleaningData { pub fn itm_count(&self) -> u32; - pub fn set_itm_count(&mut self, count: u32); - pub fn deleted_reference_count(&self) -> u32; - pub fn set_deleted_reference_count(&mut self, count: u32); - pub fn deleted_navmesh_count(&self) -> u32; - pub fn set_deleted_navmesh_count(&mut self, count: u32); - pub fn cleaning_utility(&self) -> &str; } } @@ -571,8 +620,14 @@ impl From> for libloot::metadata::PluginCleaningData { #[repr(transparent)] pub struct Location(libloot::metadata::Location); -pub fn new_location(url: String) -> Box { - Box::new(Location(libloot::metadata::Location::new(url))) +pub fn new_location(url: String, name: &str) -> Box { + let mut location = libloot::metadata::Location::new(url); + + if !name.is_empty() { + location = location.with_name(name.to_owned()); + } + + Box::new(Location(location)) } impl Location { @@ -587,8 +642,6 @@ impl Location { delegate! { to self.0 { pub fn url(&self) -> &str; - - pub fn set_name(&mut self, name: String); } } } diff --git a/cxx/src/tests/api/internals/main.cpp b/cxx/src/tests/api/internals/main.cpp index dbcf9c75..4a09bb8a 100644 --- a/cxx/src/tests/api/internals/main.cpp +++ b/cxx/src/tests/api/internals/main.cpp @@ -4,7 +4,6 @@ #include "libloot-cxx/src/lib.rs.h" #include "rust/cxx.h" - #include "tests/api/internals/metadata/conditional_metadata_test.h" #include "tests/api/internals/metadata/file_test.h" #include "tests/api/internals/metadata/group_test.h" @@ -43,20 +42,18 @@ TEST(new_game, shouldThrowIfGivenNonsense) { } TEST(Message, creation) { - auto content = new_message_content("a message"); - content->set_language( + auto content = new_message_content( + "a message", ::rust::String(std::string(message_content_default_language()))); - auto message = new_message(MessageType::Say, "message2"); - message->set_condition("invalid condition"); + auto message = new_message(MessageType::Say, "message2", "invalid condition"); std::vector<::rust::Box> contents; contents.push_back(std::move(content)); auto multi_message = multilingual_message( MessageType::Say, - ::rust::Slice>(contents)); - - multi_message->set_condition("invalid condition"); + ::rust::Slice>(contents), + "invalid condition"); EXPECT_EQ(multi_message->content()[0].text(), "a message"); EXPECT_EQ(multi_message->content()[0].language(), "en"); diff --git a/nodejs/src/metadata.rs b/nodejs/src/metadata.rs index 8ec7f881..2b113be4 100644 --- a/nodejs/src/metadata.rs +++ b/nodejs/src/metadata.rs @@ -24,11 +24,11 @@ impl Group { let mut group = libloot::metadata::Group::new(name); if let Some(description) = description { - group.set_description(description); + group = group.with_description(description); } if let Some(after_groups) = after_groups { - group.set_after_groups(after_groups); + group = group.with_after_groups(after_groups); } Self(group) @@ -44,20 +44,10 @@ impl Group { self.0.description() } - #[napi(setter)] - pub fn set_description(&mut self, description: String) { - self.0.set_description(description); - } - #[napi(getter)] pub fn after_groups(&self) -> Vec { self.0.after_groups().to_vec() } - - #[napi(setter)] - pub fn set_after_groups(&mut self, after_groups: Vec) { - self.0.set_after_groups(after_groups); - } } impl From for Group { @@ -89,7 +79,7 @@ impl MessageContent { let mut content = libloot::metadata::MessageContent::new(text); if let Some(language) = language { - content.set_language(language); + content = content.with_language(language); } Self(content) @@ -104,11 +94,6 @@ impl MessageContent { pub fn language(&self) -> &str { self.0.language() } - - #[napi(setter)] - pub fn set_language(&mut self, language: String) { - self.0.set_language(language); - } } impl From for MessageContent { @@ -184,7 +169,7 @@ impl Message { }; if let Some(condition) = condition { - message.set_condition(condition); + message = message.with_condition(condition); } Ok(Self(message)) @@ -204,11 +189,6 @@ impl Message { pub fn condition(&self) -> Option<&str> { self.0.condition() } - - #[napi(setter)] - pub fn set_condition(&mut self, condition: String) { - self.0.set_condition(condition); - } } impl From for Message { @@ -241,20 +221,20 @@ impl File { let mut file = libloot::metadata::File::new(name); if let Some(display_name) = display_name { - file.set_display_name(display_name); + file = file.with_display_name(display_name); } if let Some(detail) = detail { let detail = detail.into_iter().cloned().map(Into::into).collect(); - file.set_detail(detail)?; + file = file.with_detail(detail)?; } if let Some(condition) = condition { - file.set_condition(condition); + file = file.with_condition(condition); } if let Some(constraint) = constraint { - file.set_constraint(constraint); + file = file.with_constraint(constraint); } Ok(Self(file)) @@ -270,42 +250,20 @@ impl File { self.0.display_name() } - #[napi(setter)] - pub fn set_display_name(&mut self, description: String) { - self.0.set_display_name(description); - } - #[napi(getter)] pub fn detail(&self) -> Vec { self.0.detail().iter().cloned().map(Into::into).collect() } - #[napi(setter)] - pub fn set_detail(&mut self, detail: Vec<&MessageContent>) -> Result<(), VerboseError> { - let detail = detail.into_iter().cloned().map(Into::into).collect(); - self.0.set_detail(detail)?; - Ok(()) - } - #[napi(getter)] pub fn condition(&self) -> Option<&str> { self.0.condition() } - #[napi(setter)] - pub fn set_condition(&mut self, condition: String) { - self.0.set_condition(condition); - } - #[napi(getter)] pub fn constraint(&self) -> Option<&str> { self.0.constraint() } - - #[napi(setter)] - pub fn set_constraint(&mut self, constraint: String) { - self.0.set_constraint(constraint); - } } impl From for File { @@ -363,20 +321,20 @@ impl PluginCleaningData { let mut data = libloot::metadata::PluginCleaningData::new(crc, cleaning_utility); if let Some(count) = itm_count { - data.set_itm_count(count); + data = data.with_itm_count(count); } if let Some(count) = deleted_reference_count { - data.set_deleted_reference_count(count); + data = data.with_deleted_reference_count(count); } if let Some(count) = deleted_navmesh_count { - data.set_deleted_navmesh_count(count); + data = data.with_deleted_navmesh_count(count); } if let Some(detail) = detail { let detail = detail.into_iter().cloned().map(Into::into).collect(); - data.set_detail(detail)?; + data = data.with_detail(detail)?; } Ok(Self(data)) @@ -392,31 +350,16 @@ impl PluginCleaningData { self.0.itm_count() } - #[napi(setter)] - pub fn set_itm_count(&mut self, count: u32) { - self.0.set_itm_count(count); - } - #[napi(getter)] pub fn deleted_reference_count(&self) -> u32 { self.0.deleted_reference_count() } - #[napi(setter)] - pub fn set_deleted_reference_count(&mut self, count: u32) { - self.0.set_deleted_reference_count(count); - } - #[napi(getter)] pub fn deleted_navmesh_count(&self) -> u32 { self.0.deleted_navmesh_count() } - #[napi(setter)] - pub fn set_deleted_navmesh_count(&mut self, count: u32) { - self.0.set_deleted_navmesh_count(count); - } - #[napi(getter)] pub fn cleaning_utility(&self) -> &str { self.0.cleaning_utility() @@ -426,13 +369,6 @@ impl PluginCleaningData { pub fn detail(&self) -> Vec { self.0.detail().iter().cloned().map(Into::into).collect() } - - #[napi(setter)] - pub fn set_detail(&mut self, detail: Vec<&MessageContent>) -> Result<(), VerboseError> { - let detail = detail.into_iter().cloned().map(Into::into).collect(); - self.0.set_detail(detail)?; - Ok(()) - } } impl From for PluginCleaningData { @@ -475,7 +411,7 @@ impl Tag { let mut tag = libloot::metadata::Tag::new(name, suggestion.into()); if let Some(condition) = condition { - tag.set_condition(condition); + tag = tag.with_condition(condition); } Self(tag) @@ -495,11 +431,6 @@ impl Tag { pub fn condition(&self) -> Option<&str> { self.0.condition() } - - #[napi(setter)] - pub fn set_condition(&mut self, condition: String) { - self.0.set_condition(condition); - } } impl From for Tag { @@ -526,7 +457,7 @@ impl Location { let mut location = libloot::metadata::Location::new(url); if let Some(name) = name { - location.set_name(name); + location = location.with_name(name); } Self(location) @@ -541,11 +472,6 @@ impl Location { pub fn name(&self) -> Option<&str> { self.0.name() } - - #[napi(setter)] - pub fn set_name(&mut self, name: String) { - self.0.set_name(name); - } } impl From for Location { diff --git a/pyo3/src/metadata.rs b/pyo3/src/metadata.rs index c5b2539e..42b310b0 100644 --- a/pyo3/src/metadata.rs +++ b/pyo3/src/metadata.rs @@ -10,7 +10,7 @@ use crate::error::VerboseError; pub(crate) const NONE_REPR: &str = "None"; -#[pyclass(eq, ord, str = "{0:?}")] +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] #[repr(transparent)] #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct Group(libloot::metadata::Group); @@ -28,11 +28,11 @@ impl Group { let mut group = libloot::metadata::Group::new(name); if let Some(description) = description { - group.set_description(description); + group = group.with_description(description); } if let Some(after_groups) = after_groups { - group.set_after_groups(after_groups); + group = group.with_after_groups(after_groups); } Self(group) @@ -48,21 +48,11 @@ impl Group { self.0.description() } - #[setter] - fn set_description(&mut self, description: String) { - self.0.set_description(description); - } - #[getter] fn after_groups(&self) -> &[String] { self.0.after_groups() } - #[setter] - fn set_after_groups(&mut self, after_groups: Vec) { - self.0.set_after_groups(after_groups); - } - fn __repr__(slf: &Bound<'_, Self>) -> PyResult { let class_name = slf.get_type().qualname()?; let inner = &slf.borrow().0; @@ -74,12 +64,6 @@ impl Group { inner.after_groups().join(",") )) } - - fn __hash__(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - self.0.hash(&mut hasher); - hasher.finish() - } } impl From for Group { @@ -94,7 +78,7 @@ impl From for libloot::metadata::Group { } } -#[pyclass(eq, ord, str = "{0:?}")] +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] #[repr(transparent)] #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct MessageContent(libloot::metadata::MessageContent); @@ -112,7 +96,7 @@ impl MessageContent { let mut content = libloot::metadata::MessageContent::new(text); if let Some(language) = language { - content.set_language(language); + content = content.with_language(language); } Self(content) @@ -128,11 +112,6 @@ impl MessageContent { self.0.language() } - #[setter] - fn set_language(&mut self, language: String) { - self.0.set_language(language); - } - fn __repr__(slf: &Bound<'_, Self>) -> PyResult { let class_name = slf.get_type().qualname()?; let inner = &slf.borrow().0; @@ -143,12 +122,6 @@ impl MessageContent { inner.language() )) } - - fn __hash__(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - self.0.hash(&mut hasher); - hasher.finish() - } } impl From for MessageContent { @@ -214,7 +187,7 @@ impl From for libloot::metadata::MessageType { } } -#[pyclass(eq, ord, str = "{0:?}")] +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] #[repr(transparent)] pub struct Message(libloot::metadata::Message); @@ -245,7 +218,7 @@ impl Message { }; if let Some(condition) = condition { - message.set_condition(condition); + message = message.with_condition(condition); } Ok(Self(message)) @@ -266,11 +239,6 @@ impl Message { self.0.condition() } - #[setter] - fn set_condition(&mut self, condition: String) { - self.0.set_condition(condition); - } - fn __repr__(slf: &Bound<'_, Self>) -> PyResult { let class_name = slf.get_type().qualname()?; let inner = &slf.borrow().0; @@ -282,12 +250,6 @@ impl Message { inner.condition().unwrap_or(NONE_REPR), )) } - - fn __hash__(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - self.0.hash(&mut hasher); - hasher.finish() - } } impl From for Message { @@ -302,7 +264,7 @@ impl From for libloot::metadata::Message { } } -#[pyclass(eq, ord, str = "{0:?}")] +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] #[repr(transparent)] pub struct File(libloot::metadata::File); @@ -321,20 +283,20 @@ impl File { let mut file = libloot::metadata::File::new(name); if let Some(display_name) = display_name { - file.set_display_name(display_name); + file = file.with_display_name(display_name); } if let Some(detail) = detail { let detail = detail.into_iter().map(Into::into).collect(); - file.set_detail(detail)?; + file = file.with_detail(detail)?; } if let Some(condition) = condition { - file.set_condition(condition); + file = file.with_condition(condition); } if let Some(constraint) = constraint { - file.set_constraint(constraint); + file = file.with_constraint(constraint); } Ok(Self(file)) @@ -350,43 +312,21 @@ impl File { self.0.display_name() } - #[setter] - fn set_display_name(&mut self, description: String) { - self.0.set_display_name(description); - } - #[getter] fn detail(&self) -> Vec { self.0.detail().iter().cloned().map(Into::into).collect() } - #[setter] - fn set_detail(&mut self, detail: Vec) -> Result<(), VerboseError> { - let detail = detail.into_iter().map(Into::into).collect(); - self.0.set_detail(detail)?; - Ok(()) - } - #[getter] fn condition(&self) -> Option<&str> { self.0.condition() } - #[setter] - fn set_condition(&mut self, condition: String) { - self.0.set_condition(condition); - } - #[getter] fn constraint(&self) -> Option<&str> { self.0.constraint() } - #[setter] - fn set_constraint(&mut self, constraint: String) { - self.0.set_constraint(constraint); - } - fn __repr__(slf: &Bound<'_, Self>) -> PyResult { let class_name = slf.get_type().qualname()?; let inner = &slf.borrow().0; @@ -400,12 +340,6 @@ impl File { inner.constraint().unwrap_or(NONE_REPR) )) } - - fn __hash__(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - self.0.hash(&mut hasher); - hasher.finish() - } } impl From for File { @@ -420,7 +354,7 @@ impl From for libloot::metadata::File { } } -#[pyclass(eq, ord, str = "{0:?}")] +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] #[repr(transparent)] pub struct Filename(libloot::metadata::Filename); @@ -441,12 +375,6 @@ impl Filename { let inner = &slf.borrow().0; Ok(format!("{}({})", class_name, inner.as_str(),)) } - - fn __hash__(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - self.0.hash(&mut hasher); - hasher.finish() - } } impl From for Filename { @@ -455,7 +383,7 @@ impl From for Filename { } } -#[pyclass(eq, ord, str = "{0:?}")] +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] #[repr(transparent)] pub struct PluginCleaningData(libloot::metadata::PluginCleaningData); @@ -475,20 +403,20 @@ impl PluginCleaningData { let mut data = libloot::metadata::PluginCleaningData::new(crc, cleaning_utility); if let Some(count) = itm_count { - data.set_itm_count(count); + data = data.with_itm_count(count); } if let Some(count) = deleted_reference_count { - data.set_deleted_reference_count(count); + data = data.with_deleted_reference_count(count); } if let Some(count) = deleted_navmesh_count { - data.set_deleted_navmesh_count(count); + data = data.with_deleted_navmesh_count(count); } if let Some(detail) = detail { let detail = detail.into_iter().map(Into::into).collect(); - data.set_detail(detail)?; + data = data.with_detail(detail)?; } Ok(Self(data)) @@ -504,31 +432,16 @@ impl PluginCleaningData { self.0.itm_count() } - #[setter] - fn set_itm_count(&mut self, count: u32) { - self.0.set_itm_count(count); - } - #[getter] fn deleted_reference_count(&self) -> u32 { self.0.deleted_reference_count() } - #[setter] - fn set_deleted_reference_count(&mut self, count: u32) { - self.0.set_deleted_reference_count(count); - } - #[getter] fn deleted_navmesh_count(&self) -> u32 { self.0.deleted_navmesh_count() } - #[setter] - fn set_deleted_navmesh_count(&mut self, count: u32) { - self.0.set_deleted_navmesh_count(count); - } - #[getter] fn cleaning_utility(&self) -> &str { self.0.cleaning_utility() @@ -539,13 +452,6 @@ impl PluginCleaningData { self.0.detail().iter().cloned().map(Into::into).collect() } - #[setter] - fn set_detail(&mut self, detail: Vec) -> Result<(), VerboseError> { - let detail = detail.into_iter().map(Into::into).collect(); - self.0.set_detail(detail)?; - Ok(()) - } - fn __repr__(slf: &Bound<'_, Self>) -> PyResult { let class_name = slf.get_type().qualname()?; let inner = &slf.borrow().0; @@ -560,12 +466,6 @@ impl PluginCleaningData { repr_message_contents(inner.detail()) )) } - - fn __hash__(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - self.0.hash(&mut hasher); - hasher.finish() - } } impl From for PluginCleaningData { @@ -598,7 +498,7 @@ impl TryFrom for libloot::metadata::TagSuggestion { } } -#[pyclass(eq, ord, str = "{0:?}")] +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] #[repr(transparent)] pub struct Tag(libloot::metadata::Tag); @@ -615,7 +515,7 @@ impl Tag { let mut tag = libloot::metadata::Tag::new(name, suggestion.try_into()?); if let Some(condition) = condition { - tag.set_condition(condition); + tag = tag.with_condition(condition); } Ok(Self(tag)) @@ -636,11 +536,6 @@ impl Tag { self.0.condition() } - #[setter] - fn set_condition(&mut self, condition: String) { - self.0.set_condition(condition); - } - fn __repr__(slf: &Bound<'_, Self>) -> PyResult { let class_name = slf.get_type().qualname()?; let inner = &slf.borrow().0; @@ -657,12 +552,6 @@ impl Tag { inner.condition().unwrap_or(NONE_REPR) )) } - - fn __hash__(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - self.0.hash(&mut hasher); - hasher.finish() - } } impl From for Tag { @@ -677,7 +566,7 @@ impl From for libloot::metadata::Tag { } } -#[pyclass(eq, ord, str = "{0:?}")] +#[pyclass(eq, ord, frozen, hash, str = "{0:?}")] #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] #[repr(transparent)] pub struct Location(libloot::metadata::Location); @@ -690,7 +579,7 @@ impl Location { let mut location = libloot::metadata::Location::new(url); if let Some(name) = name { - location.set_name(name); + location = location.with_name(name); } Self(location) @@ -706,11 +595,6 @@ impl Location { self.0.name() } - #[setter] - fn set_name(&mut self, name: String) { - self.0.set_name(name); - } - fn __repr__(slf: &Bound<'_, Self>) -> PyResult { let class_name = slf.get_type().qualname()?; let inner = &slf.borrow().0; @@ -721,12 +605,6 @@ impl Location { inner.name().unwrap_or(NONE_REPR) )) } - - fn __hash__(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - self.0.hash(&mut hasher); - hasher.finish() - } } impl From for Location { diff --git a/src/metadata/file.rs b/src/metadata/file.rs index 7ac5b102..ca1deec8 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -38,14 +38,14 @@ impl File { /// CommonMark. #[must_use] pub fn with_display_name(mut self, display_name: String) -> Self { - self.set_display_name(display_name); + self.display_name = Some(display_name.into_boxed_str()); self } /// Set the condition string. #[must_use] pub fn with_condition(mut self, condition: String) -> Self { - self.set_condition(condition); + self.condition = Some(condition.into_boxed_str()); self } @@ -56,14 +56,15 @@ impl File { mut self, detail: Vec, ) -> Result { - self.set_detail(detail)?; + validate_message_contents(&detail)?; + self.detail = detail.into_boxed_slice(); Ok(self) } /// Set the constraint string. #[must_use] pub fn with_constraint(mut self, constraint: String) -> Self { - self.set_constraint(constraint); + self.constraint = Some(constraint.into_boxed_str()); self } @@ -77,13 +78,6 @@ impl File { self.display_name.as_deref() } - /// Set the name to be displayed for the file in messages, formatted using - /// CommonMark. - pub fn set_display_name(&mut self, display_name: String) -> &mut Self { - self.display_name = Some(display_name.into_boxed_str()); - self - } - /// Get the detail message content of the file. /// /// If this file causes an error message to be displayed, the detail message @@ -93,39 +87,15 @@ impl File { &self.detail } - /// Set the detail message content, which may be appended to any messages - /// generated for this file. If multilingual, one language must be - /// [MessageContent::DEFAULT_LANGUAGE]. - pub fn set_detail( - &mut self, - detail: Vec, - ) -> Result<&mut Self, MultilingualMessageContentsError> { - validate_message_contents(&detail)?; - self.detail = detail.into_boxed_slice(); - Ok(self) - } - /// Get the condition string. pub fn condition(&self) -> Option<&str> { self.condition.as_deref() } - /// Set the condition string. - pub fn set_condition(&mut self, condition: String) -> &mut Self { - self.condition = Some(condition.into_boxed_str()); - self - } - /// Get the constraint string. pub fn constraint(&self) -> Option<&str> { self.constraint.as_deref() } - - /// Set the constraint string. - pub fn set_constraint(&mut self, constraint: String) -> &mut Self { - self.constraint = Some(constraint.into_boxed_str()); - self - } } /// Represents a case-insensitive filename. diff --git a/src/metadata/group.rs b/src/metadata/group.rs index 14615acd..f37dfc7c 100644 --- a/src/metadata/group.rs +++ b/src/metadata/group.rs @@ -29,14 +29,14 @@ impl Group { /// Set a description for the group. #[must_use] pub fn with_description(mut self, description: String) -> Self { - self.set_description(description); + self.description = Some(description.into_boxed_str()); self } /// Set the names of the groups that this group loads after. #[must_use] pub fn with_after_groups(mut self, after_groups: Vec) -> Self { - self.set_after_groups(after_groups); + self.after_groups = after_groups.into_boxed_slice(); self } @@ -53,22 +53,10 @@ impl Group { self.description.as_deref() } - /// Set a description for the group. - pub fn set_description(&mut self, description: String) -> &mut Self { - self.description = Some(description.into_boxed_str()); - self - } - /// Get the names of the groups that this group loads after. pub fn after_groups(&self) -> &[String] { &self.after_groups } - - /// Set the names of the groups that this group loads after. - pub fn set_after_groups(&mut self, after_groups: Vec) -> &mut Self { - self.after_groups = after_groups.into_boxed_slice(); - self - } } impl std::default::Default for Group { diff --git a/src/metadata/location.rs b/src/metadata/location.rs index 606f2eda..86348ddf 100644 --- a/src/metadata/location.rs +++ b/src/metadata/location.rs @@ -25,7 +25,7 @@ impl Location { /// Set a name for the URL, eg. the page or site name. #[must_use] pub fn with_name(mut self, name: String) -> Self { - self.set_name(name); + self.name = Some(name.into_boxed_str()); self } @@ -38,12 +38,6 @@ impl Location { pub fn name(&self) -> Option<&str> { self.name.as_deref() } - - /// Set a name for the URL, eg. the page or site name. - pub fn set_name(&mut self, name: String) -> &mut Self { - self.name = Some(name.into_boxed_str()); - self - } } impl TryFromYaml for Location { diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 19f1bb15..0a780661 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -64,7 +64,7 @@ impl MessageContent { /// Set the language code to the given value. #[must_use] pub fn with_language(mut self, language: String) -> Self { - self.set_language(language); + self.language = language.into_boxed_str(); self } @@ -77,12 +77,6 @@ impl MessageContent { pub fn language(&self) -> &str { &self.language } - - /// Set the language code to the given value. - pub fn set_language(&mut self, language: String) -> &mut Self { - self.language = language.into_boxed_str(); - self - } } impl std::default::Default for MessageContent { @@ -196,7 +190,7 @@ impl Message { /// Set the condition string. #[must_use] pub fn with_condition(mut self, condition: String) -> Self { - self.set_condition(condition); + self.condition = Some(condition.into_boxed_str()); self } @@ -214,12 +208,6 @@ impl Message { pub fn condition(&self) -> Option<&str> { self.condition.as_deref() } - - /// Set the condition string. - pub fn set_condition(&mut self, condition: String) -> &mut Self { - self.condition = Some(condition.into_boxed_str()); - self - } } pub(crate) fn validate_message_contents( diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index ee3f28ee..7dac15ed 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -40,21 +40,21 @@ impl PluginCleaningData { /// Set the number of Identical To Master records found in the plugin. #[must_use] pub fn with_itm_count(mut self, itm_count: u32) -> Self { - self.set_itm_count(itm_count); + self.itm_count = itm_count; self } /// Set the number of deleted references found in the plugin. #[must_use] pub fn with_deleted_reference_count(mut self, deleted_reference_count: u32) -> Self { - self.set_deleted_reference_count(deleted_reference_count); + self.deleted_reference_count = deleted_reference_count; self } /// Set the number of deleted navmeshes found in the plugin. #[must_use] pub fn with_deleted_navmesh_count(mut self, deleted_navmesh_count: u32) -> Self { - self.set_deleted_navmesh_count(deleted_navmesh_count); + self.deleted_navmesh_count = deleted_navmesh_count; self } @@ -65,7 +65,8 @@ impl PluginCleaningData { mut self, detail: Vec, ) -> Result { - self.set_detail(detail)?; + validate_message_contents(&detail)?; + self.detail = detail.into_boxed_slice(); Ok(self) } @@ -79,34 +80,16 @@ impl PluginCleaningData { self.itm_count } - /// Set the number of Identical To Master records found in the plugin. - pub fn set_itm_count(&mut self, itm_count: u32) -> &mut Self { - self.itm_count = itm_count; - self - } - /// Get the number of deleted references found in the plugin. pub fn deleted_reference_count(&self) -> u32 { self.deleted_reference_count } - /// Set the number of deleted references found in the plugin. - pub fn set_deleted_reference_count(&mut self, deleted_reference_count: u32) -> &mut Self { - self.deleted_reference_count = deleted_reference_count; - self - } - /// Get the number of deleted navmeshes found in the plugin. pub fn deleted_navmesh_count(&self) -> u32 { self.deleted_navmesh_count } - /// Set the number of deleted navmeshes found in the plugin. - pub fn set_deleted_navmesh_count(&mut self, deleted_navmesh_count: u32) -> &mut Self { - self.deleted_navmesh_count = deleted_navmesh_count; - self - } - /// Get the cleaning utility that was used to check the plugin. /// /// The string may include a cleaning utility name, possibly related @@ -122,18 +105,6 @@ impl PluginCleaningData { pub fn detail(&self) -> &[MessageContent] { &self.detail } - - /// Set the detail message content, which may be appended to any messages - /// generated for this cleaning data. If multilingual, one language must be - /// [MessageContent::DEFAULT_LANGUAGE]. - pub fn set_detail( - &mut self, - detail: Vec, - ) -> Result<&mut Self, MultilingualMessageContentsError> { - validate_message_contents(&detail)?; - self.detail = detail.into_boxed_slice(); - Ok(self) - } } impl TryFromYaml for PluginCleaningData { diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index be2b7e72..08edd9e5 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -38,7 +38,7 @@ impl Tag { /// Set the condition string. #[must_use] pub fn with_condition(mut self, condition: String) -> Self { - self.set_condition(condition); + self.condition = Some(condition.into_boxed_str()); self } @@ -56,12 +56,6 @@ impl Tag { pub fn condition(&self) -> Option<&str> { self.condition.as_deref() } - - /// Set the condition string. - pub fn set_condition(&mut self, condition: String) -> &mut Self { - self.condition = Some(condition.into_boxed_str()); - self - } } impl TryFromYaml for Tag { From 56b1fb09c9a2f30af4e32ab7723e7ecb44cc40ab Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 24 Apr 2025 19:44:52 +0100 Subject: [PATCH 111/206] Replace rstest with a couple of proc macros rstest was overkill and pulled in 23 additional dependencies. --- Cargo.lock | 236 ++-------------------------------- Cargo.toml | 5 +- parameterized-test/Cargo.toml | 12 ++ parameterized-test/src/lib.rs | 140 ++++++++++++++++++++ src/archive/find.rs | 18 +-- src/archive/parse.rs | 9 +- src/game.rs | 36 +++--- src/plugin/mod.rs | 48 ++++--- src/tests.rs | 35 +++-- 9 files changed, 237 insertions(+), 302 deletions(-) create mode 100644 parameterized-test/Cargo.toml create mode 100644 parameterized-test/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 08562c35..4ba65a55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,49 +418,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-core", - "futures-macro", - "futures-task", - "pin-project-lite", - "pin-utils", - "slab", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -494,12 +451,6 @@ dependencies = [ "wasi 0.14.2+wasi-0.2.4", ] -[[package]] -name = "glob" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" - [[package]] name = "hashbrown" version = "0.14.5" @@ -602,10 +553,9 @@ dependencies = [ "libloadorder", "log", "loot-condition-interpreter", + "parameterized-test", "petgraph", "rayon", - "rstest", - "rstest_reuse", "rustc-hash", "saphyr", "saphyr-parser", @@ -829,6 +779,15 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "parameterized-test" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pelite" version = "0.10.0" @@ -905,47 +864,17 @@ dependencies = [ "serde", ] -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "portable-atomic" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro-crate" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" -dependencies = [ - "toml_edit", -] - [[package]] name = "proc-macro2" -version = "1.0.94" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ "unicode-ident", ] @@ -1039,36 +968,6 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.15", -] - [[package]] name = "rayon" version = "1.10.0" @@ -1129,53 +1028,6 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" -[[package]] -name = "relative-path" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" - -[[package]] -name = "rstest" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" -dependencies = [ - "futures-timer", - "futures-util", - "rstest_macros", - "rustc_version", -] - -[[package]] -name = "rstest_macros" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" -dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn", - "unicode-ident", -] - -[[package]] -name = "rstest_reuse" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a8fb4672e840a587a66fc577a5491375df51ddb88f2a2c2a792598c326fe14" -dependencies = [ - "quote", - "rand", - "syn", -] - [[package]] name = "rust-ini" version = "0.21.1" @@ -1194,15 +1046,6 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "1.0.5" @@ -1294,15 +1137,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "slab" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] - [[package]] name = "strsim" version = "0.11.1" @@ -1397,23 +1231,6 @@ dependencies = [ "crunchy", ] -[[package]] -name = "toml_datetime" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" - -[[package]] -name = "toml_edit" -version = "0.22.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" -dependencies = [ - "indexmap", - "toml_datetime", - "winnow", -] - [[package]] name = "trim-in-place" version = "0.1.7" @@ -1688,15 +1505,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10" -dependencies = [ - "memchr", -] - [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -1705,23 +1513,3 @@ checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ "bitflags", ] - -[[package]] -name = "zerocopy" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/Cargo.toml b/Cargo.toml index 369d7899..5b2cf756 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,12 +23,11 @@ loot-condition-interpreter.workspace = true windows = { version = "0.61.1", features = ["Win32_Storage_FileSystem"] } [dev-dependencies] -rstest = "0.25.0" -rstest_reuse = "0.7.0" +parameterized-test = { path = "./parameterized-test" } tempfile = "3.17.1" [workspace] -members = ["cxx", "ffi-errors", "nodejs", "pyo3"] +members = ["cxx", "ffi-errors", "nodejs", "parameterized-test", "pyo3"] [workspace.dependencies] esplugin = "6.1.2" diff --git a/parameterized-test/Cargo.toml b/parameterized-test/Cargo.toml new file mode 100644 index 00000000..959defc9 --- /dev/null +++ b/parameterized-test/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "parameterized-test" +version = "0.1.0" +edition = "2024" + +[dependencies] +proc-macro2 = "1.0.95" +quote = "1.0.40" +syn = { version = "2.0.100", features = ["full"] } + +[lib] +proc-macro = true diff --git a/parameterized-test/src/lib.rs b/parameterized-test/src/lib.rs new file mode 100644 index 00000000..272be6b0 --- /dev/null +++ b/parameterized-test/src/lib.rs @@ -0,0 +1,140 @@ +use proc_macro::{TokenStream, TokenTree}; +use quote::{ToTokens, format_ident, quote}; +use syn::{Expr, ExprLit, FnArg, Ident, ItemConst, ItemFn, Lit, Pat, PatIdent, PatType, parse}; + +#[proc_macro_attribute] +pub fn parameterized_test(input: TokenStream, annotated_item: TokenStream) -> TokenStream { + let macro_name: Ident = parse(input).unwrap(); + + let test: ItemFn = parse(annotated_item.clone()).unwrap(); + + let Some(FnArg::Typed(PatType { + pat: type_pattern, .. + })) = test.sig.inputs.first() + else { + panic!("Expected the first test function argument a type pattern"); + }; + + let Pat::Ident(PatIdent { + ident: inner_func_arg_name, + .. + }) = type_pattern.as_ref() + else { + panic!("Expected the first test function argument pattern to be an ident"); + }; + + let inner_func_name = test.sig.ident.clone(); + + quote! { + mod #inner_func_name { + use super::*; + + #test + + #macro_name!{#inner_func_name, #inner_func_arg_name} + } + } + .into() +} + +#[proc_macro_attribute] +pub fn test_parameter(_input: TokenStream, annotated_item: TokenStream) -> TokenStream { + let item: ItemConst = parse(annotated_item.clone()).unwrap(); + + let Expr::Array(array) = item.expr.as_ref() else { + panic!("Expected expression to be an array"); + }; + + let values: Vec<_> = array + .elems + .iter() + .map(|n| match n { + Expr::Path(path) => path.path.segments.last().unwrap().ident.to_token_stream(), + Expr::Lit(ExprLit { + lit: Lit::Int(lit_int), + .. + }) => lit_int.to_token_stream(), + _ => panic!("Expected array element to be a path or int literal"), + }) + .collect(); + + let annotated_item = proc_macro2::TokenStream::from(annotated_item); + + let const_item_name = item.ident; + let macro_name = format_ident!("{}_macro", &const_item_name); + + let macro_output = quote! { + macro_rules! #macro_name { + ( $inner_test_name:ident, $inner_test_arg_name:ident ) => { + parameterized_test::generate_tests!{ + $inner_test_name, + $inner_test_arg_name, + #const_item_name, + [#(#values),*] + } + }; + } + + #[allow(unused_imports)] + pub(crate) use #macro_name as #const_item_name; + + #annotated_item + }; + + macro_output.into() +} + +#[proc_macro] +pub fn generate_tests(item: TokenStream) -> TokenStream { + let mut item_iter = item.into_iter(); + let TokenTree::Ident(inner_test_name) = item_iter.next().unwrap() else { + panic!("Expected an ident for the inner_test_name"); + }; + + let _ = item_iter.next(); + + let TokenTree::Ident(inner_test_param_name) = item_iter.next().unwrap() else { + panic!("Expected an ident for the inner_test_param_name"); + }; + + let _ = item_iter.next(); + + let TokenTree::Ident(const_item_name) = item_iter.next().unwrap() else { + panic!("Expected an ident for the const_item_name"); + }; + + let _ = item_iter.next(); + + let TokenTree::Group(const_item_values) = item_iter.next().unwrap() else { + panic!("Expected a group for the const_item_values"); + }; + + let inner_test_name: Ident = parse(TokenTree::from(inner_test_name).into()).unwrap(); + let const_item_name: Ident = parse(TokenTree::from(const_item_name).into()).unwrap(); + + let tokens: proc_macro2::TokenStream = const_item_values + .stream() + .into_iter() + .step_by(2) + .enumerate() + .flat_map(|(i, value)| { + let suffix = match value { + TokenTree::Ident(ident) => ident.to_string(), + TokenTree::Literal(literal) => literal.to_string(), + _ => panic!("Expected const item value to be an ident or literal"), + }; + + let test_name = format_ident!("{inner_test_param_name}_{i:02}_{suffix}"); + + quote! { + #[test] + #[allow(non_snake_case)] + fn #test_name() { + #inner_test_name(#const_item_name[#i]); + } + } + }) + .collect(); + + tokens.into() +} diff --git a/src/archive/find.rs b/src/archive/find.rs index 03c3497d..796708f7 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -201,14 +201,14 @@ mod tests { mod find_associated_archives { use std::path::absolute; - use rstest_reuse::apply; + use parameterized_test::parameterized_test; use tempfile::TempDir; use super::*; use crate::tests::{ - BLANK_DIFFERENT_ESM, BLANK_DIFFERENT_ESP, BLANK_ESM, BLANK_ESP, - BLANK_MASTER_DEPENDENT_ESM, all_game_types, copy_file, source_plugins_path, + ALL_GAME_TYPES, BLANK_DIFFERENT_ESM, BLANK_DIFFERENT_ESP, BLANK_ESM, BLANK_ESP, + BLANK_MASTER_DEPENDENT_ESM, copy_file, source_plugins_path, }; const NON_ASCII_ESP: &str = "non\u{00C1}scii.esp"; @@ -281,7 +281,7 @@ mod tests { } } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_return_empty_vec_if_no_matching_archives_are_found(game_type: GameType) { let fixture = Fixture::new(game_type); @@ -294,7 +294,7 @@ mod tests { assert!(archives.is_empty()); } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_find_an_archive_that_exactly_matches_an_esm_file_basename_except_for_morrowind_and_oblivion( game_type: GameType, ) { @@ -316,7 +316,7 @@ mod tests { } } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_find_an_archive_that_exactly_matches_a_non_ascii_esp_file_basename_except_for_morrowind_and_starfield( game_type: GameType, ) { @@ -338,7 +338,7 @@ mod tests { } } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_find_an_archive_that_starts_with_an_esp_file_basename_except_for_morrowind_and( game_type: GameType, ) { @@ -357,7 +357,7 @@ mod tests { } } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_find_an_archive_that_starts_with_an_esm_file_basename_only_for_fallout( game_type: GameType, ) { @@ -382,7 +382,7 @@ mod tests { } } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_find_an_archive_that_starts_with_an_esp_file_basename_only_for_oblivion_and_fallout( game_type: GameType, ) { diff --git a/src/archive/parse.rs b/src/archive/parse.rs index de988b32..0ffb8255 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -137,7 +137,7 @@ mod tests { io::SeekFrom, }; - use rstest::rstest; + use parameterized_test::{parameterized_test, test_parameter}; use tempfile::tempdir; use super::*; @@ -246,8 +246,11 @@ mod tests { assert_eq!(expected_file_hash, *value.first().unwrap()); } - #[rstest] - fn should_support_ba2_versions(#[values(1, 2, 3, 7, 8)] version: u32) { + #[test_parameter] + const BA2_VERSIONS: [u32; 5] = [1, 2, 3, 7, 8]; + + #[parameterized_test(BA2_VERSIONS)] + fn should_support_ba2_versions(version: u32) { use std::io::{Seek, Write}; let tmp_dir = tempdir().unwrap(); diff --git a/src/game.rs b/src/game.rs index 63ed2d04..3b1fa27b 100644 --- a/src/game.rs +++ b/src/game.rs @@ -805,13 +805,13 @@ impl GameCache { mod tests { use super::*; - use rstest_reuse::apply; + use parameterized_test::parameterized_test; use crate::{ metadata::{File, PluginMetadata}, tests::{ - BLANK_DIFFERENT_ESM, BLANK_DIFFERENT_ESP, BLANK_ESM, BLANK_ESP, - BLANK_MASTER_DEPENDENT_ESM, Fixture, all_game_types, + ALL_GAME_TYPES, BLANK_DIFFERENT_ESM, BLANK_DIFFERENT_ESP, BLANK_ESM, BLANK_ESP, + BLANK_MASTER_DEPENDENT_ESM, Fixture, }, }; @@ -903,7 +903,7 @@ mod tests { use super::*; #[cfg(windows)] - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_succeed_if_given_valid_game_path(game_type: GameType) { let fixture = Fixture::new(game_type); @@ -911,7 +911,7 @@ mod tests { } #[cfg(not(windows))] - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_succeed_for_morrowind_if_given_valid_game_path(game_type: GameType) { let fixture = Fixture::new(game_type); @@ -975,7 +975,7 @@ mod tests { mod with_local_path { use super::*; - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_succeed_if_given_valid_paths(game_type: GameType) { let fixture = Fixture::new(game_type); @@ -1090,7 +1090,7 @@ mod tests { } } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_set_default_additional_data_paths(game_type: GameType) { let fixture = Fixture::new(game_type); @@ -1271,7 +1271,7 @@ mod tests { use crate::tests::{NON_ASCII_ESM, NON_PLUGIN_FILE}; - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_return_true_for_a_valid_non_ascii_plugin(game_type: GameType) { let fixture = Fixture::new(game_type); @@ -1291,7 +1291,7 @@ mod tests { assert!(game.is_valid_plugin(Path::new(NON_ASCII_ESM))); } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_return_true_for_an_omwscripts_plugin(game_type: GameType) { let fixture = Fixture::new(game_type); @@ -1312,7 +1312,7 @@ mod tests { } } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_return_false_for_a_non_plugin_file(game_type: GameType) { let fixture = Fixture::new(game_type); @@ -1326,7 +1326,7 @@ mod tests { assert!(!game.is_valid_plugin(Path::new(NON_PLUGIN_FILE))); } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_return_false_for_an_empty_file(game_type: GameType) { let fixture = Fixture::new(game_type); @@ -1343,7 +1343,7 @@ mod tests { assert!(!game.is_valid_plugin(&empty_file_path)); } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_try_ghosted_path_if_given_plugin_does_not_exist_unless_game_is_openmw( game_type: GameType, ) { @@ -1413,7 +1413,7 @@ mod tests { use super::*; - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_load_the_headers_of_the_given_plugins(game_type: GameType) { let fixture = Fixture::new(game_type); @@ -1514,7 +1514,7 @@ mod tests { use super::*; - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_fully_load_the_given_plugins(game_type: GameType) { let fixture = Fixture::new(game_type); @@ -1584,7 +1584,7 @@ mod tests { assert_ne!(plugin2, plugin3); } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_error_if_loading_a_plugin_with_a_master_that_is_not_loaded_if_game_is_morrowind_or_starfield( game_type: GameType, ) { @@ -1626,7 +1626,7 @@ mod tests { } } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_not_error_if_loading_a_plugin_with_a_master_that_is_also_being_loaded_if_game_is_morrowind_or_starfield( game_type: GameType, ) { @@ -1653,7 +1653,7 @@ mod tests { assert!(game.plugin(BLANK_MASTER_DEPENDENT_ESM).is_some()); } - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_not_error_if_loading_a_plugin_with_a_master_that_is_already_loaded_if_game_is_morrowind_or_starfield( game_type: GameType, ) { @@ -1684,7 +1684,7 @@ mod tests { mod load_plugins_common { use super::*; - #[apply(all_game_types)] + #[parameterized_test(ALL_GAME_TYPES)] fn should_find_archives_in_additional_data_paths(game_type: GameType) { let fixture = Fixture::new(game_type); diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index e625bd1b..778e5924 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -453,17 +453,19 @@ fn extract_version(description: &str) -> Result, Box Date: Fri, 25 Apr 2025 18:24:16 +0100 Subject: [PATCH 112/206] Remove obsolete commented-out code --- cxx/src/lib.rs | 8 -------- cxx/src/metadata.rs | 17 ----------------- 2 files changed, 25 deletions(-) diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index 35d52565..3b12fd46 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -591,20 +591,12 @@ mod ffi { pub fn display_name(&self) -> &str; - // pub fn set_display_name(&mut self, display_name: String); - pub fn detail(&self) -> &[MessageContent]; - // pub fn set_detail(&mut self, detail: &[Box]) -> Result<()>; - pub fn condition(&self) -> &str; - // pub fn set_condition(&mut self, condition: String); - pub fn constraint(&self) -> &str; - // pub fn set_constraint(&mut self, constraint: String); - pub fn boxed_clone(&self) -> Box; } diff --git a/cxx/src/metadata.rs b/cxx/src/metadata.rs index e2974289..f50d709b 100644 --- a/cxx/src/metadata.rs +++ b/cxx/src/metadata.rs @@ -428,35 +428,18 @@ impl File { self.0.display_name().unwrap_or("") } - // pub fn set_display_name(&mut self, display_name: String) { - // self.0.set_display_name(display_name); - // } - pub fn detail(&self) -> &[MessageContent] { MessageContent::wrap_slice(self.0.detail()) } - // pub fn set_detail(&mut self, detail: &[Box]) -> Result<(), VerboseError> { - // self.0.set_detail(to_vec_of_unwrapped(detail))?; - // Ok(()) - // } - pub fn condition(&self) -> &str { self.0.condition().unwrap_or("") } - // pub fn set_condition(&mut self, condition: String) { - // self.0.set_condition(condition); - // } - pub fn constraint(&self) -> &str { self.0.constraint().unwrap_or("") } - // pub fn set_constraint(&mut self, constraint: String) { - // self.0.set_constraint(constraint); - // } - pub fn boxed_clone(&self) -> Box { Box::new(Self(self.0.clone())) } From e6948e0c8608862545c69280c0bd129af6b3a617 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Fri, 25 Apr 2025 18:27:35 +0100 Subject: [PATCH 113/206] Tweak handling of optionals in the C++ wrapper --- cxx/src/api/plugin.cpp | 12 +++++--- cxx/src/database.rs | 4 +-- cxx/src/game.rs | 4 +-- cxx/src/lib.rs | 68 ++++++++++++++++++++++++++++-------------- cxx/src/metadata.rs | 18 +++-------- cxx/src/plugin.rs | 14 ++------- 6 files changed, 65 insertions(+), 55 deletions(-) diff --git a/cxx/src/api/plugin.cpp b/cxx/src/api/plugin.cpp index 3446e8d1..08c383dc 100644 --- a/cxx/src/api/plugin.cpp +++ b/cxx/src/api/plugin.cpp @@ -43,11 +43,15 @@ std::vector Plugin::GetBashTags() const { } std::optional Plugin::GetCRC() const { - const auto value = plugin_->crc(); - if (value < 0 || value > UINT32_MAX) { + try { + auto optional = plugin_->crc(); + if (optional->is_some()) { + return optional->as_ref(); + } + return std::nullopt; - } else { - return uint32_t(value); + } catch (const ::rust::Error& e) { + std::rethrow_exception(mapError(e)); } } diff --git a/cxx/src/database.rs b/cxx/src/database.rs index 3da75bda..945dbec9 100644 --- a/cxx/src/database.rs +++ b/cxx/src/database.rs @@ -8,9 +8,9 @@ use libloot::{WriteMode, error::DatabaseLockPoisonError}; use libloot_ffi_errors::UnsupportedEnumValueError; use crate::{ - VerboseError, + OptionalPluginMetadata, VerboseError, ffi::EdgeType, - metadata::{Group, Message, OptionalPluginMetadata, PluginMetadata, to_vec_of_unwrapped}, + metadata::{Group, Message, PluginMetadata, to_vec_of_unwrapped}, }; #[derive(Debug)] diff --git a/cxx/src/game.rs b/cxx/src/game.rs index 14261275..85c29594 100644 --- a/cxx/src/game.rs +++ b/cxx/src/game.rs @@ -3,7 +3,7 @@ use std::path::Path; use delegate::delegate; use libloot_ffi_errors::UnsupportedEnumValueError; -use crate::{Plugin, VerboseError, database::Database, ffi::GameType, plugin::OptionalPlugin}; +use crate::{OptionalPlugin, Plugin, VerboseError, database::Database, ffi::GameType}; impl TryFrom for GameType { type Error = UnsupportedEnumValueError; @@ -149,7 +149,7 @@ impl Game { } pub fn plugin(&self, plugin_name: &str) -> Box { - Box::new(self.0.plugin(plugin_name).into()) + Box::new(self.0.plugin(plugin_name).map(Into::into).into()) } pub fn loaded_plugins(&self) -> Vec { diff --git a/cxx/src/lib.rs b/cxx/src/lib.rs index 3b12fd46..61f2161b 100644 --- a/cxx/src/lib.rs +++ b/cxx/src/lib.rs @@ -105,16 +105,16 @@ mod plugin; use database::{Database, Vertex, new_vertex}; use error::{EmptyOptionalError, VerboseError}; +use ffi::OptionalMessageContentRef; use game::{Game, new_game, new_game_with_local_path}; use libloot_ffi_errors::UnsupportedEnumValueError; use metadata::{ - File, Filename, Group, Location, Message, MessageContent, OptionalMessageContentRef, - OptionalPluginMetadata, PluginCleaningData, PluginMetadata, Tag, group_default_name, - message_content_default_language, multilingual_message, new_file, new_filename, new_group, - new_location, new_message, new_message_content, new_plugin_cleaning_data, new_plugin_metadata, - new_tag, select_message_content, + File, Filename, Group, Location, Message, MessageContent, PluginCleaningData, PluginMetadata, + Tag, group_default_name, message_content_default_language, multilingual_message, new_file, + new_filename, new_group, new_location, new_message, new_message_content, + new_plugin_cleaning_data, new_plugin_metadata, new_tag, select_message_content, }; -use plugin::{OptionalPlugin, Plugin}; +use plugin::Plugin; use std::{ ffi::{CString, c_char, c_uchar, c_uint, c_void}, sync::{Mutex, atomic::AtomicPtr}, @@ -124,32 +124,31 @@ use unicase::UniCase; use libloot::set_logging_callback; pub use libloot::{is_compatible, libloot_revision, libloot_version}; -#[derive(Debug)] -pub struct OptionalRef(*const T); - -impl OptionalRef { +impl OptionalMessageContentRef { pub fn is_some(&self) -> bool { - !self.0.is_null() + !self.pointer.is_null() } /// # Safety /// /// This is safe as long as the pointer in the `OptionalRef` is still valid. - pub unsafe fn as_ref(&self) -> Result<&T, EmptyOptionalError> { - if self.0.is_null() { + pub unsafe fn as_ref(&self) -> Result<&MessageContent, EmptyOptionalError> { + if self.pointer.is_null() { Err(EmptyOptionalError) } else { // SAFETY: This is safe as long as self.0 is still valid. - unsafe { Ok(&*self.0) } + unsafe { Ok(&*self.pointer) } } } } -impl From> for OptionalRef { - fn from(value: Option<&T>) -> Self { +impl From> for OptionalMessageContentRef { + fn from(value: Option<&MessageContent>) -> Self { match value { - Some(p) => OptionalRef(p), - None => OptionalRef(std::ptr::null()), + Some(p) => OptionalMessageContentRef { pointer: p }, + None => OptionalMessageContentRef { + pointer: std::ptr::null(), + }, } } } @@ -170,6 +169,18 @@ impl Optional { } } +impl From> for Optional { + fn from(value: Option) -> Self { + Self(value) + } +} + +pub type OptionalPlugin = Optional; + +pub type OptionalPluginMetadata = Optional; + +pub type OptionalCrc = Optional; + fn compare_filenames(lhs: &str, rhs: &str) -> i8 { match UniCase::new(lhs).cmp(&UniCase::new(rhs)) { std::cmp::Ordering::Less => -1, @@ -259,6 +270,19 @@ mod ffi { Fatal, } + #[derive(Debug)] + struct OptionalMessageContentRef { + pointer: *const MessageContent, + } + + extern "Rust" { + pub fn is_some(self: &OptionalMessageContentRef) -> bool; + + // Again, these lifetimes are wrong. + pub unsafe fn as_ref<'a>(self: &'a OptionalMessageContentRef) + -> Result<&'a MessageContent>; + } + extern "Rust" { fn set_log_level(level: LogLevel) -> Result<()>; @@ -269,7 +293,7 @@ mod ffi { fn select_message_content( contents: &[MessageContent], language: &str, - ) -> Box; + ) -> OptionalMessageContentRef; fn compare_filenames(lhs: &str, rhs: &str) -> i8; } @@ -450,7 +474,7 @@ mod ffi { pub fn bash_tags(&self) -> &[String]; // The None case is signalled by -1, all other values fit in u32. - pub fn crc(&self) -> i64; + pub fn crc(&self) -> Box; pub fn is_master(&self) -> bool; @@ -487,12 +511,12 @@ mod ffi { } extern "Rust" { - type OptionalMessageContentRef; + type OptionalCrc; pub fn is_some(&self) -> bool; // Again, these lifetimes are wrong. - pub unsafe fn as_ref<'a>(&'a self) -> Result<&'a MessageContent>; + pub unsafe fn as_ref<'a>(&'a self) -> Result<&'a u32>; } extern "Rust" { diff --git a/cxx/src/metadata.rs b/cxx/src/metadata.rs index f50d709b..7cbf6085 100644 --- a/cxx/src/metadata.rs +++ b/cxx/src/metadata.rs @@ -1,8 +1,8 @@ use delegate::delegate; use crate::{ - Optional, OptionalRef, UnsupportedEnumValueError, VerboseError, - ffi::{MessageType, TagSuggestion}, + UnsupportedEnumValueError, VerboseError, + ffi::{MessageType, OptionalMessageContentRef, TagSuggestion}, }; /// # Safety @@ -113,16 +113,14 @@ impl From> for libloot::metadata::MessageContent { } } -pub type OptionalMessageContentRef = OptionalRef; - pub fn select_message_content( contents: &[MessageContent], language: &str, -) -> Box { +) -> OptionalMessageContentRef { let option = libloot::metadata::select_message_content(MessageContent::unwrap_slice(contents), language); - Box::new(option.map(MessageContent::wrap_ref).into()) + option.map(MessageContent::wrap_ref).into() } #[derive(Clone, Debug)] @@ -379,14 +377,6 @@ impl From> for libloot::metadata::PluginMetadata { } } -pub type OptionalPluginMetadata = Optional; - -impl From> for Optional { - fn from(value: Option) -> Self { - Self(value) - } -} - #[derive(Clone, Debug)] #[repr(transparent)] pub struct File(libloot::metadata::File); diff --git a/cxx/src/plugin.rs b/cxx/src/plugin.rs index a0ddd296..e1a351a4 100644 --- a/cxx/src/plugin.rs +++ b/cxx/src/plugin.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use delegate::delegate; -use crate::{Optional, VerboseError}; +use crate::{OptionalCrc, VerboseError}; #[derive(Debug)] #[repr(transparent)] @@ -25,8 +25,8 @@ impl Plugin { self.0.masters().map_err(Into::into) } - pub fn crc(&self) -> i64 { - self.0.crc().map_or(-1, Into::into) + pub fn crc(&self) -> Box { + Box::new(self.0.crc().into()) } pub fn is_valid_as_light_plugin(&self) -> Result { @@ -77,11 +77,3 @@ impl From> for Plugin { Plugin(value) } } - -pub type OptionalPlugin = Optional; - -impl From>> for Optional { - fn from(value: Option>) -> Self { - Self(value.map(Into::into)) - } -} From 0bc73f35fe1b54f9f5af214edb83e1d4f3874321 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Fri, 25 Apr 2025 18:38:19 +0100 Subject: [PATCH 114/206] Use a typedef to reduce repetition --- cxx/src/api/api.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cxx/src/api/api.cpp b/cxx/src/api/api.cpp index 3878917c..a2bcb6ae 100644 --- a/cxx/src/api/api.cpp +++ b/cxx/src/api/api.cpp @@ -32,7 +32,9 @@ void libloot_set_logging_callback(void (*callback)(uint8_t, const char*, void*), namespace { using loot::LogLevel; -static std::function STORED_CALLBACK; +typedef std::function Callback; + +static Callback STORED_CALLBACK; LogLevel convert(uint8_t level) { if (level == LIBLOOT_LOG_LEVEL_TRACE) { @@ -70,16 +72,14 @@ loot::rust::LogLevel convert(LogLevel level) { } void logging_callback(uint8_t level, const char* message, void* context) { - auto callbackPtr = - static_cast*>(context); + auto& callback = *static_cast(context); - (*callbackPtr)(convert(level), message); + callback(convert(level), message); } } namespace loot { -LOOT_API void SetLoggingCallback( - std::function callback) { +LOOT_API void SetLoggingCallback(Callback callback) { STORED_CALLBACK = callback; libloot_set_logging_callback(logging_callback, &STORED_CALLBACK); } From 4cd9b3747117c0feedd72dd84c8b8402de581658 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 26 Apr 2025 16:40:46 +0100 Subject: [PATCH 115/206] Remove nodejs cargo profile The cargo profile in the nodejs folder gets ignored because the crate is in a workspace, but it got me thinking about LTO. Enabling fat LTO more than doubles the build time, and although it shrinks the binary size, if there is a performance impact it's not very obvious (based on sorting in LOOT with the built DLL). On the other hand, thin LTO only only adds a second to the cargo build time and reduced binary size by ~ 1.4 MB, and adding /LTCG on top reduces it by another ~ 1.6 MB. MSVC doesn't output how long linking takes, but it seemed to only add a second or two. --- Cargo.toml | 1 + cxx/CMakeLists.txt | 4 ++-- nodejs/Cargo.toml | 4 ---- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5b2cf756..9f4eccd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ loot-condition-interpreter = "5.3.1" [profile.release] debug = "limited" +lto = "thin" [profile.rel-with-deb-info] inherits = "release" diff --git a/cxx/CMakeLists.txt b/cxx/CMakeLists.txt index 2a55eb68..f3653d58 100644 --- a/cxx/CMakeLists.txt +++ b/cxx/CMakeLists.txt @@ -154,7 +154,6 @@ target_include_directories(loot SYSTEM PRIVATE if(CMAKE_SYSTEM_NAME STREQUAL "Windows") target_compile_definitions(loot PRIVATE UNICODE _UNICODE LOOT_EXPORT) - target_compile_options(loot PRIVATE "/Zc:__cplusplus") set(LOOT_LIBS ntdll ws2_32 bcrypt) @@ -167,7 +166,8 @@ endif() if(MSVC) # Turn off permissive mode to be more standards-compliant and avoid compiler errors. - target_compile_options(loot PRIVATE "/permissive-" "/W4") + target_compile_options(loot PRIVATE "/permissive-" "/W4" "/Zc:__cplusplus" "/GL") + target_link_options(loot PRIVATE "/LTCG") endif() ############################## diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml index 07e0ba7b..904239a1 100644 --- a/nodejs/Cargo.toml +++ b/nodejs/Cargo.toml @@ -16,7 +16,3 @@ napi-derive = "2.12.2" [build-dependencies] napi-build = "2.0.1" - -[profile.release] -lto = true -strip = "symbols" From 9c5b73da74907ac87d6f44723c75ae4b16aca92e Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 26 Apr 2025 16:41:33 +0100 Subject: [PATCH 116/206] Run cargo from CMake --- cxx/CMakeLists.txt | 19 +++++++++++++------ cxx/README.md | 5 +---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/cxx/CMakeLists.txt b/cxx/CMakeLists.txt index f3653d58..424237c3 100644 --- a/cxx/CMakeLists.txt +++ b/cxx/CMakeLists.txt @@ -24,8 +24,6 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) # External Projects ############################## -add_library(libloot-cxx STATIC IMPORTED) - if(MSVC) set(LIBLOOT_CXX_FILENAME "libloot_cxx.lib") else() @@ -38,10 +36,19 @@ else() set(TARGET_PATH "${CMAKE_SOURCE_DIR}/../target") endif() -set_target_properties(libloot-cxx PROPERTIES - IMPORTED_CONFIGURATIONS "Release;Debug" - IMPORTED_LOCATION "${TARGET_PATH}/release/${LIBLOOT_CXX_FILENAME}" - IMPORTED_LOCATION_DEBUG "${TARGET_PATH}/debug/${LIBLOOT_CXX_FILENAME}") +add_custom_target(libloot-cxx-build + COMMAND cargo build + $<$>:--release> + $<$,$>:--config> + $<$,$>:${CMAKE_SOURCE_DIR}/../.cargo/msvcd-config.toml> +) + +add_library(libloot-cxx INTERFACE) +add_dependencies(libloot-cxx libloot-cxx-build) + +target_link_libraries(libloot-cxx INTERFACE + optimized "${TARGET_PATH}/release/${LIBLOOT_CXX_FILENAME}" + debug "${TARGET_PATH}/debug/${LIBLOOT_CXX_FILENAME}") target_include_directories(libloot-cxx INTERFACE "${TARGET_PATH}/cxxbridge") diff --git a/cxx/README.md b/cxx/README.md index a02d9f1d..aa4a7dc1 100644 --- a/cxx/README.md +++ b/cxx/README.md @@ -14,15 +14,13 @@ The wrapper has two layers: To build a release build with debug info: ``` -cargo build --release cmake -B build . cmake --build build --parallel --config RelWithDebInfo ``` -Debug builds need a little extra config to get the static and shared libraries to use the same C runtime library on Windows: +To build a debug build: ``` -cargo build --config ../.cargo/msvcd-config.toml cmake -B build . cmake --build build --parallel --config Debug ``` @@ -32,7 +30,6 @@ cmake --build build --parallel --config Debug To build a release build with debug info: ``` -cargo build --release cmake -B build . -DCMAKE_BUILD_TYPE=RelWithDebInfo cmake --build build --parallel ``` From cc31934de4ed9606b7c15daf5cf96886452d7fe6 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 26 Apr 2025 18:23:07 +0100 Subject: [PATCH 117/206] Fix building C++ wrapper using Ninja --- cxx/CMakeLists.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cxx/CMakeLists.txt b/cxx/CMakeLists.txt index 424237c3..b94952b8 100644 --- a/cxx/CMakeLists.txt +++ b/cxx/CMakeLists.txt @@ -41,6 +41,11 @@ add_custom_target(libloot-cxx-build $<$>:--release> $<$,$>:--config> $<$,$>:${CMAKE_SOURCE_DIR}/../.cargo/msvcd-config.toml> + BYPRODUCTS + "${TARGET_PATH}/$,debug,release>/${LIBLOOT_CXX_FILENAME}" + "${TARGET_PATH}/cxxbridge/libloot-cxx/src/lib.rs.cc" + "${TARGET_PATH}/cxxbridge/libloot-cxx/src/lib.rs.h" + "${TARGET_PATH}/cxxbridge/rust/cxx.h" ) add_library(libloot-cxx INTERFACE) From 6c664697a94d50ab41ebeabf7742143c3f49b7ab Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 26 Apr 2025 18:29:13 +0100 Subject: [PATCH 118/206] Fix parsing of exception messages in C++ --- cxx/src/api/exception.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cxx/src/api/exception.cpp b/cxx/src/api/exception.cpp index 775ea61b..1292271f 100644 --- a/cxx/src/api/exception.cpp +++ b/cxx/src/api/exception.cpp @@ -143,7 +143,7 @@ std::exception_ptr mapError(const ::rust::Error& error) { UndefinedGroupError(getErrorSuffix(error.what()))); } else if (startsWith(error.what(), ESPLUGIN_ERROR_PREFIX)) { const auto [code, details] = parseSystemError( - std::string_view(error.what()).substr(0, ESPLUGIN_ERROR_PREFIX.size())); + std::string_view(error.what()).substr(ESPLUGIN_ERROR_PREFIX.size())); return std::make_exception_ptr( std::system_error(code, esplugin_category(), details)); @@ -151,13 +151,13 @@ std::exception_ptr mapError(const ::rust::Error& error) { } else if (startsWith(error.what(), LIBLOADORDER_ERROR_PREFIX)) { const auto [code, details] = parseSystemError(std::string_view(error.what()) - .substr(0, LIBLOADORDER_ERROR_PREFIX.size())); + .substr(LIBLOADORDER_ERROR_PREFIX.size())); return std::make_exception_ptr( std::system_error(code, libloadorder_category(), details)); } else if (startsWith(error.what(), LCI_ERROR_PREFIX)) { const auto [code, details] = parseSystemError( - std::string_view(error.what()).substr(0, LCI_ERROR_PREFIX.size())); + std::string_view(error.what()).substr(LCI_ERROR_PREFIX.size())); return std::make_exception_ptr(ConditionSyntaxError( code, loot_condition_interpreter_category(), details)); From 3ee1789316ebb25153ec5f02d3daa11e779b1de5 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 28 Apr 2025 17:43:47 +0100 Subject: [PATCH 119/206] Write paths in errors as escaped ASCII text --- src/archive/error.rs | 4 +++- src/error.rs | 4 ++-- src/game.rs | 8 ++++++-- src/lib.rs | 6 ++++++ src/metadata/error.rs | 16 ++++++++++++---- src/plugin/error.rs | 6 ++++-- 6 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/archive/error.rs b/src/archive/error.rs index 565cba01..ca5caa05 100644 --- a/src/archive/error.rs +++ b/src/archive/error.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +use crate::escape_ascii; + #[derive(Debug)] pub(crate) struct ArchivePathParsingError { path: PathBuf, @@ -24,7 +26,7 @@ impl std::fmt::Display for ArchivePathParsingError { write!( f, "failed to parse the archive at \"{}\"", - self.path.display() + escape_ascii(&self.path) ) } } diff --git a/src/error.rs b/src/error.rs index 28372120..a0b1e707 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,10 +6,10 @@ pub use crate::plugin::error::PluginDataError; use crate::plugin::error::PluginValidationError; pub use crate::sorting::error::GroupsPathError; -use crate::Vertex; use crate::sorting::error::{ BuildGroupsGraphError, PluginGraphValidationError, SortingError, display_cycle, }; +use crate::{Vertex, escape_ascii}; /// Represents an error that occurred while trying to create a [Game][crate::Game]. #[derive(Debug)] @@ -25,7 +25,7 @@ impl std::fmt::Display for GameHandleCreationError { Self::NotADirectory(p) => write!( f, "the path \"{}\" does not resolve to a directory", - p.display() + escape_ascii(p) ), Self::LoadOrderError(_) => { write!(f, "failed to initialise the load order game settings") diff --git a/src/game.rs b/src/game.rs index 3b1fa27b..b18d1cdb 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1784,12 +1784,16 @@ mod tests { Path::new("b").join(BLANK_ESM), ]; + let expected_path = if cfg!(windows) { + "b\\\\Blank.esm" + } else { + "b/Blank.esm" + }; match game.load_plugins_common(&[&paths[0], &paths[1]], LoadScope::HeaderOnly) { Err(LoadPluginsError::PluginValidationError(e)) => { assert_eq!( format!( - "the path \"{}\" has a filename that is not unique", - paths[1].display() + "the path \"{expected_path}\" has a filename that is not unique" ), e.to_string() ); diff --git a/src/lib.rs b/src/lib.rs index 02e698f3..16a612ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -119,6 +119,8 @@ mod sorting; mod tests; mod version; +use std::{path::Path, slice::EscapeAscii}; + use fancy_regex::{Error as RegexImplError, Regex, RegexBuilder}; pub use database::{Database, WriteMode}; @@ -137,3 +139,7 @@ fn case_insensitive_regex(value: &str) -> Result> { .build() .map_err(Into::into) } + +fn escape_ascii(path: &Path) -> EscapeAscii { + path.as_os_str().as_encoded_bytes().escape_ascii() +} diff --git a/src/metadata/error.rs b/src/metadata/error.rs index 4d25761e..8414955a 100644 --- a/src/metadata/error.rs +++ b/src/metadata/error.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use fancy_regex::Error as RegexImplError; use saphyr::Marker; -use crate::metadata::MessageContent; +use crate::{escape_ascii, metadata::MessageContent}; use super::yaml::{YamlObjectType, to_unmarked_yaml}; @@ -244,7 +244,11 @@ impl LoadMetadataError { impl std::fmt::Display for LoadMetadataError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "failed to parse the file at \"{}\"", self.path.display()) + write!( + f, + "failed to parse the file at \"{}\"", + escape_ascii(&self.path) + ) } } @@ -380,10 +384,14 @@ impl std::fmt::Display for WriteMetadataError { WriteMetadataErrorReason::ParentDirectoryNotFound => write!( f, "the parent directory of the path \"{}\" was not found", - self.path.display() + escape_ascii(&self.path) ), WriteMetadataErrorReason::PathAlreadyExists => { - write!(f, "the path \"{}\" already exists", self.path.display()) + write!( + f, + "the path \"{}\" already exists", + escape_ascii(&self.path) + ) } WriteMetadataErrorReason::IoError(_) => write!(f, "an I/O error occurred"), } diff --git a/src/plugin/error.rs b/src/plugin/error.rs index 7bef946a..376b0db2 100644 --- a/src/plugin/error.rs +++ b/src/plugin/error.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; use fancy_regex::Error as RegexImplError; +use crate::escape_ascii; + /// Represents an error that occurred while reading a parsed plugin's data. #[derive(Debug)] pub struct PluginDataError(esplugin::Error); @@ -121,13 +123,13 @@ impl std::fmt::Display for PluginValidationError { PluginValidationErrorReason::InvalidFilename(i) => write!( f, "the path \"{}\" has a filename that {}", - self.path.display(), + escape_ascii(&self.path), i ), PluginValidationErrorReason::InvalidPluginHeader => write!( f, "the file at \"{}\" does not have a valid plugin header", - self.path.display() + escape_ascii(&self.path) ), } } From 0fb4624e58c548ef83ff6c2ab9b685faaf317c2f Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 28 Apr 2025 17:45:56 +0100 Subject: [PATCH 120/206] Write paths in logs as escaped ASCII text --- src/archive/parse.rs | 7 ++++--- src/game.rs | 17 +++++++++-------- src/metadata/metadata_document.rs | 14 +++++++------- src/plugin/mod.rs | 6 +++--- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/archive/parse.rs b/src/archive/parse.rs index 0ffb8255..49e4538a 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -8,6 +8,7 @@ use std::{ use super::error::{ArchiveParsingError, ArchivePathParsingError}; use crate::{ archive::error::slice_too_small, + escape_ascii, logging::{self, format_details}, plugin::has_ascii_extension, }; @@ -20,7 +21,7 @@ pub fn assets_in_archives(archive_paths: &[PathBuf]) -> BTreeMap BTreeMap { logging::error!( "Encountered an error while trying to read the Bethesda archive at \"{}\": {}", - archive_path.display(), + escape_ascii(archive_path), format_details(&e) ); continue; @@ -46,7 +47,7 @@ pub fn assets_in_archives(archive_paths: &[PathBuf]) -> BTreeMap Result { logging::info!( - "Attempting to create a game handle for game type \"{}\" with game path {:?}", + "Attempting to create a game handle for game type \"{}\" with game path \"{}\"", game_type, - game_path + escape_ascii(game_path) ); let resolved_game_path = resolve_path(game_path); @@ -197,10 +198,10 @@ impl Game { game_local_path: &Path, ) -> Result { logging::info!( - "Attempting to create a game handle for game type \"{}\" with game path {:?} and game local path {:?}", + "Attempting to create a game handle for game type \"{}\" with game path \"{}\" and game local path \"{}\"", game_type, - game_path, - game_local_path + escape_ascii(game_path), + escape_ascii(game_local_path) ); let resolved_game_path = resolve_path(game_path); @@ -671,7 +672,7 @@ fn try_load_plugin( Err(e) => { logging::error!( "Caught error while trying to load \"{}\": {}", - plugin_path.display(), + escape_ascii(plugin_path), format_details(&e) ); None @@ -685,8 +686,8 @@ fn resolve_plugin_path(game_type: GameType, data_path: &Path, plugin_path: &Path if game_type != GameType::OpenMW && !plugin_path.exists() { if let Some(filename) = plugin_path.file_name() { logging::debug!( - "Could not find plugin at {}, adding {} file extension", - plugin_path.display(), + "Could not find plugin at \"{}\", adding {} file extension", + escape_ascii(&plugin_path), GHOST_FILE_EXTENSION ); let mut filename = filename.to_os_string(); diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index 72705097..d1b11629 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -5,7 +5,7 @@ use std::{ use saphyr::{LoadableYamlNode, MarkedYaml, YamlData}; -use crate::logging; +use crate::{escape_ascii, logging}; use super::{ error::{ @@ -37,7 +37,7 @@ impl MetadataDocument { )); } - logging::trace!("Loading file: {:?}", file_path); + logging::trace!("Loading file at \"{}\"", escape_ascii(file_path)); let content = std::fs::read_to_string(file_path) .map_err(|e| LoadMetadataError::from_io_error(file_path.into(), e))?; @@ -46,8 +46,8 @@ impl MetadataDocument { .map_err(|e| LoadMetadataError::new(file_path.into(), e))?; logging::trace!( - "Successfully loaded metadata from file at \"{:?}\".", - file_path + "Successfully loaded metadata from file at \"{}\".", + escape_ascii(file_path) ); Ok(()) @@ -84,8 +84,8 @@ impl MetadataDocument { .map_err(|e| LoadMetadataError::new(masterlist_path.into(), e))?; logging::trace!( - "Successfully loaded metadata from file at \"{:?}\".", - masterlist_path + "Successfully loaded metadata from file at \"{}\".", + escape_ascii(masterlist_path) ); Ok(()) @@ -198,7 +198,7 @@ impl MetadataDocument { } pub fn save(&self, file_path: &Path) -> Result<(), WriteMetadataError> { - logging::trace!("Saving metadata list to: {}", file_path.display()); + logging::trace!("Saving metadata list to: \"{}\"", escape_ascii(file_path)); let mut emitter = YamlEmitter::new(); diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 778e5924..3cff3a2e 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -15,7 +15,7 @@ use fancy_regex::{Error as RegexImplError, Regex}; use crate::{ GameType, archive::{assets_in_archives, do_assets_overlap, find_associated_archives}, - case_insensitive_regex, + case_insensitive_regex, escape_ascii, game::GameCache, logging, metadata::plugin_metadata::trim_dot_ghost, @@ -289,7 +289,7 @@ pub(crate) fn validate_plugin_path_and_header( } else if !has_plugin_file_extension(game_type, plugin_path) { logging::debug!( "The file \"{}\" is not a valid plugin", - plugin_path.display() + escape_ascii(plugin_path) ); Err(PluginValidationError::invalid( plugin_path.into(), @@ -301,7 +301,7 @@ pub(crate) fn validate_plugin_path_and_header( } else { logging::debug!( "The file \"{}\" is not a valid plugin", - plugin_path.display() + escape_ascii(plugin_path) ); Err(PluginValidationError::new( plugin_path.into(), From 011d67fef8cea8a72178d7f7ac91911ef00747fa Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 28 Apr 2025 17:47:00 +0100 Subject: [PATCH 121/206] Fix YAML merge key error message Print the YAML struct as debug text if emitting it fails. --- src/metadata/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/metadata/error.rs b/src/metadata/error.rs index 8414955a..523031b2 100644 --- a/src/metadata/error.rs +++ b/src/metadata/error.rs @@ -341,7 +341,7 @@ impl YamlMergeKeyError { yaml = yaml.split_off(index); } } else { - yaml = format!("{yaml:?}"); + yaml = format!("{unmarked_yaml:?}"); } YamlMergeKeyError { From bdc79c662c581aa33d06da2dcc198485d1de8a8f Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 28 Apr 2025 17:47:22 +0100 Subject: [PATCH 122/206] Avoid using debug formatting for logged edge stack --- src/sorting/plugins.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 7c4bf892..800d8ec4 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -1089,15 +1089,22 @@ impl<'a, 'b, 'c, 'd, 'e, T: SortingPlugin> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e, edge_stack_index: usize, target_plugins: &[PluginNodeIndex], ) { + use std::fmt::Write; + let Some([from_edge, edges @ ..]) = self.edge_stack.get(edge_stack_index..) else { if is_log_enabled(LogLevel::Error) { logging::error!( - "Unexpected invalid edge stack index {} for edge stack {:?}", + "Unexpected invalid edge stack index {} for edge stack [{}]", edge_stack_index, self.edge_stack .iter() .map(|e| e.0.weight()) - .collect::>() + .fold(String::new(), |mut a, b| if a.is_empty() { + b.to_string() + } else { + let _e = write!(a, ", {b}"); + a + }) ); } return; From 98f82cc21d3bede9a4c6d5c61283f3b331832afc Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 28 Apr 2025 17:47:38 +0100 Subject: [PATCH 123/206] Move Cargo MSVC debug config --- .cargo/msvcd-config.toml => cxx/.cargo/msvc-debug-config.toml | 0 cxx/CMakeLists.txt | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename .cargo/msvcd-config.toml => cxx/.cargo/msvc-debug-config.toml (100%) diff --git a/.cargo/msvcd-config.toml b/cxx/.cargo/msvc-debug-config.toml similarity index 100% rename from .cargo/msvcd-config.toml rename to cxx/.cargo/msvc-debug-config.toml diff --git a/cxx/CMakeLists.txt b/cxx/CMakeLists.txt index b94952b8..234a129e 100644 --- a/cxx/CMakeLists.txt +++ b/cxx/CMakeLists.txt @@ -40,7 +40,7 @@ add_custom_target(libloot-cxx-build COMMAND cargo build $<$>:--release> $<$,$>:--config> - $<$,$>:${CMAKE_SOURCE_DIR}/../.cargo/msvcd-config.toml> + $<$,$>:${CMAKE_SOURCE_DIR}/.cargo/msvc-debug-config.toml> BYPRODUCTS "${TARGET_PATH}/$,debug,release>/${LIBLOOT_CXX_FILENAME}" "${TARGET_PATH}/cxxbridge/libloot-cxx/src/lib.rs.cc" From 21d500893aabbf33965ab413d6270bf932d8ddb5 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 28 Apr 2025 17:47:52 +0100 Subject: [PATCH 124/206] Remove irrelevant test file --- .../metadata/condition_evaluator_test.h | 267 ------------------ 1 file changed, 267 deletions(-) delete mode 100644 cxx/src/tests/api/internals/metadata/condition_evaluator_test.h diff --git a/cxx/src/tests/api/internals/metadata/condition_evaluator_test.h b/cxx/src/tests/api/internals/metadata/condition_evaluator_test.h deleted file mode 100644 index deab40c3..00000000 --- a/cxx/src/tests/api/internals/metadata/condition_evaluator_test.h +++ /dev/null @@ -1,267 +0,0 @@ -/* LOOT - -A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and -Fallout: New Vegas. - -Copyright (C) 2014-2016 WrinklyNinja - -This file is part of LOOT. - -LOOT is free software: you can redistribute -it and/or modify it under the terms of the GNU General Public License -as published by the Free Software Foundation, either version 3 of -the License, or (at your option) any later version. - -LOOT is distributed in the hope that it will -be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with LOOT. If not, see -. -*/ - -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_CONDITION_EVALUATOR_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_CONDITION_EVALUATOR_TEST - -#include "api/metadata/condition_evaluator.h" -#include "loot/exception/condition_syntax_error.h" -#include "tests/common_game_test_fixture.h" - -namespace loot { -namespace test { -class ConditionEvaluatorTest : public CommonGameTestFixture, - public testing::WithParamInterface { -protected: - ConditionEvaluatorTest() : - CommonGameTestFixture(GetParam()), - info_(std::vector({ - MessageContent("info"), - })), - nonAsciiEsm(u8"non\u00C1scii.esm"), - nonAsciiNestedFile(u8"non\u00C1scii/test.txt"), - game_(GetParam(), gamePath, localPath), - evaluator_(game_.GetType(), game_.DataPath()) { - // Make sure the plugin with a non-ASCII filename exists. - std::filesystem::copy_file(dataPath / blankEsm, - dataPath / std::filesystem::u8path(nonAsciiEsm)); - - touch(dataPath / std::filesystem::u8path(nonAsciiNestedFile)); - - loadInstalledPlugins(); - evaluator_.RefreshLoadedPluginsState(game_.GetLoadedPlugins()); - evaluator_.RefreshActivePluginsState( - game_.GetLoadOrderHandler().GetActivePlugins()); - } - - std::string IntToHexString(const uint32_t value) { - std::stringstream stream; - stream << std::hex << value; - return stream.str(); - } - - void loadInstalledPlugins() { - auto plugins = GetInstalledPlugins(); - - plugins.push_back(std::filesystem::u8path(nonAsciiEsm)); - - game_.LoadCurrentLoadOrderState(); - game_.LoadPlugins(plugins, true); - } - - const std::vector info_; - const std::string nonAsciiEsm; - const std::string nonAsciiNestedFile; - - Game game_; - ConditionEvaluator evaluator_; -}; - -// Pass an empty first argument, as it's a prefix for the test instantation, -// but we only have the one so no prefix is necessary. -INSTANTIATE_TEST_SUITE_P(, - ConditionEvaluatorTest, - ::testing::Values(GameType::tes3, - GameType::tes4, - GameType::tes5, - GameType::fo3, - GameType::fonv, - GameType::fo4, - GameType::tes5se, - GameType::openmw)); - -TEST_P(ConditionEvaluatorTest, - evaluateShouldReturnTrueForAnEmptyConditionString) { - EXPECT_TRUE(evaluator_.Evaluate("")); -} - -TEST_P(ConditionEvaluatorTest, evaluateShouldThrowForAnInvalidConditionString) { - EXPECT_THROW(evaluator_.Evaluate("condition"), ConditionSyntaxError); -} - -TEST_P(ConditionEvaluatorTest, - evaluateShouldReturnTrueForAConditionThatIsTrue) { - EXPECT_TRUE(evaluator_.Evaluate("file(\"" + blankEsm + "\")")); -} - -TEST_P(ConditionEvaluatorTest, evaluateShouldUseAllGivenDataPaths) { - ASSERT_FALSE( - evaluator_.Evaluate("file(\"" + localPath.filename().u8string() + "\")")); - - evaluator_.ClearConditionCache(); - evaluator_.SetAdditionalDataPaths({localPath.parent_path()}); - - EXPECT_TRUE( - evaluator_.Evaluate("file(\"" + localPath.filename().u8string() + "\")")); -} - -TEST_P(ConditionEvaluatorTest, - evaluateFileConditionShouldReturnTrueForANonAsciiFileThatExists) { - EXPECT_TRUE(evaluator_.Evaluate("file(\"" + nonAsciiEsm + "\")")); -} - -TEST_P(ConditionEvaluatorTest, - evaluateChecksumConditionShouldBeAbleToGetTheCrcOfANonAsciiFile) { - std::string condition("checksum(\"" + nonAsciiEsm + "\", " + - IntToHexString(blankEsmCrc) + ")"); - EXPECT_TRUE(evaluator_.Evaluate(condition)); -} - -TEST_P(ConditionEvaluatorTest, - evaluateVersionConditionShouldBeAbleToGetTheVersionOfANonAsciiFile) { - std::string condition("version(\"" + nonAsciiEsm + "\", \"5.0\", ==)"); - EXPECT_TRUE(evaluator_.Evaluate(condition)); -} - -TEST_P(ConditionEvaluatorTest, - evaluateActiveConditionShouldReturnTrueForAnActivePlugin) { - std::string condition("active(\"" + blankEsm + "\")"); - EXPECT_TRUE(evaluator_.Evaluate(condition)); -} - -TEST_P(ConditionEvaluatorTest, - evaluateRegexFileConditionShouldReturnTrueForANonAsciiFileThatExists) { - std::string condition(u8"file(\"non\u00C1scii.*\\.esm\")"); - EXPECT_TRUE(evaluator_.Evaluate(condition)); -} - -TEST_P( - ConditionEvaluatorTest, - evaluateRegexFileConditionShouldReturnTrueForANonAsciiNestedFileThatExists) { - std::string condition(u8"file(\"non\u00C1scii/.+\\.txt\")"); - EXPECT_TRUE(evaluator_.Evaluate(condition)); -} - -TEST_P(ConditionEvaluatorTest, - evaluateShouldReturnFalseForAConditionThatIsFalse) { - EXPECT_FALSE(evaluator_.Evaluate("file(\"" + missingEsp + "\")")); -} - -TEST_P(ConditionEvaluatorTest, evaluateAllShouldEvaluateAllMetadataConditions) { - PluginMetadata plugin(nonAsciiEsm); - plugin.SetGroup("group1"); - - File file1(blankEsp); - File file2(blankDifferentEsm, "", "file(\"" + missingEsp + "\")"); - plugin.SetLoadAfterFiles({file1, file2}); - plugin.SetRequirements({file1, file2}); - plugin.SetIncompatibilities({file1, file2}); - - Message message1(MessageType::say, "content"); - Message message2(MessageType::say, "content", "file(\"" + missingEsp + "\")"); - plugin.SetMessages({message1, message2}); - - Tag tag1("Relev"); - Tag tag2("Relev", true, "file(\"" + missingEsp + "\")"); - plugin.SetTags({tag1, tag2}); - - PluginCleaningData info1(blankEsmCrc, "utility", info_, 1, 2, 3); - PluginCleaningData info2(0xDEADBEEF, "utility", info_, 1, 2, 3); - plugin.SetDirtyInfo({info1, info2}); - plugin.SetCleanInfo({info1, info2}); - - EXPECT_NO_THROW(plugin = evaluator_.EvaluateAll(plugin)); - - std::vector expectedFiles({file1}); - EXPECT_EQ("group1", plugin.GetGroup().value()); - EXPECT_EQ(expectedFiles, plugin.GetLoadAfterFiles()); - EXPECT_EQ(expectedFiles, plugin.GetRequirements()); - EXPECT_EQ(expectedFiles, plugin.GetIncompatibilities()); - EXPECT_EQ(std::vector({message1}), plugin.GetMessages()); - EXPECT_EQ(std::vector({tag1}), plugin.GetTags()); - EXPECT_EQ(std::vector({info1}), plugin.GetDirtyInfo()); - EXPECT_EQ(std::vector({info1}), plugin.GetCleanInfo()); -} - -TEST_P(ConditionEvaluatorTest, evaluateAllShouldPreserveGroupExplicitness) { - PluginMetadata plugin(blankEsm); - - EXPECT_NO_THROW(plugin = evaluator_.EvaluateAll(plugin)); - EXPECT_FALSE(plugin.GetGroup()); -} - -TEST_P(ConditionEvaluatorTest, - refreshActivePluginsStateShouldClearTheConditionCache) { - std::string condition("active(\"" + blankEsm + "\")"); - ASSERT_TRUE(evaluator_.Evaluate(condition)); - - evaluator_.RefreshActivePluginsState({blankEsp}); - - EXPECT_FALSE(evaluator_.Evaluate(condition)); -} - -TEST_P( - ConditionEvaluatorTest, - refreshActivePluginsStateShouldClearTheActivePluginsCacheIfGivenAnEmptyVector) { - std::string condition("active(\"" + blankEsm + "\")"); - ASSERT_TRUE(evaluator_.Evaluate(condition)); - - evaluator_.RefreshActivePluginsState({}); - - EXPECT_FALSE(evaluator_.Evaluate(condition)); -} - -TEST_P(ConditionEvaluatorTest, - refreshLoadedPluginsStateShouldClearTheConditionCache) { - std::string condition("version(\"" + blankEsm + "\", \"5.0\", ==)"); - - ASSERT_TRUE(evaluator_.Evaluate(condition)); - - auto plugins = game_.GetLoadedPlugins(); - auto pluginsIt = - std::find_if(plugins.cbegin(), plugins.cend(), [&](auto plugin) { - return plugin->GetName() == blankEsm; - }); - plugins.erase(pluginsIt); - evaluator_.RefreshLoadedPluginsState(plugins); - - EXPECT_FALSE(evaluator_.Evaluate(condition)); -} - -TEST_P( - ConditionEvaluatorTest, - refreshLoadedPluginsStateShouldClearTheVersionsCacheIfGivenAnEmptyVector) { - std::string condition("version(\"" + blankEsm + "\", \"5.0\", ==)"); - - ASSERT_TRUE(evaluator_.Evaluate(condition)); - - evaluator_.RefreshLoadedPluginsState({}); - - EXPECT_FALSE(evaluator_.Evaluate(condition)); -} - -TEST_P(ConditionEvaluatorTest, - setAdditionalDataPathsShouldAcceptAnEmptyVector) { - EXPECT_NO_THROW(evaluator_.SetAdditionalDataPaths({})); -} - -TEST_P(ConditionEvaluatorTest, - setAdditionalDataPathsShouldAcceptANonEmptyVector) { - EXPECT_NO_THROW(evaluator_.SetAdditionalDataPaths( - {std::filesystem::u8path("a"), std::filesystem::u8path("b")})); -} -} -} - -#endif From 3553b02644cf80a7fd69789652f62ee1915b5f9f Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 29 Apr 2025 17:20:11 +0100 Subject: [PATCH 125/206] Fix clippy not linting logging format args --- src/game.rs | 6 +++--- src/logging.rs | 6 ++++++ src/sorting/groups.rs | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/game.rs b/src/game.rs index 8ec5923a..c70d5a3e 100644 --- a/src/game.rs +++ b/src/game.rs @@ -390,7 +390,7 @@ impl Game { self.cache.set_archive_paths(archive_paths); - logging::trace!("Starting loading {}s.", load_scope); + logging::trace!("Starting loading {load_scope}s."); let plugins: Vec<_> = plugin_paths .par_iter() @@ -462,7 +462,7 @@ impl Game { if is_log_enabled(LogLevel::Debug) { logging::debug!("Current load order:"); for plugin_name in plugin_names { - logging::debug!("\t{}", plugin_name); + logging::debug!("\t{plugin_name}"); } } @@ -477,7 +477,7 @@ impl Game { if is_log_enabled(LogLevel::Debug) { logging::debug!("Sorted load order:"); for plugin_name in &new_load_order { - logging::debug!("\t{}", plugin_name); + logging::debug!("\t{plugin_name}"); } } diff --git a/src/logging.rs b/src/logging.rs index f444c29f..b0023491 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -105,6 +105,7 @@ impl Logger { } } +#[clippy::format_args] macro_rules! log { ($level:expr, $($arg:tt)+) => { // Log using the Rust log crate, as it's probably good to support that. @@ -123,22 +124,27 @@ macro_rules! log { }; } +#[clippy::format_args] macro_rules! error { ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Error, $($arg)+) }; } +#[clippy::format_args] macro_rules! warning { ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Warning, $($arg)+) }; } +#[clippy::format_args] macro_rules! info { ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Info, $($arg)+) }; } +#[clippy::format_args] macro_rules! debug { ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Debug, $($arg)+) }; } +#[clippy::format_args] macro_rules! trace { ($($arg:tt)+) => { $crate::logging::log!(crate::LogLevel::Trace, $($arg)+) }; } diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs index d152a334..25f5c992 100644 --- a/src/sorting/groups.rs +++ b/src/sorting/groups.rs @@ -197,7 +197,7 @@ fn find_node_by_weight( { Ok(n) } else { - logging::error!("Can't find group with name {}", weight); + logging::error!("Can't find group with name {weight}"); Err(UndefinedGroupError::new(weight.to_owned())) } } From 2b1b67daba1d8b0bf6147080d1dbc9ab37b5c469 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 28 Apr 2025 18:02:15 +0100 Subject: [PATCH 126/206] Rename pyo3 directory to python Also update the crate's name. --- .github/workflows/ci.yml | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- {pyo3 => python}/.github/workflows/CI.yml | 0 {pyo3 => python}/.gitignore | 0 {pyo3 => python}/Cargo.toml | 2 +- {pyo3 => python}/README.md | 2 +- {pyo3 => python}/pyproject.toml | 0 {pyo3 => python}/src/database.rs | 0 {pyo3 => python}/src/error.rs | 0 {pyo3 => python}/src/game.rs | 0 {pyo3 => python}/src/lib.rs | 0 {pyo3 => python}/src/metadata.rs | 0 {pyo3 => python}/src/plugin.rs | 0 15 files changed, 6 insertions(+), 6 deletions(-) rename {pyo3 => python}/.github/workflows/CI.yml (100%) rename {pyo3 => python}/.gitignore (100%) rename {pyo3 => python}/Cargo.toml (92%) rename {pyo3 => python}/README.md (98%) rename {pyo3 => python}/pyproject.toml (100%) rename {pyo3 => python}/src/database.rs (100%) rename {pyo3 => python}/src/error.rs (100%) rename {pyo3 => python}/src/game.rs (100%) rename {pyo3 => python}/src/lib.rs (100%) rename {pyo3 => python}/src/metadata.rs (100%) rename {pyo3 => python}/src/plugin.rs (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81b13039..81bfe344 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,7 +99,7 @@ jobs: - name: Build the Python wrapper shell: bash - working-directory: pyo3 + working-directory: python run: | python -m venv .venv diff --git a/Cargo.lock b/Cargo.lock index 4ba65a55..0d506cdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -598,7 +598,7 @@ dependencies = [ ] [[package]] -name = "libloot_pyo3" +name = "libloot_python" version = "0.26.0" dependencies = [ "libloot", diff --git a/Cargo.toml b/Cargo.toml index 9f4eccd6..bb69d325 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ parameterized-test = { path = "./parameterized-test" } tempfile = "3.17.1" [workspace] -members = ["cxx", "ffi-errors", "nodejs", "parameterized-test", "pyo3"] +members = ["cxx", "ffi-errors", "nodejs", "parameterized-test", "python"] [workspace.dependencies] esplugin = "6.1.2" diff --git a/README.md b/README.md index 0e94e94d..f3114a65 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This is an **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++, that should match libloot v0.26.0. -See the `cxx` and `pyo3` subdirectories for C++ and Python wrappers respectively. +See the `cxx` and `python` subdirectories for C++ and Python wrappers respectively. Expect: diff --git a/pyo3/.github/workflows/CI.yml b/python/.github/workflows/CI.yml similarity index 100% rename from pyo3/.github/workflows/CI.yml rename to python/.github/workflows/CI.yml diff --git a/pyo3/.gitignore b/python/.gitignore similarity index 100% rename from pyo3/.gitignore rename to python/.gitignore diff --git a/pyo3/Cargo.toml b/python/Cargo.toml similarity index 92% rename from pyo3/Cargo.toml rename to python/Cargo.toml index 6579b46c..529f1d93 100644 --- a/pyo3/Cargo.toml +++ b/python/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "libloot_pyo3" +name = "libloot_python" version = "0.26.0" edition = "2024" diff --git a/pyo3/README.md b/python/README.md similarity index 98% rename from pyo3/README.md rename to python/README.md index 588f7161..d4afaa36 100644 --- a/pyo3/README.md +++ b/python/README.md @@ -1,4 +1,4 @@ -# libloot-pyo3 +# libloot-python An **experimental** Python wrapper around the libloot Rust implementation, built using [PyO3](https://pyo3.rs). diff --git a/pyo3/pyproject.toml b/python/pyproject.toml similarity index 100% rename from pyo3/pyproject.toml rename to python/pyproject.toml diff --git a/pyo3/src/database.rs b/python/src/database.rs similarity index 100% rename from pyo3/src/database.rs rename to python/src/database.rs diff --git a/pyo3/src/error.rs b/python/src/error.rs similarity index 100% rename from pyo3/src/error.rs rename to python/src/error.rs diff --git a/pyo3/src/game.rs b/python/src/game.rs similarity index 100% rename from pyo3/src/game.rs rename to python/src/game.rs diff --git a/pyo3/src/lib.rs b/python/src/lib.rs similarity index 100% rename from pyo3/src/lib.rs rename to python/src/lib.rs diff --git a/pyo3/src/metadata.rs b/python/src/metadata.rs similarity index 100% rename from pyo3/src/metadata.rs rename to python/src/metadata.rs diff --git a/pyo3/src/plugin.rs b/python/src/plugin.rs similarity index 100% rename from pyo3/src/plugin.rs rename to python/src/plugin.rs From 83d9420422856e6b65017c78b9586f03f55438e0 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 28 Apr 2025 18:10:57 +0100 Subject: [PATCH 127/206] Rename cxx directory to cpp Also update the crate name and references to it. This helps distinguish the C++ wrapper that's built using CXX from CXX itself. --- .github/workflows/ci.yml | 14 ++++------ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- {cxx => cpp}/.cargo/msvc-debug-config.toml | 0 {cxx => cpp}/.clang-format | 0 {cxx => cpp}/.gitignore | 0 {cxx => cpp}/CMakeLists.txt | 28 ++++++++++--------- {cxx => cpp}/Cargo.toml | 2 +- {cxx => cpp}/README.md | 0 {cxx => cpp}/build.rs | 2 +- {cxx => cpp}/cmake/Config.cmake.in | 0 {cxx => cpp}/cmake/tests.cmake | 2 +- {cxx => cpp}/include/loot/api.h | 0 {cxx => cpp}/include/loot/api_decorator.h | 0 .../include/loot/database_interface.h | 0 {cxx => cpp}/include/loot/enum/edge_type.h | 0 {cxx => cpp}/include/loot/enum/game_type.h | 0 {cxx => cpp}/include/loot/enum/log_level.h | 0 {cxx => cpp}/include/loot/enum/message_type.h | 0 .../loot/exception/condition_syntax_error.h | 0 .../loot/exception/cyclic_interaction_error.h | 0 .../include/loot/exception/error_categories.h | 0 .../loot/exception/file_access_error.h | 0 .../loot/exception/undefined_group_error.h | 0 {cxx => cpp}/include/loot/game_interface.h | 0 {cxx => cpp}/include/loot/loot_version.h | 0 .../loot/metadata/conditional_metadata.h | 0 {cxx => cpp}/include/loot/metadata/file.h | 0 {cxx => cpp}/include/loot/metadata/filename.h | 0 {cxx => cpp}/include/loot/metadata/group.h | 0 {cxx => cpp}/include/loot/metadata/location.h | 0 {cxx => cpp}/include/loot/metadata/message.h | 0 .../include/loot/metadata/message_content.h | 0 .../loot/metadata/plugin_cleaning_data.h | 0 .../include/loot/metadata/plugin_metadata.h | 0 {cxx => cpp}/include/loot/metadata/tag.h | 0 {cxx => cpp}/include/loot/plugin_interface.h | 0 {cxx => cpp}/include/loot/vertex.h | 0 {cxx => cpp}/src/api/api.cpp | 2 +- {cxx => cpp}/src/api/convert.cpp | 0 {cxx => cpp}/src/api/convert.h | 2 +- {cxx => cpp}/src/api/database.cpp | 0 {cxx => cpp}/src/api/database.h | 2 +- {cxx => cpp}/src/api/error_categories.cpp | 0 {cxx => cpp}/src/api/exception.cpp | 0 {cxx => cpp}/src/api/exception.h | 0 {cxx => cpp}/src/api/game.cpp | 0 {cxx => cpp}/src/api/game.h | 2 +- .../src/api/metadata/conditional_metadata.cpp | 0 {cxx => cpp}/src/api/metadata/file.cpp | 0 {cxx => cpp}/src/api/metadata/filename.cpp | 2 +- {cxx => cpp}/src/api/metadata/group.cpp | 0 {cxx => cpp}/src/api/metadata/location.cpp | 0 {cxx => cpp}/src/api/metadata/message.cpp | 0 .../src/api/metadata/message_content.cpp | 0 .../src/api/metadata/plugin_cleaning_data.cpp | 0 .../src/api/metadata/plugin_metadata.cpp | 2 +- {cxx => cpp}/src/api/metadata/tag.cpp | 0 {cxx => cpp}/src/api/plugin.cpp | 0 {cxx => cpp}/src/api/plugin.h | 2 +- {cxx => cpp}/src/api/resource.rc | 0 .../api/sorting/cyclic_interaction_error.cpp | 0 .../src/api/sorting/undefined_group_error.cpp | 0 {cxx => cpp}/src/api/vertex.cpp | 0 {cxx => cpp}/src/database.rs | 0 {cxx => cpp}/src/error.rs | 0 {cxx => cpp}/src/game.rs | 0 {cxx => cpp}/src/lib.rs | 0 {cxx => cpp}/src/metadata.rs | 0 {cxx => cpp}/src/plugin.rs | 0 .../api/interface/api_game_operations_test.h | 0 .../api/interface/create_game_handle_test.h | 0 .../api/interface/database_interface_test.h | 0 .../tests/api/interface/game_interface_test.h | 0 .../tests/api/interface/is_compatible_test.h | 0 {cxx => cpp}/src/tests/api/interface/main.cpp | 0 {cxx => cpp}/src/tests/api/internals/main.cpp | 2 +- .../metadata/conditional_metadata_test.h | 0 .../tests/api/internals/metadata/file_test.h | 0 .../tests/api/internals/metadata/group_test.h | 0 .../api/internals/metadata/location_test.h | 0 .../internals/metadata/message_content_test.h | 0 .../api/internals/metadata/message_test.h | 0 .../metadata/plugin_cleaning_data_test.h | 0 .../internals/metadata/plugin_metadata_test.h | 0 .../tests/api/internals/metadata/tag_test.h | 0 .../src/tests/common_game_test_fixture.h | 0 {cxx => cpp}/src/tests/printers.h | 0 {cxx => cpp}/src/tests/test_helpers.h | 0 90 files changed, 34 insertions(+), 36 deletions(-) rename {cxx => cpp}/.cargo/msvc-debug-config.toml (100%) rename {cxx => cpp}/.clang-format (100%) rename {cxx => cpp}/.gitignore (100%) rename {cxx => cpp}/CMakeLists.txt (93%) rename {cxx => cpp}/Cargo.toml (93%) rename {cxx => cpp}/README.md (100%) rename {cxx => cpp}/build.rs (95%) rename {cxx => cpp}/cmake/Config.cmake.in (100%) rename {cxx => cpp}/cmake/tests.cmake (99%) rename {cxx => cpp}/include/loot/api.h (100%) rename {cxx => cpp}/include/loot/api_decorator.h (100%) rename {cxx => cpp}/include/loot/database_interface.h (100%) rename {cxx => cpp}/include/loot/enum/edge_type.h (100%) rename {cxx => cpp}/include/loot/enum/game_type.h (100%) rename {cxx => cpp}/include/loot/enum/log_level.h (100%) rename {cxx => cpp}/include/loot/enum/message_type.h (100%) rename {cxx => cpp}/include/loot/exception/condition_syntax_error.h (100%) rename {cxx => cpp}/include/loot/exception/cyclic_interaction_error.h (100%) rename {cxx => cpp}/include/loot/exception/error_categories.h (100%) rename {cxx => cpp}/include/loot/exception/file_access_error.h (100%) rename {cxx => cpp}/include/loot/exception/undefined_group_error.h (100%) rename {cxx => cpp}/include/loot/game_interface.h (100%) rename {cxx => cpp}/include/loot/loot_version.h (100%) rename {cxx => cpp}/include/loot/metadata/conditional_metadata.h (100%) rename {cxx => cpp}/include/loot/metadata/file.h (100%) rename {cxx => cpp}/include/loot/metadata/filename.h (100%) rename {cxx => cpp}/include/loot/metadata/group.h (100%) rename {cxx => cpp}/include/loot/metadata/location.h (100%) rename {cxx => cpp}/include/loot/metadata/message.h (100%) rename {cxx => cpp}/include/loot/metadata/message_content.h (100%) rename {cxx => cpp}/include/loot/metadata/plugin_cleaning_data.h (100%) rename {cxx => cpp}/include/loot/metadata/plugin_metadata.h (100%) rename {cxx => cpp}/include/loot/metadata/tag.h (100%) rename {cxx => cpp}/include/loot/plugin_interface.h (100%) rename {cxx => cpp}/include/loot/vertex.h (100%) rename {cxx => cpp}/src/api/api.cpp (98%) rename {cxx => cpp}/src/api/convert.cpp (100%) rename {cxx => cpp}/src/api/convert.h (98%) rename {cxx => cpp}/src/api/database.cpp (100%) rename {cxx => cpp}/src/api/database.h (98%) rename {cxx => cpp}/src/api/error_categories.cpp (100%) rename {cxx => cpp}/src/api/exception.cpp (100%) rename {cxx => cpp}/src/api/exception.h (100%) rename {cxx => cpp}/src/api/game.cpp (100%) rename {cxx => cpp}/src/api/game.h (98%) rename {cxx => cpp}/src/api/metadata/conditional_metadata.cpp (100%) rename {cxx => cpp}/src/api/metadata/file.cpp (100%) rename {cxx => cpp}/src/api/metadata/filename.cpp (97%) rename {cxx => cpp}/src/api/metadata/group.cpp (100%) rename {cxx => cpp}/src/api/metadata/location.cpp (100%) rename {cxx => cpp}/src/api/metadata/message.cpp (100%) rename {cxx => cpp}/src/api/metadata/message_content.cpp (100%) rename {cxx => cpp}/src/api/metadata/plugin_cleaning_data.cpp (100%) rename {cxx => cpp}/src/api/metadata/plugin_metadata.cpp (99%) rename {cxx => cpp}/src/api/metadata/tag.cpp (100%) rename {cxx => cpp}/src/api/plugin.cpp (100%) rename {cxx => cpp}/src/api/plugin.h (97%) rename {cxx => cpp}/src/api/resource.rc (100%) rename {cxx => cpp}/src/api/sorting/cyclic_interaction_error.cpp (100%) rename {cxx => cpp}/src/api/sorting/undefined_group_error.cpp (100%) rename {cxx => cpp}/src/api/vertex.cpp (100%) rename {cxx => cpp}/src/database.rs (100%) rename {cxx => cpp}/src/error.rs (100%) rename {cxx => cpp}/src/game.rs (100%) rename {cxx => cpp}/src/lib.rs (100%) rename {cxx => cpp}/src/metadata.rs (100%) rename {cxx => cpp}/src/plugin.rs (100%) rename {cxx => cpp}/src/tests/api/interface/api_game_operations_test.h (100%) rename {cxx => cpp}/src/tests/api/interface/create_game_handle_test.h (100%) rename {cxx => cpp}/src/tests/api/interface/database_interface_test.h (100%) rename {cxx => cpp}/src/tests/api/interface/game_interface_test.h (100%) rename {cxx => cpp}/src/tests/api/interface/is_compatible_test.h (100%) rename {cxx => cpp}/src/tests/api/interface/main.cpp (100%) rename {cxx => cpp}/src/tests/api/internals/main.cpp (98%) rename {cxx => cpp}/src/tests/api/internals/metadata/conditional_metadata_test.h (100%) rename {cxx => cpp}/src/tests/api/internals/metadata/file_test.h (100%) rename {cxx => cpp}/src/tests/api/internals/metadata/group_test.h (100%) rename {cxx => cpp}/src/tests/api/internals/metadata/location_test.h (100%) rename {cxx => cpp}/src/tests/api/internals/metadata/message_content_test.h (100%) rename {cxx => cpp}/src/tests/api/internals/metadata/message_test.h (100%) rename {cxx => cpp}/src/tests/api/internals/metadata/plugin_cleaning_data_test.h (100%) rename {cxx => cpp}/src/tests/api/internals/metadata/plugin_metadata_test.h (100%) rename {cxx => cpp}/src/tests/api/internals/metadata/tag_test.h (100%) rename {cxx => cpp}/src/tests/common_game_test_fixture.h (100%) rename {cxx => cpp}/src/tests/printers.h (100%) rename {cxx => cpp}/src/tests/test_helpers.h (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81bfe344..0e835e55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,10 +124,6 @@ jobs: working-directory: nodejs run: npm test - - name: Build the CXX wrapper layer - working-directory: cxx - run: cargo build --release --target ${{ matrix.target.triple }} - - name: Get descriptive libloot version id: get-libloot-version shell: bash @@ -138,7 +134,7 @@ jobs: echo "version=$LIBLOOT_SAFE_DESC_REF" >> $GITHUB_OUTPUT - name: Build the C++ wrapper (Linux) - working-directory: cxx + working-directory: cpp run: | cmake \ -DCMAKE_BUILD_TYPE=${{ env.CMAKE_CONFIG }} \ @@ -149,7 +145,7 @@ jobs: if: runner.os == 'Linux' - name: Build the C++ wrapper (Windows) - working-directory: cxx + working-directory: cpp run: | cmake -G "Visual Studio 16 2019" ` -A ${{ matrix.target.platform }} ` @@ -160,11 +156,11 @@ jobs: if: runner.os == 'Windows' - name: Run the C++ wrapper tests - working-directory: cxx + working-directory: cpp run: ctest --test-dir build --build-config ${{ env.CMAKE_CONFIG }} --output-on-failure --parallel - name: Package the C++ wrapper - working-directory: cxx/build + working-directory: cpp/build run: cpack -C ${{ env.CMAKE_CONFIG }} - name: Get archive name @@ -193,5 +189,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ steps.archive-name.outputs.filename }} - path: cxx/build/package/${{ steps.archive-name.outputs.filename }} + path: cpp/build/package/${{ steps.archive-name.outputs.filename }} if: github.event_name == 'push' diff --git a/Cargo.lock b/Cargo.lock index 0d506cdc..098ab28e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -565,7 +565,7 @@ dependencies = [ ] [[package]] -name = "libloot-cxx" +name = "libloot-cpp" version = "0.26.0" dependencies = [ "cxx", diff --git a/Cargo.toml b/Cargo.toml index bb69d325..d73b7cc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ parameterized-test = { path = "./parameterized-test" } tempfile = "3.17.1" [workspace] -members = ["cxx", "ffi-errors", "nodejs", "parameterized-test", "python"] +members = ["cpp", "ffi-errors", "nodejs", "parameterized-test", "python"] [workspace.dependencies] esplugin = "6.1.2" diff --git a/README.md b/README.md index f3114a65..a36bea18 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This is an **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++, that should match libloot v0.26.0. -See the `cxx` and `python` subdirectories for C++ and Python wrappers respectively. +See the `cpp` and `python` subdirectories for C++ and Python wrappers respectively. Expect: diff --git a/cxx/.cargo/msvc-debug-config.toml b/cpp/.cargo/msvc-debug-config.toml similarity index 100% rename from cxx/.cargo/msvc-debug-config.toml rename to cpp/.cargo/msvc-debug-config.toml diff --git a/cxx/.clang-format b/cpp/.clang-format similarity index 100% rename from cxx/.clang-format rename to cpp/.clang-format diff --git a/cxx/.gitignore b/cpp/.gitignore similarity index 100% rename from cxx/.gitignore rename to cpp/.gitignore diff --git a/cxx/CMakeLists.txt b/cpp/CMakeLists.txt similarity index 93% rename from cxx/CMakeLists.txt rename to cpp/CMakeLists.txt index 234a129e..5f234e79 100644 --- a/cxx/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -25,9 +25,9 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) ############################## if(MSVC) - set(LIBLOOT_CXX_FILENAME "libloot_cxx.lib") + set(LIBLOOT_CPP_FILENAME "libloot_cpp.lib") else() - set(LIBLOOT_CXX_FILENAME "liblibloot_cxx.a") + set(LIBLOOT_CPP_FILENAME "liblibloot_cpp.a") endif() if(DEFINED RUST_TARGET) @@ -36,25 +36,27 @@ else() set(TARGET_PATH "${CMAKE_SOURCE_DIR}/../target") endif() -add_custom_target(libloot-cxx-build +add_custom_target(libloot-cpp-build COMMAND cargo build $<$>:--release> $<$,$>:--config> $<$,$>:${CMAKE_SOURCE_DIR}/.cargo/msvc-debug-config.toml> + $<$:--target> + $<$:${RUST_TARGET}> BYPRODUCTS - "${TARGET_PATH}/$,debug,release>/${LIBLOOT_CXX_FILENAME}" - "${TARGET_PATH}/cxxbridge/libloot-cxx/src/lib.rs.cc" - "${TARGET_PATH}/cxxbridge/libloot-cxx/src/lib.rs.h" + "${TARGET_PATH}/$,debug,release>/${LIBLOOT_CPP_FILENAME}" + "${TARGET_PATH}/cxxbridge/libloot-cpp/src/lib.rs.cc" + "${TARGET_PATH}/cxxbridge/libloot-cpp/src/lib.rs.h" "${TARGET_PATH}/cxxbridge/rust/cxx.h" ) -add_library(libloot-cxx INTERFACE) -add_dependencies(libloot-cxx libloot-cxx-build) +add_library(libloot-cpp INTERFACE) +add_dependencies(libloot-cpp libloot-cpp-build) -target_link_libraries(libloot-cxx INTERFACE - optimized "${TARGET_PATH}/release/${LIBLOOT_CXX_FILENAME}" - debug "${TARGET_PATH}/debug/${LIBLOOT_CXX_FILENAME}") -target_include_directories(libloot-cxx INTERFACE "${TARGET_PATH}/cxxbridge") +target_link_libraries(libloot-cpp INTERFACE + optimized "${TARGET_PATH}/release/${LIBLOOT_CPP_FILENAME}" + debug "${TARGET_PATH}/debug/${LIBLOOT_CPP_FILENAME}") +target_include_directories(libloot-cpp INTERFACE "${TARGET_PATH}/cxxbridge") ############################## @@ -142,7 +144,7 @@ set(LIBLOOT_ALL_SOURCES # Build API. add_library(loot ${LIBLOOT_ALL_SOURCES}) -target_link_libraries(loot PRIVATE libloot-cxx) +target_link_libraries(loot PRIVATE libloot-cpp) ############################## # Set Target-Specific Flags diff --git a/cxx/Cargo.toml b/cpp/Cargo.toml similarity index 93% rename from cxx/Cargo.toml rename to cpp/Cargo.toml index ae45aad3..6c44e949 100644 --- a/cxx/Cargo.toml +++ b/cpp/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "libloot-cxx" +name = "libloot-cpp" version = "0.26.0" edition = "2024" license = "GPL-3.0" diff --git a/cxx/README.md b/cpp/README.md similarity index 100% rename from cxx/README.md rename to cpp/README.md diff --git a/cxx/build.rs b/cpp/build.rs similarity index 95% rename from cxx/build.rs rename to cpp/build.rs index d805cf29..9e470942 100644 --- a/cxx/build.rs +++ b/cpp/build.rs @@ -3,7 +3,7 @@ fn main() { .std("c++17") .flag_if_supported("/Zc:__cplusplus") .flag_if_supported("/permissive-") - .compile("libloot-cxx"); + .compile("libloot-cpp"); // From if std::env::var("TARGET").is_ok_and(|s| s.contains("windows-msvc")) { diff --git a/cxx/cmake/Config.cmake.in b/cpp/cmake/Config.cmake.in similarity index 100% rename from cxx/cmake/Config.cmake.in rename to cpp/cmake/Config.cmake.in diff --git a/cxx/cmake/tests.cmake b/cpp/cmake/tests.cmake similarity index 99% rename from cxx/cmake/tests.cmake rename to cpp/cmake/tests.cmake index 8eed90d0..27c4636a 100644 --- a/cxx/cmake/tests.cmake +++ b/cpp/cmake/tests.cmake @@ -97,7 +97,7 @@ set(LIBLOOT_INTERFACE_TESTS_ALL_SOURCES # Build tests. add_executable(libloot_internals_tests ${LIBLOOT_INTERNALS_TESTS_ALL_SOURCES}) target_link_libraries(libloot_internals_tests PRIVATE - libloot-cxx + libloot-cpp GTest::gtest_main) # Build API tests. diff --git a/cxx/include/loot/api.h b/cpp/include/loot/api.h similarity index 100% rename from cxx/include/loot/api.h rename to cpp/include/loot/api.h diff --git a/cxx/include/loot/api_decorator.h b/cpp/include/loot/api_decorator.h similarity index 100% rename from cxx/include/loot/api_decorator.h rename to cpp/include/loot/api_decorator.h diff --git a/cxx/include/loot/database_interface.h b/cpp/include/loot/database_interface.h similarity index 100% rename from cxx/include/loot/database_interface.h rename to cpp/include/loot/database_interface.h diff --git a/cxx/include/loot/enum/edge_type.h b/cpp/include/loot/enum/edge_type.h similarity index 100% rename from cxx/include/loot/enum/edge_type.h rename to cpp/include/loot/enum/edge_type.h diff --git a/cxx/include/loot/enum/game_type.h b/cpp/include/loot/enum/game_type.h similarity index 100% rename from cxx/include/loot/enum/game_type.h rename to cpp/include/loot/enum/game_type.h diff --git a/cxx/include/loot/enum/log_level.h b/cpp/include/loot/enum/log_level.h similarity index 100% rename from cxx/include/loot/enum/log_level.h rename to cpp/include/loot/enum/log_level.h diff --git a/cxx/include/loot/enum/message_type.h b/cpp/include/loot/enum/message_type.h similarity index 100% rename from cxx/include/loot/enum/message_type.h rename to cpp/include/loot/enum/message_type.h diff --git a/cxx/include/loot/exception/condition_syntax_error.h b/cpp/include/loot/exception/condition_syntax_error.h similarity index 100% rename from cxx/include/loot/exception/condition_syntax_error.h rename to cpp/include/loot/exception/condition_syntax_error.h diff --git a/cxx/include/loot/exception/cyclic_interaction_error.h b/cpp/include/loot/exception/cyclic_interaction_error.h similarity index 100% rename from cxx/include/loot/exception/cyclic_interaction_error.h rename to cpp/include/loot/exception/cyclic_interaction_error.h diff --git a/cxx/include/loot/exception/error_categories.h b/cpp/include/loot/exception/error_categories.h similarity index 100% rename from cxx/include/loot/exception/error_categories.h rename to cpp/include/loot/exception/error_categories.h diff --git a/cxx/include/loot/exception/file_access_error.h b/cpp/include/loot/exception/file_access_error.h similarity index 100% rename from cxx/include/loot/exception/file_access_error.h rename to cpp/include/loot/exception/file_access_error.h diff --git a/cxx/include/loot/exception/undefined_group_error.h b/cpp/include/loot/exception/undefined_group_error.h similarity index 100% rename from cxx/include/loot/exception/undefined_group_error.h rename to cpp/include/loot/exception/undefined_group_error.h diff --git a/cxx/include/loot/game_interface.h b/cpp/include/loot/game_interface.h similarity index 100% rename from cxx/include/loot/game_interface.h rename to cpp/include/loot/game_interface.h diff --git a/cxx/include/loot/loot_version.h b/cpp/include/loot/loot_version.h similarity index 100% rename from cxx/include/loot/loot_version.h rename to cpp/include/loot/loot_version.h diff --git a/cxx/include/loot/metadata/conditional_metadata.h b/cpp/include/loot/metadata/conditional_metadata.h similarity index 100% rename from cxx/include/loot/metadata/conditional_metadata.h rename to cpp/include/loot/metadata/conditional_metadata.h diff --git a/cxx/include/loot/metadata/file.h b/cpp/include/loot/metadata/file.h similarity index 100% rename from cxx/include/loot/metadata/file.h rename to cpp/include/loot/metadata/file.h diff --git a/cxx/include/loot/metadata/filename.h b/cpp/include/loot/metadata/filename.h similarity index 100% rename from cxx/include/loot/metadata/filename.h rename to cpp/include/loot/metadata/filename.h diff --git a/cxx/include/loot/metadata/group.h b/cpp/include/loot/metadata/group.h similarity index 100% rename from cxx/include/loot/metadata/group.h rename to cpp/include/loot/metadata/group.h diff --git a/cxx/include/loot/metadata/location.h b/cpp/include/loot/metadata/location.h similarity index 100% rename from cxx/include/loot/metadata/location.h rename to cpp/include/loot/metadata/location.h diff --git a/cxx/include/loot/metadata/message.h b/cpp/include/loot/metadata/message.h similarity index 100% rename from cxx/include/loot/metadata/message.h rename to cpp/include/loot/metadata/message.h diff --git a/cxx/include/loot/metadata/message_content.h b/cpp/include/loot/metadata/message_content.h similarity index 100% rename from cxx/include/loot/metadata/message_content.h rename to cpp/include/loot/metadata/message_content.h diff --git a/cxx/include/loot/metadata/plugin_cleaning_data.h b/cpp/include/loot/metadata/plugin_cleaning_data.h similarity index 100% rename from cxx/include/loot/metadata/plugin_cleaning_data.h rename to cpp/include/loot/metadata/plugin_cleaning_data.h diff --git a/cxx/include/loot/metadata/plugin_metadata.h b/cpp/include/loot/metadata/plugin_metadata.h similarity index 100% rename from cxx/include/loot/metadata/plugin_metadata.h rename to cpp/include/loot/metadata/plugin_metadata.h diff --git a/cxx/include/loot/metadata/tag.h b/cpp/include/loot/metadata/tag.h similarity index 100% rename from cxx/include/loot/metadata/tag.h rename to cpp/include/loot/metadata/tag.h diff --git a/cxx/include/loot/plugin_interface.h b/cpp/include/loot/plugin_interface.h similarity index 100% rename from cxx/include/loot/plugin_interface.h rename to cpp/include/loot/plugin_interface.h diff --git a/cxx/include/loot/vertex.h b/cpp/include/loot/vertex.h similarity index 100% rename from cxx/include/loot/vertex.h rename to cpp/include/loot/vertex.h diff --git a/cxx/src/api/api.cpp b/cpp/src/api/api.cpp similarity index 98% rename from cxx/src/api/api.cpp rename to cpp/src/api/api.cpp index a2bcb6ae..a0252c34 100644 --- a/cxx/src/api/api.cpp +++ b/cpp/src/api/api.cpp @@ -3,7 +3,7 @@ #include "loot/api.h" #include "api/game.h" -#include "libloot-cxx/src/lib.rs.h" +#include "libloot-cpp/src/lib.rs.h" #include "rust/cxx.h" extern "C" { diff --git a/cxx/src/api/convert.cpp b/cpp/src/api/convert.cpp similarity index 100% rename from cxx/src/api/convert.cpp rename to cpp/src/api/convert.cpp diff --git a/cxx/src/api/convert.h b/cpp/src/api/convert.h similarity index 98% rename from cxx/src/api/convert.h rename to cpp/src/api/convert.h index 998c3d72..99cfae5c 100644 --- a/cxx/src/api/convert.h +++ b/cpp/src/api/convert.h @@ -1,7 +1,7 @@ #ifndef LOOT_API_CONVERT #define LOOT_API_CONVERT -#include "libloot-cxx/src/lib.rs.h" +#include "libloot-cpp/src/lib.rs.h" #include "loot/metadata/group.h" #include "loot/metadata/plugin_metadata.h" #include "loot/vertex.h" diff --git a/cxx/src/api/database.cpp b/cpp/src/api/database.cpp similarity index 100% rename from cxx/src/api/database.cpp rename to cpp/src/api/database.cpp diff --git a/cxx/src/api/database.h b/cpp/src/api/database.h similarity index 98% rename from cxx/src/api/database.h rename to cpp/src/api/database.h index 393625e9..34a370b0 100644 --- a/cxx/src/api/database.h +++ b/cpp/src/api/database.h @@ -1,7 +1,7 @@ #ifndef LOOT_API_DATABASE #define LOOT_API_DATABASE -#include "libloot-cxx/src/lib.rs.h" +#include "libloot-cpp/src/lib.rs.h" #include "loot/database_interface.h" #include "rust/cxx.h" diff --git a/cxx/src/api/error_categories.cpp b/cpp/src/api/error_categories.cpp similarity index 100% rename from cxx/src/api/error_categories.cpp rename to cpp/src/api/error_categories.cpp diff --git a/cxx/src/api/exception.cpp b/cpp/src/api/exception.cpp similarity index 100% rename from cxx/src/api/exception.cpp rename to cpp/src/api/exception.cpp diff --git a/cxx/src/api/exception.h b/cpp/src/api/exception.h similarity index 100% rename from cxx/src/api/exception.h rename to cpp/src/api/exception.h diff --git a/cxx/src/api/game.cpp b/cpp/src/api/game.cpp similarity index 100% rename from cxx/src/api/game.cpp rename to cpp/src/api/game.cpp diff --git a/cxx/src/api/game.h b/cpp/src/api/game.h similarity index 98% rename from cxx/src/api/game.h rename to cpp/src/api/game.h index 85fb4886..58c4e72c 100644 --- a/cxx/src/api/game.h +++ b/cpp/src/api/game.h @@ -5,7 +5,7 @@ #include "api/database.h" #include "api/plugin.h" -#include "libloot-cxx/src/lib.rs.h" +#include "libloot-cpp/src/lib.rs.h" #include "loot/game_interface.h" #include "loot/metadata/filename.h" #include "rust/cxx.h" diff --git a/cxx/src/api/metadata/conditional_metadata.cpp b/cpp/src/api/metadata/conditional_metadata.cpp similarity index 100% rename from cxx/src/api/metadata/conditional_metadata.cpp rename to cpp/src/api/metadata/conditional_metadata.cpp diff --git a/cxx/src/api/metadata/file.cpp b/cpp/src/api/metadata/file.cpp similarity index 100% rename from cxx/src/api/metadata/file.cpp rename to cpp/src/api/metadata/file.cpp diff --git a/cxx/src/api/metadata/filename.cpp b/cpp/src/api/metadata/filename.cpp similarity index 97% rename from cxx/src/api/metadata/filename.cpp rename to cpp/src/api/metadata/filename.cpp index 54a34964..9d24d90e 100644 --- a/cxx/src/api/metadata/filename.cpp +++ b/cpp/src/api/metadata/filename.cpp @@ -27,7 +27,7 @@ #include // #include "api/helpers/text.h" -#include "libloot-cxx/src/lib.rs.h" +#include "libloot-cpp/src/lib.rs.h" namespace loot { Filename::Filename(std::string_view filename) : filename_(filename) {} diff --git a/cxx/src/api/metadata/group.cpp b/cpp/src/api/metadata/group.cpp similarity index 100% rename from cxx/src/api/metadata/group.cpp rename to cpp/src/api/metadata/group.cpp diff --git a/cxx/src/api/metadata/location.cpp b/cpp/src/api/metadata/location.cpp similarity index 100% rename from cxx/src/api/metadata/location.cpp rename to cpp/src/api/metadata/location.cpp diff --git a/cxx/src/api/metadata/message.cpp b/cpp/src/api/metadata/message.cpp similarity index 100% rename from cxx/src/api/metadata/message.cpp rename to cpp/src/api/metadata/message.cpp diff --git a/cxx/src/api/metadata/message_content.cpp b/cpp/src/api/metadata/message_content.cpp similarity index 100% rename from cxx/src/api/metadata/message_content.cpp rename to cpp/src/api/metadata/message_content.cpp diff --git a/cxx/src/api/metadata/plugin_cleaning_data.cpp b/cpp/src/api/metadata/plugin_cleaning_data.cpp similarity index 100% rename from cxx/src/api/metadata/plugin_cleaning_data.cpp rename to cpp/src/api/metadata/plugin_cleaning_data.cpp diff --git a/cxx/src/api/metadata/plugin_metadata.cpp b/cpp/src/api/metadata/plugin_metadata.cpp similarity index 99% rename from cxx/src/api/metadata/plugin_metadata.cpp rename to cpp/src/api/metadata/plugin_metadata.cpp index 7430b95b..5163d278 100644 --- a/cxx/src/api/metadata/plugin_metadata.cpp +++ b/cpp/src/api/metadata/plugin_metadata.cpp @@ -29,7 +29,7 @@ #include #include "api/convert.h" -#include "libloot-cxx/src/lib.rs.h" +#include "libloot-cpp/src/lib.rs.h" namespace { // Append second to first, skipping any elements that are already present in diff --git a/cxx/src/api/metadata/tag.cpp b/cpp/src/api/metadata/tag.cpp similarity index 100% rename from cxx/src/api/metadata/tag.cpp rename to cpp/src/api/metadata/tag.cpp diff --git a/cxx/src/api/plugin.cpp b/cpp/src/api/plugin.cpp similarity index 100% rename from cxx/src/api/plugin.cpp rename to cpp/src/api/plugin.cpp diff --git a/cxx/src/api/plugin.h b/cpp/src/api/plugin.h similarity index 97% rename from cxx/src/api/plugin.h rename to cpp/src/api/plugin.h index 32107e02..d77321f3 100644 --- a/cxx/src/api/plugin.h +++ b/cpp/src/api/plugin.h @@ -8,7 +8,7 @@ #include #include -#include "libloot-cxx/src/lib.rs.h" +#include "libloot-cpp/src/lib.rs.h" #include "loot/metadata/tag.h" #include "loot/plugin_interface.h" diff --git a/cxx/src/api/resource.rc b/cpp/src/api/resource.rc similarity index 100% rename from cxx/src/api/resource.rc rename to cpp/src/api/resource.rc diff --git a/cxx/src/api/sorting/cyclic_interaction_error.cpp b/cpp/src/api/sorting/cyclic_interaction_error.cpp similarity index 100% rename from cxx/src/api/sorting/cyclic_interaction_error.cpp rename to cpp/src/api/sorting/cyclic_interaction_error.cpp diff --git a/cxx/src/api/sorting/undefined_group_error.cpp b/cpp/src/api/sorting/undefined_group_error.cpp similarity index 100% rename from cxx/src/api/sorting/undefined_group_error.cpp rename to cpp/src/api/sorting/undefined_group_error.cpp diff --git a/cxx/src/api/vertex.cpp b/cpp/src/api/vertex.cpp similarity index 100% rename from cxx/src/api/vertex.cpp rename to cpp/src/api/vertex.cpp diff --git a/cxx/src/database.rs b/cpp/src/database.rs similarity index 100% rename from cxx/src/database.rs rename to cpp/src/database.rs diff --git a/cxx/src/error.rs b/cpp/src/error.rs similarity index 100% rename from cxx/src/error.rs rename to cpp/src/error.rs diff --git a/cxx/src/game.rs b/cpp/src/game.rs similarity index 100% rename from cxx/src/game.rs rename to cpp/src/game.rs diff --git a/cxx/src/lib.rs b/cpp/src/lib.rs similarity index 100% rename from cxx/src/lib.rs rename to cpp/src/lib.rs diff --git a/cxx/src/metadata.rs b/cpp/src/metadata.rs similarity index 100% rename from cxx/src/metadata.rs rename to cpp/src/metadata.rs diff --git a/cxx/src/plugin.rs b/cpp/src/plugin.rs similarity index 100% rename from cxx/src/plugin.rs rename to cpp/src/plugin.rs diff --git a/cxx/src/tests/api/interface/api_game_operations_test.h b/cpp/src/tests/api/interface/api_game_operations_test.h similarity index 100% rename from cxx/src/tests/api/interface/api_game_operations_test.h rename to cpp/src/tests/api/interface/api_game_operations_test.h diff --git a/cxx/src/tests/api/interface/create_game_handle_test.h b/cpp/src/tests/api/interface/create_game_handle_test.h similarity index 100% rename from cxx/src/tests/api/interface/create_game_handle_test.h rename to cpp/src/tests/api/interface/create_game_handle_test.h diff --git a/cxx/src/tests/api/interface/database_interface_test.h b/cpp/src/tests/api/interface/database_interface_test.h similarity index 100% rename from cxx/src/tests/api/interface/database_interface_test.h rename to cpp/src/tests/api/interface/database_interface_test.h diff --git a/cxx/src/tests/api/interface/game_interface_test.h b/cpp/src/tests/api/interface/game_interface_test.h similarity index 100% rename from cxx/src/tests/api/interface/game_interface_test.h rename to cpp/src/tests/api/interface/game_interface_test.h diff --git a/cxx/src/tests/api/interface/is_compatible_test.h b/cpp/src/tests/api/interface/is_compatible_test.h similarity index 100% rename from cxx/src/tests/api/interface/is_compatible_test.h rename to cpp/src/tests/api/interface/is_compatible_test.h diff --git a/cxx/src/tests/api/interface/main.cpp b/cpp/src/tests/api/interface/main.cpp similarity index 100% rename from cxx/src/tests/api/interface/main.cpp rename to cpp/src/tests/api/interface/main.cpp diff --git a/cxx/src/tests/api/internals/main.cpp b/cpp/src/tests/api/internals/main.cpp similarity index 98% rename from cxx/src/tests/api/internals/main.cpp rename to cpp/src/tests/api/internals/main.cpp index 4a09bb8a..1b0774e6 100644 --- a/cxx/src/tests/api/internals/main.cpp +++ b/cpp/src/tests/api/internals/main.cpp @@ -2,7 +2,7 @@ #include -#include "libloot-cxx/src/lib.rs.h" +#include "libloot-cpp/src/lib.rs.h" #include "rust/cxx.h" #include "tests/api/internals/metadata/conditional_metadata_test.h" #include "tests/api/internals/metadata/file_test.h" diff --git a/cxx/src/tests/api/internals/metadata/conditional_metadata_test.h b/cpp/src/tests/api/internals/metadata/conditional_metadata_test.h similarity index 100% rename from cxx/src/tests/api/internals/metadata/conditional_metadata_test.h rename to cpp/src/tests/api/internals/metadata/conditional_metadata_test.h diff --git a/cxx/src/tests/api/internals/metadata/file_test.h b/cpp/src/tests/api/internals/metadata/file_test.h similarity index 100% rename from cxx/src/tests/api/internals/metadata/file_test.h rename to cpp/src/tests/api/internals/metadata/file_test.h diff --git a/cxx/src/tests/api/internals/metadata/group_test.h b/cpp/src/tests/api/internals/metadata/group_test.h similarity index 100% rename from cxx/src/tests/api/internals/metadata/group_test.h rename to cpp/src/tests/api/internals/metadata/group_test.h diff --git a/cxx/src/tests/api/internals/metadata/location_test.h b/cpp/src/tests/api/internals/metadata/location_test.h similarity index 100% rename from cxx/src/tests/api/internals/metadata/location_test.h rename to cpp/src/tests/api/internals/metadata/location_test.h diff --git a/cxx/src/tests/api/internals/metadata/message_content_test.h b/cpp/src/tests/api/internals/metadata/message_content_test.h similarity index 100% rename from cxx/src/tests/api/internals/metadata/message_content_test.h rename to cpp/src/tests/api/internals/metadata/message_content_test.h diff --git a/cxx/src/tests/api/internals/metadata/message_test.h b/cpp/src/tests/api/internals/metadata/message_test.h similarity index 100% rename from cxx/src/tests/api/internals/metadata/message_test.h rename to cpp/src/tests/api/internals/metadata/message_test.h diff --git a/cxx/src/tests/api/internals/metadata/plugin_cleaning_data_test.h b/cpp/src/tests/api/internals/metadata/plugin_cleaning_data_test.h similarity index 100% rename from cxx/src/tests/api/internals/metadata/plugin_cleaning_data_test.h rename to cpp/src/tests/api/internals/metadata/plugin_cleaning_data_test.h diff --git a/cxx/src/tests/api/internals/metadata/plugin_metadata_test.h b/cpp/src/tests/api/internals/metadata/plugin_metadata_test.h similarity index 100% rename from cxx/src/tests/api/internals/metadata/plugin_metadata_test.h rename to cpp/src/tests/api/internals/metadata/plugin_metadata_test.h diff --git a/cxx/src/tests/api/internals/metadata/tag_test.h b/cpp/src/tests/api/internals/metadata/tag_test.h similarity index 100% rename from cxx/src/tests/api/internals/metadata/tag_test.h rename to cpp/src/tests/api/internals/metadata/tag_test.h diff --git a/cxx/src/tests/common_game_test_fixture.h b/cpp/src/tests/common_game_test_fixture.h similarity index 100% rename from cxx/src/tests/common_game_test_fixture.h rename to cpp/src/tests/common_game_test_fixture.h diff --git a/cxx/src/tests/printers.h b/cpp/src/tests/printers.h similarity index 100% rename from cxx/src/tests/printers.h rename to cpp/src/tests/printers.h diff --git a/cxx/src/tests/test_helpers.h b/cpp/src/tests/test_helpers.h similarity index 100% rename from cxx/src/tests/test_helpers.h rename to cpp/src/tests/test_helpers.h From b78a6eb2bb1eb16b4b682993e9403bbe74a3a541 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 28 Apr 2025 18:11:53 +0100 Subject: [PATCH 128/206] Mention nodejs wrapper in the root readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a36bea18..c79ff68c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This is an **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++, that should match libloot v0.26.0. -See the `cpp` and `python` subdirectories for C++ and Python wrappers respectively. +There are C++, Python and Node.js wrappers in the `cpp`, `python` and `nodejs` subdirectories respectively. Expect: From a951bd3a62a48b9321b38d99c05ca35ec8280967 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 29 Apr 2025 19:17:47 +0100 Subject: [PATCH 129/206] Remove unnecessary capturing group from regex --- src/metadata/message.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 0a780661..68913d1a 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -305,7 +305,7 @@ impl TryFromYaml for Message { reason = "Only panics if the hardcoded regex string is invalid" )] static FMT_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(r"\{(\d+)\}").expect("hardcoded fmt placeholder regex should be valid") + Regex::new(r"\{\d+\}").expect("hardcoded fmt placeholder regex should be valid") }); for mc in &mut content { From f1186e4c0829eb0b8f5a1a4011f3653c084b3278 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 29 Apr 2025 22:24:19 +0100 Subject: [PATCH 130/206] Update Windows CI to windows-2025 windows-2019 will be fully retired by the end of June, and 2025 is the latest version. --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e835e55..63b19461 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,12 +36,12 @@ jobs: strategy: matrix: target: - - os: windows-2019 + - os: windows-2025 platform: Win32 triple: i686-pc-windows-msvc architecture: x86 - - os: windows-2019 + - os: windows-2025 platform: x64 triple: x86_64-pc-windows-msvc architecture: x64 @@ -147,7 +147,7 @@ jobs: - name: Build the C++ wrapper (Windows) working-directory: cpp run: | - cmake -G "Visual Studio 16 2019" ` + cmake -G "Visual Studio 17 2022" ` -A ${{ matrix.target.platform }} ` -DCPACK_PACKAGE_VERSION="${{ steps.get-libloot-version.outputs.version }}" ` -DRUST_TARGET="${{ matrix.target.triple }}" ` From eabc7a5d85ffb9d780d98026424b4be15c8f4f7e Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Fri, 2 May 2025 17:29:19 +0100 Subject: [PATCH 131/206] Sync with libloot v0.26.1 Aside from adding GameType::oblivionRemastered and its usage, this is almost entirely about moving C++ files and tests around. --- Cargo.lock | 22 +- Cargo.toml | 8 +- README.md | 4 +- cpp/CMakeLists.txt | 12 +- cpp/Cargo.toml | 2 +- cpp/README.md | 4 +- cpp/cmake/tests.cmake | 30 +- cpp/include/loot/enum/game_type.h | 4 +- cpp/include/loot/loot_version.h | 2 +- cpp/src/api/convert.cpp | 2 +- cpp/src/api/database.cpp | 2 +- .../cyclic_interaction_error.cpp | 0 .../api/{ => exception}/error_categories.cpp | 0 cpp/src/api/{ => exception}/exception.cpp | 2 +- cpp/src/api/{ => exception}/exception.h | 0 .../undefined_group_error.cpp | 0 cpp/src/api/game.cpp | 6 +- cpp/src/api/game.h | 2 +- cpp/src/api/plugin.cpp | 6 +- cpp/src/api/resource.rc | 4 +- cpp/src/game.rs | 2 + cpp/src/lib.rs | 1 + .../api/interface/create_game_handle_test.h | 80 ++- .../api/interface/database_interface_test.h | 9 +- .../tests/api/interface/game_interface_test.h | 359 ++++++++++++- cpp/src/tests/api/interface/main.cpp | 10 + .../metadata/conditional_metadata_test.h | 11 +- .../metadata/file_test.h | 8 +- .../metadata/group_test.h | 8 +- .../metadata/location_test.h | 8 +- .../metadata/message_content_test.h | 8 +- .../metadata/message_test.h | 8 +- .../metadata/plugin_cleaning_data_test.h | 8 +- .../metadata/plugin_metadata_test.h | 8 +- .../metadata/tag_test.h | 8 +- .../api/interface/plugin_interface_test.h | 486 ++++++++++++++++++ cpp/src/tests/api/internals/main.cpp | 11 +- cpp/src/tests/common_game_test_fixture.h | 49 +- cpp/src/tests/test_helpers.h | 3 +- ffi-errors/Cargo.toml | 2 +- nodejs/Cargo.toml | 2 +- nodejs/__test__/index.spec.mjs | 2 +- nodejs/npm/linux-x64-gnu/package.json | 2 +- nodejs/npm/win32-x64-msvc/package.json | 2 +- nodejs/package.json | 2 +- nodejs/src/game.rs | 3 + python/Cargo.toml | 2 +- python/src/game.rs | 3 + src/archive/find.rs | 8 +- src/game.rs | 18 +- src/plugin/mod.rs | 27 +- src/tests.rs | 14 +- 52 files changed, 1100 insertions(+), 184 deletions(-) rename cpp/src/api/{sorting => exception}/cyclic_interaction_error.cpp (100%) rename cpp/src/api/{ => exception}/error_categories.cpp (100%) rename cpp/src/api/{ => exception}/exception.cpp (99%) rename cpp/src/api/{ => exception}/exception.h (100%) rename cpp/src/api/{sorting => exception}/undefined_group_error.cpp (100%) rename cpp/src/tests/api/{internals => interface}/metadata/conditional_metadata_test.h (81%) rename cpp/src/tests/api/{internals => interface}/metadata/file_test.h (98%) rename cpp/src/tests/api/{internals => interface}/metadata/group_test.h (98%) rename cpp/src/tests/api/{internals => interface}/metadata/location_test.h (98%) rename cpp/src/tests/api/{internals => interface}/metadata/message_content_test.h (98%) rename cpp/src/tests/api/{internals => interface}/metadata/message_test.h (98%) rename cpp/src/tests/api/{internals => interface}/metadata/plugin_cleaning_data_test.h (98%) rename cpp/src/tests/api/{internals => interface}/metadata/plugin_metadata_test.h (98%) rename cpp/src/tests/api/{internals => interface}/metadata/tag_test.h (97%) create mode 100644 cpp/src/tests/api/interface/plugin_interface_test.h diff --git a/Cargo.lock b/Cargo.lock index 098ab28e..697c9934 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,9 +379,9 @@ dependencies = [ [[package]] name = "esplugin" -version = "6.1.2" +version = "6.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8b3f4507e489fce3df78273537e93e939916c83d992fbfb712db9b1f5eee62" +checksum = "3c334f10e3dc2e21d45e208f5b6b232a6876ea6352130d90a42bdb05246f7fcc" dependencies = [ "encoding_rs", "memchr", @@ -528,9 +528,9 @@ dependencies = [ [[package]] name = "libloadorder" -version = "18.3.0" +version = "18.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "034ffaf7bf5bd34669741500d4ccddc5dbc62c491cdb0c5e103c592da535dbeb" +checksum = "bc4de694d488b4ce2a775d59825ec30d206546ec87fe0866717e6db25c46a164" dependencies = [ "dirs", "encoding_rs", @@ -545,7 +545,7 @@ dependencies = [ [[package]] name = "libloot" -version = "0.26.0" +version = "0.26.1" dependencies = [ "crc32fast", "esplugin", @@ -566,7 +566,7 @@ dependencies = [ [[package]] name = "libloot-cpp" -version = "0.26.0" +version = "0.26.1" dependencies = [ "cxx", "cxx-build", @@ -578,7 +578,7 @@ dependencies = [ [[package]] name = "libloot-ffi-errors" -version = "0.26.0" +version = "0.26.1" dependencies = [ "esplugin", "libloadorder", @@ -588,7 +588,7 @@ dependencies = [ [[package]] name = "libloot-nodejs" -version = "0.26.0" +version = "0.26.1" dependencies = [ "libloot", "libloot-ffi-errors", @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "libloot_python" -version = "0.26.0" +version = "0.26.1" dependencies = [ "libloot", "libloot-ffi-errors", @@ -640,9 +640,9 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "loot-condition-interpreter" -version = "5.3.1" +version = "5.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94d58882c85aba2f4fca0951301b58e03428a8a02e51ccb0cf5f5b28d7869281" +checksum = "eec12dc709a84442632efdc064e3463612bf7ddcc39d804de021cf56e557be04" dependencies = [ "crc32fast", "esplugin", diff --git a/Cargo.toml b/Cargo.toml index d73b7cc4..9023661f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot" -version = "0.26.0" +version = "0.26.1" edition = "2024" license = "GPL-3.0" @@ -30,9 +30,9 @@ tempfile = "3.17.1" members = ["cpp", "ffi-errors", "nodejs", "parameterized-test", "python"] [workspace.dependencies] -esplugin = "6.1.2" -libloadorder = "18.3.0" -loot-condition-interpreter = "5.3.1" +esplugin = "6.1.3" +libloadorder = "18.4.0" +loot-condition-interpreter = "5.3.2" [profile.release] debug = "limited" diff --git a/README.md b/README.md index c79ff68c..e18da3c9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # libloot-rs -This is an **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++, that should match libloot v0.26.0. +This is an **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++, that should match libloot v0.26.1. + +There is one intentional difference in behaviour: if a plugin has metadata but it is all filtered out when conditions are evaluated, getting its metadata returns `None` instead of a name-only PluginMetadata object. There are C++, Python and Node.js wrappers in the `cpp`, `python` and `nodejs` subdirectories respectively. diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 5f234e79..14db90ee 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -67,8 +67,10 @@ set(LIBLOOT_SRC_API_CPP_FILES "${CMAKE_SOURCE_DIR}/src/api/api.cpp" "${CMAKE_SOURCE_DIR}/src/api/convert.cpp" "${CMAKE_SOURCE_DIR}/src/api/database.cpp" - "${CMAKE_SOURCE_DIR}/src/api/error_categories.cpp" - "${CMAKE_SOURCE_DIR}/src/api/exception.cpp" + "${CMAKE_SOURCE_DIR}/src/api/exception/cyclic_interaction_error.cpp" + "${CMAKE_SOURCE_DIR}/src/api/exception/error_categories.cpp" + "${CMAKE_SOURCE_DIR}/src/api/exception/exception.cpp" + "${CMAKE_SOURCE_DIR}/src/api/exception/undefined_group_error.cpp" "${CMAKE_SOURCE_DIR}/src/api/metadata/conditional_metadata.cpp" "${CMAKE_SOURCE_DIR}/src/api/metadata/file.cpp" "${CMAKE_SOURCE_DIR}/src/api/metadata/filename.cpp" @@ -81,8 +83,6 @@ set(LIBLOOT_SRC_API_CPP_FILES "${CMAKE_SOURCE_DIR}/src/api/metadata/tag.cpp" "${CMAKE_SOURCE_DIR}/src/api/game.cpp" "${CMAKE_SOURCE_DIR}/src/api/plugin.cpp" - "${CMAKE_SOURCE_DIR}/src/api/sorting/cyclic_interaction_error.cpp" - "${CMAKE_SOURCE_DIR}/src/api/sorting/undefined_group_error.cpp" "${CMAKE_SOURCE_DIR}/src/api/vertex.cpp") set(LIBLOOT_INCLUDE_H_FILES @@ -116,7 +116,7 @@ set(LIBLOOT_INCLUDE_H_FILES set(LIBLOOT_SRC_API_H_FILES "${CMAKE_SOURCE_DIR}/src/api/convert.h" "${CMAKE_SOURCE_DIR}/src/api/database.h" - "${CMAKE_SOURCE_DIR}/src/api/exception.h" + "${CMAKE_SOURCE_DIR}/src/api/exception/exception.h" "${CMAKE_SOURCE_DIR}/src/api/game.h" "${CMAKE_SOURCE_DIR}/src/api/plugin.h") @@ -239,7 +239,7 @@ endif() # Install ######################################## -set(LIBLOOT_VERSION "0.26.0") +set(LIBLOOT_VERSION "0.26.1") set_property(TARGET loot PROPERTY VERSION ${LIBLOOT_VERSION}) set_property(TARGET loot PROPERTY SOVERSION 0) diff --git a/cpp/Cargo.toml b/cpp/Cargo.toml index 6c44e949..c22adf5d 100644 --- a/cpp/Cargo.toml +++ b/cpp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot-cpp" -version = "0.26.0" +version = "0.26.1" edition = "2024" license = "GPL-3.0" diff --git a/cpp/README.md b/cpp/README.md index aa4a7dc1..249f6cda 100644 --- a/cpp/README.md +++ b/cpp/README.md @@ -1,6 +1,6 @@ # libloot-rs C++ wrapper -This is an **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface that's ABI-compatible with libloot v0.26.0. +This is an **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface that's ABI-compatible with libloot v0.26.1. The wrapper has two layers: @@ -44,7 +44,7 @@ cmake --build build --parallel ### Tests & Packaging -The build process also builds a copy of the public API tests from C++ libloot v0.26.0 by default. To skip building the tests, pass `-DLIBLOOT_BUILD_TESTS=OFF` when first running CMake. +The build process also builds a copy of the public API tests from C++ libloot v0.26.1 by default. To skip building the tests, pass `-DLIBLOOT_BUILD_TESTS=OFF` when first running CMake. If built, the tests can be run using: diff --git a/cpp/cmake/tests.cmake b/cpp/cmake/tests.cmake index 27c4636a..fbd1b515 100644 --- a/cpp/cmake/tests.cmake +++ b/cpp/cmake/tests.cmake @@ -36,16 +36,7 @@ set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_INITIAL}) set(LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES "${CMAKE_SOURCE_DIR}/src/tests/api/internals/main.cpp") -set(LIBLOOT_SRC_TESTS_INTERNALS_H_FILES - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/conditional_metadata_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/file_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/group_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/location_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/message_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/message_content_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/plugin_cleaning_data_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/plugin_metadata_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/tag_test.h") +# set(LIBLOOT_SRC_TESTS_INTERNALS_H_FILES) set(LIBLOOT_SRC_TESTS_INTERFACE_CPP_FILES "${CMAKE_SOURCE_DIR}/src/tests/api/interface/main.cpp") @@ -55,15 +46,24 @@ set(LIBLOOT_SRC_TESTS_INTERFACE_H_FILES "${CMAKE_SOURCE_DIR}/src/tests/api/interface/create_game_handle_test.h" "${CMAKE_SOURCE_DIR}/src/tests/api/interface/database_interface_test.h" "${CMAKE_SOURCE_DIR}/src/tests/api/interface/game_interface_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/interface/is_compatible_test.h") + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/is_compatible_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/conditional_metadata_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/file_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/group_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/location_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/message_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/message_content_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/plugin_cleaning_data_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/plugin_metadata_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/tag_test.h") source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" PREFIX "Source Files" FILES ${LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES}) -source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" - PREFIX "Header Files" - FILES ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES}) +# source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" +# PREFIX "Header Files" +# FILES ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES}) source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/interface" PREFIX "Source Files" @@ -77,7 +77,7 @@ source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/interface" set(LIBLOOT_INTERNALS_TESTS_ALL_SOURCES ${LIBLOOT_ALL_SOURCES} ${LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES} - ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES} + # ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES} "${CMAKE_SOURCE_DIR}/src/tests/common_game_test_fixture.h" "${CMAKE_SOURCE_DIR}/src/tests/test_helpers.h" "${CMAKE_SOURCE_DIR}/src/tests/printers.h") diff --git a/cpp/include/loot/enum/game_type.h b/cpp/include/loot/enum/game_type.h index ccec865c..8a1edc01 100644 --- a/cpp/include/loot/enum/game_type.h +++ b/cpp/include/loot/enum/game_type.h @@ -52,7 +52,9 @@ enum struct GameType : unsigned int { /** Starfield */ starfield, /** OpenMW */ - openmw + openmw, + /** The Elder Scrolls IV: Oblivion Remastered */ + oblivionRemastered }; } diff --git a/cpp/include/loot/loot_version.h b/cpp/include/loot/loot_version.h index 961407b0..55e78fc7 100644 --- a/cpp/include/loot/loot_version.h +++ b/cpp/include/loot/loot_version.h @@ -37,7 +37,7 @@ inline constexpr unsigned int LIBLOOT_VERSION_MAJOR = 0; inline constexpr unsigned int LIBLOOT_VERSION_MINOR = 26; /** @brief libloot's patch version number. */ -inline constexpr unsigned int LIBLOOT_VERSION_PATCH = 0; +inline constexpr unsigned int LIBLOOT_VERSION_PATCH = 1; /** * @brief Get the library version. diff --git a/cpp/src/api/convert.cpp b/cpp/src/api/convert.cpp index ee48b0b2..a4f34b07 100644 --- a/cpp/src/api/convert.cpp +++ b/cpp/src/api/convert.cpp @@ -1,6 +1,6 @@ #include "api/convert.h" -#include "api/exception.h" +#include "api/exception/exception.h" namespace loot { // To public types diff --git a/cpp/src/api/database.cpp b/cpp/src/api/database.cpp index d5620ae9..d0a174b8 100644 --- a/cpp/src/api/database.cpp +++ b/cpp/src/api/database.cpp @@ -2,7 +2,7 @@ #include "api/database.h" #include "api/convert.h" -#include "api/exception.h" +#include "api/exception/exception.h" namespace loot { Database::Database(::rust::Box&& database) : diff --git a/cpp/src/api/sorting/cyclic_interaction_error.cpp b/cpp/src/api/exception/cyclic_interaction_error.cpp similarity index 100% rename from cpp/src/api/sorting/cyclic_interaction_error.cpp rename to cpp/src/api/exception/cyclic_interaction_error.cpp diff --git a/cpp/src/api/error_categories.cpp b/cpp/src/api/exception/error_categories.cpp similarity index 100% rename from cpp/src/api/error_categories.cpp rename to cpp/src/api/exception/error_categories.cpp diff --git a/cpp/src/api/exception.cpp b/cpp/src/api/exception/exception.cpp similarity index 99% rename from cpp/src/api/exception.cpp rename to cpp/src/api/exception/exception.cpp index 1292271f..73c0bfff 100644 --- a/cpp/src/api/exception.cpp +++ b/cpp/src/api/exception/exception.cpp @@ -1,4 +1,4 @@ -#include "api/exception.h" +#include "api/exception/exception.h" #include diff --git a/cpp/src/api/exception.h b/cpp/src/api/exception/exception.h similarity index 100% rename from cpp/src/api/exception.h rename to cpp/src/api/exception/exception.h diff --git a/cpp/src/api/sorting/undefined_group_error.cpp b/cpp/src/api/exception/undefined_group_error.cpp similarity index 100% rename from cpp/src/api/sorting/undefined_group_error.cpp rename to cpp/src/api/exception/undefined_group_error.cpp diff --git a/cpp/src/api/game.cpp b/cpp/src/api/game.cpp index 50192171..34f05e58 100644 --- a/cpp/src/api/game.cpp +++ b/cpp/src/api/game.cpp @@ -2,7 +2,7 @@ #include "api/game.h" #include "api/convert.h" -#include "api/exception.h" +#include "api/exception/exception.h" namespace { loot::GameType convert(loot::rust::GameType gameType) { @@ -29,6 +29,8 @@ loot::GameType convert(loot::rust::GameType gameType) { return loot::GameType::starfield; case loot::rust::GameType::OpenMW: return loot::GameType::openmw; + case loot::rust::GameType::OblivionRemastered: + return loot::GameType::oblivionRemastered; default: throw std::logic_error("Unsupported GameType value"); } @@ -58,6 +60,8 @@ loot::rust::GameType convert(loot::GameType gameType) { return loot::rust::GameType::Starfield; case loot::GameType::openmw: return loot::rust::GameType::OpenMW; + case loot::GameType::oblivionRemastered: + return loot::rust::GameType::OblivionRemastered; default: throw std::logic_error("Unsupported GameType value"); } diff --git a/cpp/src/api/game.h b/cpp/src/api/game.h index 58c4e72c..7422282b 100644 --- a/cpp/src/api/game.h +++ b/cpp/src/api/game.h @@ -22,7 +22,7 @@ public: GameType GetType() const override; - std::vector GetAdditionalDataPaths() const; + std::vector GetAdditionalDataPaths() const override; void SetAdditionalDataPaths( const std::vector& additionalDataPaths) override; diff --git a/cpp/src/api/plugin.cpp b/cpp/src/api/plugin.cpp index 08c383dc..135c703d 100644 --- a/cpp/src/api/plugin.cpp +++ b/cpp/src/api/plugin.cpp @@ -4,7 +4,7 @@ #include #include "api/convert.h" -#include "api/exception.h" +#include "api/exception/exception.h" namespace loot { Plugin::Plugin(::rust::Box plugin) : @@ -101,9 +101,7 @@ bool Plugin::DoRecordsOverlap(const PluginInterface& plugin) const { return plugin_->do_records_overlap(*otherPlugin.plugin_); } catch (std::bad_cast&) { - throw std::invalid_argument( - "Tried to check if records overlapped with a different concrete type " - "implementing PluginInterface"); + return false; } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); } diff --git a/cpp/src/api/resource.rc b/cpp/src/api/resource.rc index a119a2db..8c9d7a4d 100644 --- a/cpp/src/api/resource.rc +++ b/cpp/src/api/resource.rc @@ -13,12 +13,12 @@ BLOCK "040904b0" BEGIN VALUE "CompanyName", "LOOT" VALUE "FileDescription", "Library providing LOOT's core functionality" -VALUE "FileVersion", "0.26.0" +VALUE "FileVersion", "0.26.1" VALUE "InternalName", "loot" VALUE "LegalCopyright", "Copyright (C) 2013-2022 Oliver Hamlet" VALUE "OriginalFilename", "loot.dll" VALUE "ProductName", "LOOT" -VALUE "ProductVersion", "0.26.0" +VALUE "ProductVersion", "0.26.1" END END BLOCK "VarFileInfo" diff --git a/cpp/src/game.rs b/cpp/src/game.rs index 85c29594..50312d77 100644 --- a/cpp/src/game.rs +++ b/cpp/src/game.rs @@ -21,6 +21,7 @@ impl TryFrom for GameType { libloot::GameType::Morrowind => Ok(GameType::Morrowind), libloot::GameType::Starfield => Ok(GameType::Starfield), libloot::GameType::OpenMW => Ok(GameType::OpenMW), + libloot::GameType::OblivionRemastered => Ok(GameType::OblivionRemastered), _ => Err(UnsupportedEnumValueError), } } @@ -42,6 +43,7 @@ impl TryFrom for libloot::GameType { GameType::Morrowind => Ok(libloot::GameType::Morrowind), GameType::Starfield => Ok(libloot::GameType::Starfield), GameType::OpenMW => Ok(libloot::GameType::OpenMW), + GameType::OblivionRemastered => Ok(libloot::GameType::OblivionRemastered), _ => Err(UnsupportedEnumValueError), } } diff --git a/cpp/src/lib.rs b/cpp/src/lib.rs index 61f2161b..b1e3f141 100644 --- a/cpp/src/lib.rs +++ b/cpp/src/lib.rs @@ -232,6 +232,7 @@ mod ffi { Morrowind, Starfield, OpenMW, + OblivionRemastered, } pub enum MessageType { diff --git a/cpp/src/tests/api/interface/create_game_handle_test.h b/cpp/src/tests/api/interface/create_game_handle_test.h index 07b7a01b..80539b9b 100644 --- a/cpp/src/tests/api/interface/create_game_handle_test.h +++ b/cpp/src/tests/api/interface/create_game_handle_test.h @@ -88,17 +88,7 @@ protected: // but we only have the one so no prefix is necessary. INSTANTIATE_TEST_SUITE_P(, CreateGameHandleTest, - ::testing::Values(GameType::tes4, - GameType::tes5, - GameType::fo3, - GameType::fonv, - GameType::fo4, - GameType::tes5se, - GameType::fo4vr, - GameType::tes5vr, - GameType::tes3, - GameType::starfield, - GameType::openmw)); + ::testing::ValuesIn(ALL_GAME_TYPES)); TEST_P(CreateGameHandleTest, shouldSucceedIfPassedValidParametersWithRelativePaths) { @@ -151,6 +141,74 @@ TEST_P(CreateGameHandleTest, EXPECT_TRUE(handle_); } #endif + +#ifndef _WIN32 +TEST_P( + CreateGameHandleTest, + shouldThrowOnLinuxIfLocalPathIsNotGivenExceptForMorrowindOpenMWAndOblivionRemastered) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || + GetParam() == GameType::oblivionRemastered) { + EXPECT_NO_THROW(CreateGameHandle(GetParam(), gamePath)); + } else { + EXPECT_THROW(CreateGameHandle(GetParam(), gamePath), std::system_error); + } +} +#else +TEST_P(CreateGameHandleTest, + shouldNotThrowOnWindowsIfLocalPathIsNotGiven) { + EXPECT_NO_THROW(CreateGameHandle(GetParam(), gamePath)); +} +#endif + +TEST_P(CreateGameHandleTest, + shouldNotThrowIfGameAndLocalPathsAreNotEmpty) { + EXPECT_NO_THROW(CreateGameHandle(GetParam(), gamePath, localPath)); +} + +TEST_P( + CreateGameHandleTest, + shouldSetAdditionalDataPathsForFallout4FromMicrosoftStoreOrStarfield) { + if (GetParam() == GameType::fo4) { + // Create the file that indicates it's a Microsoft Store install. + touch(gamePath / "appxmanifest.xml"); + } else if (GetParam() == GameType::openmw) { + std::ofstream out(gamePath / "openmw.cfg"); + out << "data-local=\"" << (localPath / "data").u8string() << "\"" + << std::endl + << "config=\"" << localPath.u8string() << "\""; + } + + const auto game = CreateGameHandle(GetParam(), gamePath, localPath); + + if (GetParam() == GameType::fo4) { + const auto basePath = gamePath / ".." / ".."; + EXPECT_EQ(std::vector( + {basePath / "Fallout 4- Automatron (PC)" / "Content" / "Data", + basePath / "Fallout 4- Nuka-World (PC)" / "Content" / "Data", + basePath / "Fallout 4- Wasteland Workshop (PC)" / "Content" / + "Data", + basePath / "Fallout 4- High Resolution Texture Pack" / + "Content" / "Data", + basePath / "Fallout 4- Vault-Tec Workshop (PC)" / "Content" / + "Data", + basePath / "Fallout 4- Far Harbor (PC)" / "Content" / "Data", + basePath / "Fallout 4- Contraptions Workshop (PC)" / + "Content" / "Data"}), + game->GetAdditionalDataPaths()); + } else if (GetParam() == GameType::starfield) { + ASSERT_EQ(1, game->GetAdditionalDataPaths().size()); + + const auto expectedSuffix = std::filesystem::u8path("Documents") / + "My Games" / "Starfield" / "Data"; + EXPECT_TRUE(endsWith(game->GetAdditionalDataPaths()[0].u8string(), + expectedSuffix.u8string())); + } else if (GetParam() == GameType::openmw) { + EXPECT_EQ(std::vector{localPath / "data"}, + game->GetAdditionalDataPaths()); + } else { + EXPECT_TRUE(game->GetAdditionalDataPaths().empty()); + } +} } } diff --git a/cpp/src/tests/api/interface/database_interface_test.h b/cpp/src/tests/api/interface/database_interface_test.h index 2ee00b95..a6aad0ff 100644 --- a/cpp/src/tests/api/interface/database_interface_test.h +++ b/cpp/src/tests/api/interface/database_interface_test.h @@ -113,12 +113,7 @@ protected: // but we only have the one so no prefix is necessary. INSTANTIATE_TEST_SUITE_P(, DatabaseInterfaceTest, - ::testing::Values(GameType::tes4, - GameType::tes5, - GameType::fo3, - GameType::fonv, - GameType::fo4, - GameType::tes5se)); + ::testing::ValuesIn(ALL_GAME_TYPES)); TEST_P(DatabaseInterfaceTest, loadMasterlistShouldSucceedEvenIfGameHandleIsDiscarded) { @@ -275,7 +270,7 @@ TEST_P(DatabaseInterfaceTest, writeUserMetadataShouldShouldWriteUserMetadata) { } TEST_P(DatabaseInterfaceTest, evaluateShouldReturnTrueIfTheConditionIsTrue) { - EXPECT_TRUE(handle_->GetDatabase().Evaluate("file(\"Blank.esp\")")); + EXPECT_TRUE(handle_->GetDatabase().Evaluate("file(\"Blank.esp\")")); } TEST_P(DatabaseInterfaceTest, evaluateShouldReturnFalseIfTheConditionIsFalse) { diff --git a/cpp/src/tests/api/interface/game_interface_test.h b/cpp/src/tests/api/interface/game_interface_test.h index f5c699bf..b1c72236 100644 --- a/cpp/src/tests/api/interface/game_interface_test.h +++ b/cpp/src/tests/api/interface/game_interface_test.h @@ -30,6 +30,8 @@ along with LOOT. If not, see namespace loot { namespace test { +constexpr unsigned int ESP_ERROR_PLUGIN_METADATA_NOT_FOUND = 14; + class GameInterfaceTest : public ApiGameOperationsTest { protected: GameInterfaceTest() : @@ -74,17 +76,7 @@ protected: // but we only have the one so no prefix is necessary. INSTANTIATE_TEST_SUITE_P(, GameInterfaceTest, - ::testing::Values(GameType::tes4, - GameType::tes5, - GameType::fo3, - GameType::fonv, - GameType::fo4, - GameType::tes5se, - GameType::fo4vr, - GameType::tes5vr, - GameType::tes3, - GameType::starfield, - GameType::openmw)); + ::testing::ValuesIn(ALL_GAME_TYPES)); TEST_P(GameInterfaceTest, setAdditionalDataPathsShouldDoThat) { const auto paths = std::vector{ @@ -95,6 +87,56 @@ TEST_P(GameInterfaceTest, setAdditionalDataPathsShouldDoThat) { EXPECT_EQ(paths, handle_->GetAdditionalDataPaths()); } +TEST_P(GameInterfaceTest, setAdditionalDataPathsShouldClearTheConditionCache) { + PluginMetadata metadata(blankEsm); + metadata.SetLoadAfterFiles({File("plugin.esp", "", "file(\"plugin.esp\")")}); + handle_->GetDatabase().SetPluginUserMetadata(metadata); + + auto evaluatedMetadata = + handle_->GetDatabase().GetPluginUserMetadata(blankEsm, true); + EXPECT_FALSE(evaluatedMetadata.has_value()); + + const auto dataFilePath = gamePath.parent_path() / "Data" / "plugin.esp"; + touch(dataFilePath); + handle_->SetAdditionalDataPaths({dataFilePath.parent_path()}); + + evaluatedMetadata = + handle_->GetDatabase().GetPluginUserMetadata(blankEsm, true); + EXPECT_FALSE(evaluatedMetadata.value().GetLoadAfterFiles().empty()); +} + +TEST_P(GameInterfaceTest, + setAdditionalDataPathsShouldUpdateWhereLoadOrderPluginsAreFound) { + // Set no additional data paths to avoid picking up non-test plugins on PCs + // which have Starfield or Fallout 4 installed. + handle_->SetAdditionalDataPaths({}); + handle_->LoadCurrentLoadOrderState(); + auto loadOrder = handle_->GetLoadOrder(); + + const auto filename = "plugin.esp"; + const auto dataFilePath = gamePath.parent_path() / "Data" / filename; + std::filesystem::create_directories(dataFilePath.parent_path()); + std::filesystem::copy_file(getSourcePluginsPath() / blankEsp, dataFilePath); + ASSERT_TRUE(std::filesystem::exists(dataFilePath)); + + if (GetParam() == GameType::starfield) { + std::filesystem::copy_file(getSourcePluginsPath() / blankEsp, + dataPath / filename); + ASSERT_TRUE(std::filesystem::exists(dataPath / filename)); + } + + std::filesystem::last_write_time( + dataFilePath, + std::filesystem::file_time_type::clock().now() + std::chrono::hours(1)); + + handle_->SetAdditionalDataPaths({dataFilePath.parent_path()}); + handle_->LoadCurrentLoadOrderState(); + + loadOrder.push_back(filename); + + EXPECT_EQ(loadOrder, handle_->GetLoadOrder()); +} + TEST_P(GameInterfaceTest, isValidPluginShouldReturnTrueForAValidPlugin) { EXPECT_TRUE(handle_->IsValidPlugin(blankEsm)); } @@ -116,9 +158,37 @@ TEST_P(GameInterfaceTest, isValidPluginShouldReturnFalseForAnEmptyFile) { EXPECT_FALSE(handle_->IsValidPlugin(emptyFile)); } +TEST_P(GameInterfaceTest, + isValidPluginShouldResolveRelativePathsRelativeToDataPath) { + const auto path = ".." / dataPath.filename() / blankEsm; + + EXPECT_TRUE(handle_->IsValidPlugin(path)); +} + +TEST_P(GameInterfaceTest, isValidPluginShouldUseAbsolutePathsAsGiven) { + ASSERT_TRUE(dataPath.is_absolute()); + + const auto path = dataPath / std::filesystem::u8path(blankEsm); + + EXPECT_TRUE(handle_->IsValidPlugin(path)); +} + TEST_P( GameInterfaceTest, - loadPluginsWithHeadersOnlyTrueShouldLoadTheHeadersOfAllGivenPlugins) { + isValidPluginShouldTryGhostedPathIfGivenPluginDoesNotExistExceptForOpenMW) { + if (GetParam() == GameType::openmw) { + // This wasn't done for OpenMW during common setup. + const auto pluginPath = dataPath / (blankMasterDependentEsm + ".ghost"); + std::filesystem::rename(dataPath / blankMasterDependentEsm, pluginPath); + + EXPECT_FALSE(handle_->IsValidPlugin(blankMasterDependentEsm)); + } else { + EXPECT_TRUE(handle_->IsValidPlugin(blankMasterDependentEsm)); + } +} + +TEST_P(GameInterfaceTest, + loadPluginsWithHeadersOnlyTrueShouldLoadTheHeadersOfAllGivenPlugins) { handle_->LoadPlugins(pluginsToLoad, true); if (GetParam() == GameType::starfield) { EXPECT_EQ(6, handle_->GetLoadedPlugins().size()); @@ -154,9 +224,29 @@ TEST_P(GameInterfaceTest, loadPluginsShouldTrimDotGhostFileExtensions) { EXPECT_EQ(blankMasterDependentEsm, plugin->GetName()); } +TEST_P(GameInterfaceTest, + loadPluginsWithHeadersOnlyTrueShouldLoadTheHeadersOfGivenPlugins) { + handle_->LoadPlugins(pluginsToLoad, true); + + if (GetParam() == GameType::starfield) { + EXPECT_EQ(6, handle_->GetLoadedPlugins().size()); + } else { + EXPECT_EQ(11, handle_->GetLoadedPlugins().size()); + } + + // Check that one plugin's header has been read. + ASSERT_NO_THROW(handle_->GetPlugin(masterFile)); + auto plugin = handle_->GetPlugin(masterFile); + EXPECT_EQ("5.0", plugin->GetVersion().value()); + + // Check that only the header has been read. + EXPECT_FALSE(plugin->GetCRC()); +} + TEST_P(GameInterfaceTest, loadPluginsWithHeadersOnlyFalseShouldFullyLoadAllInstalledPlugins) { handle_->LoadPlugins(pluginsToLoad, false); + if (GetParam() == GameType::starfield) { EXPECT_EQ(6, handle_->GetLoadedPlugins().size()); } else { @@ -184,6 +274,211 @@ TEST_P(GameInterfaceTest, loadPluginsWithANonAsciiPluginShouldLoadIt) { EXPECT_EQ(blankEsmCrc, plugin->GetCRC().value()); } +TEST_P( + GameInterfaceTest, + loadPluginsShouldNotThrowIfAFilenameHasNonWindows1252EncodableCharacters) { + const auto pluginName = std::filesystem::u8path( + u8"\u2551\u00BB\u00C1\u2510\u2557\u00FE\u00C3\u00CE.esp"); + std::filesystem::copy(dataPath / blankEsp, dataPath / pluginName); + + EXPECT_NO_THROW(handle_->LoadPlugins({pluginName}, false)); +} + +TEST_P(GameInterfaceTest, + loadPluginsWithANonPluginShouldNotAddItToTheLoadedPlugins) { + ASSERT_THROW(handle_->LoadPlugins({nonPluginFile}, false), + std::invalid_argument); + + ASSERT_TRUE(handle_->GetLoadedPlugins().empty()); +} + +TEST_P(GameInterfaceTest, + loadPluginsWithAnInvalidPluginShouldNotAddItToTheLoadedPlugins) { + ASSERT_FALSE(std::filesystem::exists(dataPath / invalidPlugin)); + ASSERT_NO_THROW(std::filesystem::copy_file(dataPath / blankEsm, + dataPath / invalidPlugin)); + ASSERT_TRUE(std::filesystem::exists(dataPath / invalidPlugin)); + std::ofstream out(dataPath / invalidPlugin, std::fstream::app); + out << "GRUP0"; + out.close(); + + ASSERT_NO_THROW(handle_->LoadPlugins({invalidPlugin}, false)); + + ASSERT_TRUE(handle_->GetLoadedPlugins().empty()); +} + +TEST_P(GameInterfaceTest, loadPluginsShouldNotClearThePluginsCache) { + handle_->LoadPlugins({std::filesystem::u8path(blankEsm)}, true); + ASSERT_EQ(1, handle_->GetLoadedPlugins().size()); + + handle_->LoadPlugins({std::filesystem::u8path(blankEsp)}, true); + + EXPECT_EQ(2, handle_->GetLoadedPlugins().size()); +} + +TEST_P(GameInterfaceTest, + loadPluginsShouldReplaceCacheEntriesForTheGivenPlugins) { + handle_->LoadPlugins({std::filesystem::u8path(blankEsm)}, true); + const auto pointer = handle_->GetPlugin(blankEsm); + ASSERT_NE(nullptr, pointer); + + handle_->LoadPlugins({std::filesystem::u8path(blankEsm)}, false); + + const auto newPointer = handle_->GetPlugin(blankEsm); + ASSERT_NE(nullptr, newPointer); + EXPECT_NE(pointer, newPointer); +} + +TEST_P(GameInterfaceTest, + loadPluginsShouldThrowIfGivenVectorElementsWithTheSameFilename) { + const auto dataPluginPath = dataPath / std::filesystem::u8path(blankEsm); + const auto sourcePluginPath = + getSourcePluginsPath() / std::filesystem::u8path(blankEsm); + + EXPECT_THROW(handle_->LoadPlugins(std::vector( + {dataPluginPath, sourcePluginPath}), + true), + std::invalid_argument); +} + +TEST_P(GameInterfaceTest, + loadPluginsShouldResolveRelativePathsRelativeToDataPath) { + const auto relativePath = ".." / dataPath.filename() / blankEsm; + + handle_->LoadPlugins(std::vector({relativePath}), + true); + + EXPECT_NE(nullptr, handle_->GetPlugin(blankEsm)); +} + +TEST_P(GameInterfaceTest, loadPluginsShouldUseAbsolutePathsAsGiven) { + const auto absolutePath = dataPath / std::filesystem::u8path(blankEsm); + + handle_->LoadPlugins(std::vector({absolutePath}), + true); + + EXPECT_NE(nullptr, handle_->GetPlugin(blankEsm)); +} + +TEST_P( + GameInterfaceTest, + loadPluginsShouldThrowIfFullyLoadingAPluginWithAMissingMasterIfGameIsMorrowindOrStarfield) { + const auto pluginName = + GetParam() == GameType::starfield ? blankFullEsm : blankEsm; + + std::filesystem::remove(dataPath / pluginName); + + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || + GetParam() == GameType::starfield) { + try { + handle_->LoadPlugins({blankMasterDependentEsm}, false); + FAIL(); + } catch (const std::system_error& e) { + EXPECT_EQ(ESP_ERROR_PLUGIN_METADATA_NOT_FOUND, e.code().value()); + EXPECT_EQ(esplugin_category(), e.code().category()); + } + } else { + handle_->LoadPlugins({blankMasterDependentEsm}, false); + + EXPECT_NE(nullptr, handle_->GetPlugin(blankMasterDependentEsm)); + } +} + +TEST_P( + GameInterfaceTest, + loadPluginsShouldThrowIfAPluginHasAMasterThatIsNotInTheInputAndIsNotAlreadyLoadedAndGameIsMorrowindOrStarfield) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || + GetParam() == GameType::starfield) { + try { + handle_->LoadPlugins({blankMasterDependentEsm}, false); + FAIL(); + } catch (const std::system_error& e) { + EXPECT_EQ(ESP_ERROR_PLUGIN_METADATA_NOT_FOUND, e.code().value()); + EXPECT_EQ(esplugin_category(), e.code().category()); + } + } else { + handle_->LoadPlugins({blankMasterDependentEsm}, false); + + EXPECT_NE(nullptr, handle_->GetPlugin(blankMasterDependentEsm)); + } +} + +TEST_P( + GameInterfaceTest, + loadPluginsShouldNotThrowIfAPluginHasAMasterThatIsNotInTheInputButIsAlreadyLoaded) { + const auto pluginName = + GetParam() == GameType::starfield ? blankFullEsm : blankEsm; + + handle_->LoadPlugins({pluginName}, true); + + handle_->LoadPlugins({blankMasterDependentEsm}, false); + + EXPECT_NE(nullptr, handle_->GetPlugin(blankMasterDependentEsm)); +} + +TEST_P(GameInterfaceTest, + sortPluginsWithNoLoadedPluginsShouldReturnAnEmptyList) { + const auto sorted = handle_->SortPlugins(handle_->GetLoadOrder()); + + EXPECT_TRUE(sorted.empty()); +} + +TEST_P(GameInterfaceTest, sortPluginsShouldOnlySortTheGivenPlugins) { + handle_->LoadPlugins(GetInstalledPlugins(), false); + + std::vector plugins{blankEsp, blankDifferentEsp}; + const auto sorted = handle_->SortPlugins(plugins); + + EXPECT_EQ(plugins, sorted); +} + +TEST_P(GameInterfaceTest, + sortingShouldNotMakeUnnecessaryChangesToAnExistingLoadOrder) { + std::filesystem::remove(dataPath / std::filesystem::u8path(nonAsciiEsm)); + + handle_->LoadCurrentLoadOrderState(); + + auto plugins = GetInstalledPlugins(); + handle_->LoadPlugins({plugins.front()}, true); + plugins.erase(plugins.begin()); + handle_->LoadPlugins(plugins, false); + + std::vector expectedSortedOrder; + if (GetParam() == GameType::openmw) { + // The existing load order for OpenMW doesn't have plugins loading after + // their masters, because the game doesn't enforce that, and the test + // setup cannot enforce the positions of inactive plugins. + expectedSortedOrder = { + blankDifferentEsm, + blankDifferentMasterDependentEsm, + blankDifferentEsp, + blankDifferentPluginDependentEsp, + blankEsm, + blankMasterDependentEsm, + blankMasterDependentEsp, + blankEsp, + blankPluginDependentEsp, + masterFile, + blankDifferentMasterDependentEsp, + }; + } else { + expectedSortedOrder = getLoadOrder(); + } + + // Check stability by running the sort 100 times. + for (int i = 0; i < 100; i++) { + auto input = handle_->GetLoadOrder(); + auto sorted = handle_->SortPlugins(handle_->GetLoadOrder()); + ASSERT_EQ(expectedSortedOrder, sorted) << " for sort " << i; + } +} + +TEST_P(GameInterfaceTest, sortPluginsShouldThrowIfAGivenPluginIsNotLoaded) { + std::vector plugins{blankEsp, blankDifferentEsp}; + + EXPECT_THROW(handle_->SortPlugins(plugins), std::runtime_error); +} + TEST_P(GameInterfaceTest, clearLoadedPluginsShouldClearThePluginsCache) { handle_->LoadPlugins({std::filesystem::u8path(blankEsm)}, true); const auto pointer = handle_->GetPlugin(blankEsm); @@ -255,18 +550,54 @@ TEST_P(GameInterfaceTest, sortPluginsShouldSucceedIfPassedValidArguments) { } TEST_P(GameInterfaceTest, - isPluginActiveShouldReturnFalseIfTheGivenPluginIsNotActive) { + isPluginActiveShouldReturnTrueIfTheGivenPluginIsActive) { handle_->LoadCurrentLoadOrderState(); EXPECT_TRUE(handle_->IsPluginActive(blankEsm)); } TEST_P(GameInterfaceTest, - isPluginActiveShouldReturnTrueIfTheGivenPluginIsActive) { + isPluginActiveShouldReturnFalseIfTheGivenPluginIsNotActive) { handle_->LoadCurrentLoadOrderState(); EXPECT_FALSE(handle_->IsPluginActive(blankEsp)); } +TEST_P(GameInterfaceTest, + isPluginActiveShouldActivePluginAsActiveWithHeaderLoaded) { + handle_->LoadCurrentLoadOrderState(); + + ASSERT_NO_THROW(handle_->LoadPlugins({blankEsm}, true)); + + EXPECT_TRUE(handle_->IsPluginActive(blankEsm)); +} + +TEST_P(GameInterfaceTest, + isPluginActiveShouldInactivePluginAsInactiveWithHeaderLoaded) { + handle_->LoadCurrentLoadOrderState(); + + ASSERT_NO_THROW(handle_->LoadPlugins({blankEsp}, true)); + + EXPECT_FALSE(handle_->IsPluginActive(blankEsp)); +} + +TEST_P(GameInterfaceTest, + isPluginActiveShouldActivePluginAsActiveWhenFullyLoaded) { + handle_->LoadCurrentLoadOrderState(); + + ASSERT_NO_THROW(handle_->LoadPlugins({blankEsm}, false)); + + EXPECT_TRUE(handle_->IsPluginActive(blankEsm)); +} + +TEST_P(GameInterfaceTest, + isPluginActiveShouldInactivePluginAsInactiveWhenFullyLoaded) { + handle_->LoadCurrentLoadOrderState(); + + ASSERT_NO_THROW(handle_->LoadPlugins({blankEsp}, false)); + + EXPECT_FALSE(handle_->IsPluginActive(blankEsp)); +} + TEST_P(GameInterfaceTest, getLoadOrderShouldReturnTheCurrentLoadOrder) { // Remove the non-ASCII duplicate plugin. std::filesystem::remove(dataPath / std::filesystem::u8path(nonAsciiEsm)); diff --git a/cpp/src/tests/api/interface/main.cpp b/cpp/src/tests/api/interface/main.cpp index 29027fef..afd21763 100644 --- a/cpp/src/tests/api/interface/main.cpp +++ b/cpp/src/tests/api/interface/main.cpp @@ -25,10 +25,20 @@ #include #include "loot/api.h" +#include "tests/api/interface/metadata/conditional_metadata_test.h" +#include "tests/api/interface/metadata/file_test.h" +#include "tests/api/interface/metadata/group_test.h" +#include "tests/api/interface/metadata/location_test.h" +#include "tests/api/interface/metadata/message_content_test.h" +#include "tests/api/interface/metadata/message_test.h" +#include "tests/api/interface/metadata/plugin_cleaning_data_test.h" +#include "tests/api/interface/metadata/plugin_metadata_test.h" +#include "tests/api/interface/metadata/tag_test.h" #include "tests/api/interface/create_game_handle_test.h" #include "tests/api/interface/database_interface_test.h" #include "tests/api/interface/game_interface_test.h" #include "tests/api/interface/is_compatible_test.h" +#include "tests/api/interface/plugin_interface_test.h" int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/cpp/src/tests/api/internals/metadata/conditional_metadata_test.h b/cpp/src/tests/api/interface/metadata/conditional_metadata_test.h similarity index 81% rename from cpp/src/tests/api/internals/metadata/conditional_metadata_test.h rename to cpp/src/tests/api/interface/metadata/conditional_metadata_test.h index 4fdf0012..6f8b4804 100644 --- a/cpp/src/tests/api/internals/metadata/conditional_metadata_test.h +++ b/cpp/src/tests/api/interface/metadata/conditional_metadata_test.h @@ -22,8 +22,8 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_CONDITIONAL_METADATA_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_CONDITIONAL_METADATA_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_CONDITIONAL_METADATA_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_CONDITIONAL_METADATA_TEST #include "loot/metadata/conditional_metadata.h" #include "tests/common_game_test_fixture.h" @@ -41,12 +41,7 @@ protected: // but we only have the one so no prefix is necessary. INSTANTIATE_TEST_SUITE_P(, ConditionalMetadataTest, - ::testing::Values(GameType::tes4, - GameType::tes5, - GameType::fo3, - GameType::fonv, - GameType::fo4, - GameType::tes5se)); + ::testing::ValuesIn(ALL_GAME_TYPES)); TEST_P(ConditionalMetadataTest, defaultConstructorShouldSetEmptyConditionString) { diff --git a/cpp/src/tests/api/internals/metadata/file_test.h b/cpp/src/tests/api/interface/metadata/file_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/file_test.h rename to cpp/src/tests/api/interface/metadata/file_test.h index e1ae3a2d..16aa2e1d 100644 --- a/cpp/src/tests/api/internals/metadata/file_test.h +++ b/cpp/src/tests/api/interface/metadata/file_test.h @@ -22,15 +22,14 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_FILE_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_FILE_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_FILE_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_FILE_TEST #include #include "loot/metadata/file.h" -namespace loot { -namespace test { +namespace loot::test { TEST(File, defaultConstructorShouldInitialiseEmptyStrings) { File file; @@ -381,6 +380,5 @@ TEST(File, getDisplayNameShouldReturnDisplayString) { EXPECT_EQ("display", file.GetDisplayName()); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/group_test.h b/cpp/src/tests/api/interface/metadata/group_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/group_test.h rename to cpp/src/tests/api/interface/metadata/group_test.h index ae670152..f3cd21fb 100644 --- a/cpp/src/tests/api/internals/metadata/group_test.h +++ b/cpp/src/tests/api/interface/metadata/group_test.h @@ -22,15 +22,14 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_GROUP_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_GROUP_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_GROUP_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_GROUP_TEST #include #include "loot/metadata/group.h" -namespace loot { -namespace test { +namespace loot::test { TEST(Group, defaultConstructorShouldCreateDefaultGroup) { Group group; @@ -350,6 +349,5 @@ TEST( EXPECT_TRUE(group2 >= group1); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/location_test.h b/cpp/src/tests/api/interface/metadata/location_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/location_test.h rename to cpp/src/tests/api/interface/metadata/location_test.h index 6d9d3bcd..3a00cc5f 100644 --- a/cpp/src/tests/api/internals/metadata/location_test.h +++ b/cpp/src/tests/api/interface/metadata/location_test.h @@ -22,15 +22,14 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_LOCATION_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_LOCATION_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_LOCATION_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_LOCATION_TEST #include #include "loot/metadata/location.h" -namespace loot { -namespace test { +namespace loot::test { TEST(Location, defaultConstructorShouldInitialiseEmptyStrings) { Location location; @@ -234,6 +233,5 @@ TEST( EXPECT_FALSE(location1 >= location2); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/message_content_test.h b/cpp/src/tests/api/interface/metadata/message_content_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/message_content_test.h rename to cpp/src/tests/api/interface/metadata/message_content_test.h index f5f20cfb..00826bf8 100644 --- a/cpp/src/tests/api/internals/metadata/message_content_test.h +++ b/cpp/src/tests/api/interface/metadata/message_content_test.h @@ -22,15 +22,14 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_MESSAGE_CONTENT_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_MESSAGE_CONTENT_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_MESSAGE_CONTENT_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_MESSAGE_CONTENT_TEST #include #include "loot/metadata/message_content.h" -namespace loot { -namespace test { +namespace loot::test { const std::string french = "fr"; TEST(MessageContent, defaultConstructorShouldSetEmptyEnglishLanguageString) { @@ -330,6 +329,5 @@ TEST( EXPECT_EQ("test3", content.value().GetText()); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/message_test.h b/cpp/src/tests/api/interface/metadata/message_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/message_test.h rename to cpp/src/tests/api/interface/metadata/message_test.h index 8939440f..b55bc196 100644 --- a/cpp/src/tests/api/internals/metadata/message_test.h +++ b/cpp/src/tests/api/interface/metadata/message_test.h @@ -22,14 +22,13 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_MESSAGE_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_MESSAGE_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_MESSAGE_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_MESSAGE_TEST #include "loot/metadata/message.h" #include "tests/common_game_test_fixture.h" -namespace loot { -namespace test { +namespace loot::test { class MessageTest : public CommonGameTestFixture { protected: MessageTest() : CommonGameTestFixture(GameType::tes4) {} @@ -342,6 +341,5 @@ TEST_F( EXPECT_TRUE(message2 >= message1); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/plugin_cleaning_data_test.h b/cpp/src/tests/api/interface/metadata/plugin_cleaning_data_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/plugin_cleaning_data_test.h rename to cpp/src/tests/api/interface/metadata/plugin_cleaning_data_test.h index 890e5cc7..0d8a6d9c 100644 --- a/cpp/src/tests/api/internals/metadata/plugin_cleaning_data_test.h +++ b/cpp/src/tests/api/interface/metadata/plugin_cleaning_data_test.h @@ -22,14 +22,13 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_PLUGIN_CLEANING_DATA -#define LOOT_TESTS_API_INTERNALS_METADATA_PLUGIN_CLEANING_DATA +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_PLUGIN_CLEANING_DATA_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_PLUGIN_CLEANING_DATA_TEST #include "loot/metadata/plugin_cleaning_data.h" #include "tests/common_game_test_fixture.h" -namespace loot { -namespace test { +namespace loot::test { class PluginCleaningDataTest : public CommonGameTestFixture { protected: PluginCleaningDataTest() : @@ -310,6 +309,5 @@ TEST_F( EXPECT_TRUE(info2 > info1); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/plugin_metadata_test.h b/cpp/src/tests/api/interface/metadata/plugin_metadata_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/plugin_metadata_test.h rename to cpp/src/tests/api/interface/metadata/plugin_metadata_test.h index 4629c3de..fd4a97fe 100644 --- a/cpp/src/tests/api/internals/metadata/plugin_metadata_test.h +++ b/cpp/src/tests/api/interface/metadata/plugin_metadata_test.h @@ -22,14 +22,13 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_PLUGIN_METADATA_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_PLUGIN_METADATA_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_PLUGIN_METADATA_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_PLUGIN_METADATA_TEST #include "loot/metadata/plugin_metadata.h" #include "tests/common_game_test_fixture.h" -namespace loot { -namespace test { +namespace loot::test { class PluginMetadataTest : public CommonGameTestFixture { protected: PluginMetadataTest() : @@ -392,6 +391,5 @@ TEST_F(PluginMetadataTest, plugin.AsYaml()); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/tag_test.h b/cpp/src/tests/api/interface/metadata/tag_test.h similarity index 97% rename from cpp/src/tests/api/internals/metadata/tag_test.h rename to cpp/src/tests/api/interface/metadata/tag_test.h index e7793a4d..39836935 100644 --- a/cpp/src/tests/api/internals/metadata/tag_test.h +++ b/cpp/src/tests/api/interface/metadata/tag_test.h @@ -22,15 +22,14 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_TAG_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_TAG_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_TAG_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_TAG_TEST #include #include "loot/metadata/tag.h" -namespace loot { -namespace test { +namespace loot::test { TEST(Tag, defaultConstructorShouldSetEmptyNameAndConditionStringsForATagAddition) { Tag tag; @@ -284,6 +283,5 @@ TEST( EXPECT_TRUE(tag2 >= tag1); } } -} #endif diff --git a/cpp/src/tests/api/interface/plugin_interface_test.h b/cpp/src/tests/api/interface/plugin_interface_test.h new file mode 100644 index 00000000..ed8da8ba --- /dev/null +++ b/cpp/src/tests/api/interface/plugin_interface_test.h @@ -0,0 +1,486 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERFACE_PLUGIN_INTERFACE_TEST +#define LOOT_TESTS_API_INTERFACE_PLUGIN_INTERFACE_TEST + +#include "loot/api.h" +#include "tests/api/interface/api_game_operations_test.h" + +namespace loot::test { +class PluginInterfaceTest : public ApiGameOperationsTest { +protected: + PluginInterfaceTest() : + ApiGameOperationsTest(), + nonAsciiEsp(u8"non\u00C1scii.esp"), + otherNonAsciiEsp(u8"other non\u00C1scii.esp"), + blankArchive("Blank" + GetArchiveFileExtension(GetParam())), + blankSuffixArchive("Blank - Different - suffix" + + GetArchiveFileExtension(GetParam())) {} + + void SetUp() override { + ApiGameOperationsTest::SetUp(); + + handle_->LoadPlugins(GetInstalledPlugins(), false); + + if (!supportsLightPlugins(GetParam())) { + ASSERT_NO_THROW( + std::filesystem::copy(dataPath / blankEsp, dataPath / blankEsl)); + } + ASSERT_TRUE(std::filesystem::exists(dataPath / blankEsl)); + + // Make sure the plugins with non-ASCII filenames exists. + ASSERT_NO_THROW(std::filesystem::copy_file( + dataPath / blankEsp, dataPath / std::filesystem::u8path(nonAsciiEsp))); + ASSERT_NO_THROW(std::filesystem::copy_file( + dataPath / blankEsp, + dataPath / std::filesystem::u8path(otherNonAsciiEsp))); + + // Copy across archive files. + std::filesystem::path blankMasterDependentArchive; + if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::starfield) { + copyPlugin(getSourceArchivesPath(GetParam()), "Blank - Main.ba2"); + copyPlugin(getSourceArchivesPath(GetParam()), "Blank - Textures.ba2"); + + blankMasterDependentArchive = "Blank - Master Dependent - Main.ba2"; + std::filesystem::copy_file( + getSourceArchivesPath(GetParam()) / "Blank - Main.ba2", + dataPath / blankMasterDependentArchive); + ASSERT_TRUE( + std::filesystem::exists(dataPath / blankMasterDependentArchive)); + } else if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { + touch(dataPath / blankArchive); + + blankMasterDependentArchive = "Blank - Master Dependent.bsa"; + touch(dataPath / blankMasterDependentArchive); + } else { + copyPlugin(getSourcePluginsPath(), blankArchive); + + // Also create a copy for Blank - Master Dependent.esp to test overlap. + blankMasterDependentArchive = "Blank - Master Dependent.bsa"; + std::filesystem::copy_file(getSourcePluginsPath() / blankArchive, + dataPath / blankMasterDependentArchive); + ASSERT_TRUE( + std::filesystem::exists(dataPath / blankMasterDependentArchive)); + } + + // Create dummy archive files. + touch(dataPath / blankSuffixArchive); + + auto nonAsciiArchivePath = + dataPath / std::filesystem::u8path(u8"non\u00E1scii" + + GetArchiveFileExtension(GetParam())); + touch(dataPath / nonAsciiArchivePath); + + auto nonAsciiPrefixArchivePath = + dataPath / std::filesystem::u8path(u8"other non\u00E1scii2 - suffix" + + GetArchiveFileExtension(GetParam())); + touch(dataPath / nonAsciiPrefixArchivePath); + } + + std::shared_ptr LoadPluginHeader( + std::string_view pluginName) { + handle_->LoadPlugins({std::filesystem::u8path(pluginName)}, true); + + return handle_->GetPlugin(pluginName); + } + + std::shared_ptr LoadPlugin( + std::string_view pluginName) { + handle_->LoadPlugins({std::filesystem::u8path(pluginName)}, false); + + return handle_->GetPlugin(pluginName); + } + + std::string nonAsciiEsp; + std::string otherNonAsciiEsp; + std::string blankArchive; + std::string blankSuffixArchive; + + std::shared_ptr game_; + +private: + static std::string GetArchiveFileExtension(const GameType gameType) { + if (gameType == GameType::fo4 || gameType == GameType::fo4vr || + gameType == GameType::starfield) + return ".ba2"; + else + return ".bsa"; + } +}; + +class TestPlugin : public PluginInterface { +public: + std::string GetName() const override { return ""; } + + std::optional GetHeaderVersion() const override { + return std::optional(); + } + + std::optional GetVersion() const override { + return std::optional(); + } + + std::vector GetMasters() const override { return {}; } + + std::vector GetBashTags() const override { return {}; } + + std::optional GetCRC() const override { + return std::optional(); + } + + bool IsMaster() const override { return false; } + + bool IsLightPlugin() const override { return false; } + + bool IsMediumPlugin() const override { return false; } + + bool IsUpdatePlugin() const override { return false; } + + bool IsBlueprintPlugin() const override { return false; } + + bool IsValidAsLightPlugin() const override { return false; } + + bool IsValidAsMediumPlugin() const override { return false; } + + bool IsValidAsUpdatePlugin() const override { return false; } + + bool IsEmpty() const override { return false; } + + bool LoadsArchive() const override { return false; } + + bool DoRecordsOverlap(const PluginInterface&) const override { return false; } +}; + +// Pass an empty first argument, as it's a prefix for the test instantation, +// but we only have the one so no prefix is necessary. +INSTANTIATE_TEST_SUITE_P(, + PluginInterfaceTest, + ::testing::ValuesIn(ALL_GAME_TYPES)); + +TEST_P(PluginInterfaceTest, + shouldBeAbleToGetHeaderDataFromPluginLoadedHeaderOnly) { + const auto plugin = LoadPluginHeader(blankEsm); + + EXPECT_EQ(blankEsm, plugin->GetName()); + EXPECT_TRUE(plugin->GetMasters().empty()); + if (GetParam() == GameType::openmw || + GetParam() == GameType::oblivionRemastered) { + EXPECT_FALSE(plugin->IsMaster()); + } else { + EXPECT_TRUE(plugin->IsMaster()); + } + EXPECT_FALSE(plugin->IsEmpty()); + EXPECT_EQ("5.0", plugin->GetVersion()); + + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { + EXPECT_FLOAT_EQ(1.2f, plugin->GetHeaderVersion().value()); + } else if (GetParam() == GameType::tes4 || + GetParam() == GameType::oblivionRemastered) { + EXPECT_FLOAT_EQ(0.8f, plugin->GetHeaderVersion().value()); + } else if (GetParam() == GameType::starfield) { + EXPECT_FLOAT_EQ(0.96f, plugin->GetHeaderVersion().value()); + } else { + EXPECT_FLOAT_EQ(0.94f, plugin->GetHeaderVersion().value()); + } +} + +TEST_P(PluginInterfaceTest, shouldBeAbleToGetAllDataFromFullyLoadedPlugin) { + const auto plugin = LoadPlugin(blankEsm); + + EXPECT_EQ(blankEsm, plugin->GetName()); + EXPECT_TRUE(plugin->GetMasters().empty()); + if (GetParam() == GameType::openmw || + GetParam() == GameType::oblivionRemastered) { + EXPECT_FALSE(plugin->IsMaster()); + } else { + EXPECT_TRUE(plugin->IsMaster()); + } + EXPECT_FALSE(plugin->IsEmpty()); + EXPECT_EQ("5.0", plugin->GetVersion()); + + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { + EXPECT_FLOAT_EQ(1.2f, plugin->GetHeaderVersion().value()); + } else if (GetParam() == GameType::tes4 || + GetParam() == GameType::oblivionRemastered) { + EXPECT_FLOAT_EQ(0.8f, plugin->GetHeaderVersion().value()); + } else if (GetParam() == GameType::starfield) { + EXPECT_FLOAT_EQ(0.96f, plugin->GetHeaderVersion().value()); + } else { + EXPECT_FLOAT_EQ(0.94f, plugin->GetHeaderVersion().value()); + } + + EXPECT_EQ(blankEsmCrc, plugin->GetCRC()); +} + +TEST_P(PluginInterfaceTest, + loadingANonMasterPluginShouldReadTheMasterFlagAsFalse) { + const auto plugin = LoadPluginHeader(blankMasterDependentEsp); + + EXPECT_FALSE(plugin->IsMaster()); +} + +TEST_P( + PluginInterfaceTest, + isLightPluginShouldBeTrueForAPluginWithEslFileExtensionForFallout4AndSkyrimSe) { + const auto plugin1 = LoadPluginHeader(blankEsm); + const auto plugin2 = LoadPluginHeader(blankMasterDependentEsp); + + EXPECT_FALSE(plugin1->IsLightPlugin()); + EXPECT_FALSE(plugin2->IsLightPlugin()); + + if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::tes5se || GetParam() == GameType::tes5vr || + GetParam() == GameType::starfield) { + const auto plugin3 = LoadPluginHeader(blankEsl); + EXPECT_TRUE(plugin3->IsLightPlugin()); + } +} + +TEST_P(PluginInterfaceTest, + isMediumPluginShouldBeTrueForAMediumFlaggedPluginForStarfield) { + if (GetParam() != GameType::starfield) { + auto bytes = ReadFile(dataPath / blankEsm); + bytes[9] = 0x4; + WriteFile(dataPath / blankEsm, bytes); + } + + const auto& pluginName = + GetParam() == GameType::starfield ? blankMediumEsm : blankEsm; + const auto plugin = LoadPluginHeader(pluginName); + + EXPECT_EQ(GetParam() == GameType::starfield, plugin->IsMediumPlugin()); +} + +TEST_P(PluginInterfaceTest, + isUpdatePluginShouldOnlyBeTrueForAStarfieldUpdatePlugin) { + auto bytes = ReadFile(dataPath / blankMasterDependentEsp); + bytes[9] = 0x2; + WriteFile(dataPath / blankMasterDependentEsp, bytes); + + const auto plugin1 = LoadPluginHeader(blankEsp); + const auto plugin2 = LoadPluginHeader(blankMasterDependentEsp); + + EXPECT_FALSE(plugin1->IsUpdatePlugin()); + EXPECT_EQ(GetParam() == GameType::starfield, plugin2->IsUpdatePlugin()); +} + +TEST_P(PluginInterfaceTest, + isBlueprintPluginShouldOnlyBeTrueForAStarfieldBlueprintPlugin) { + SetBlueprintFlag(dataPath / blankMasterDependentEsp); + + const auto plugin1 = LoadPluginHeader(blankEsp); + const auto plugin2 = LoadPluginHeader(blankMasterDependentEsp); + + EXPECT_FALSE(plugin1->IsBlueprintPlugin()); + EXPECT_EQ(GetParam() == GameType::starfield, plugin2->IsBlueprintPlugin()); +} + +TEST_P(PluginInterfaceTest, loadingAPluginWithMastersShouldReadThemCorrectly) { + const auto plugin = LoadPluginHeader(blankMasterDependentEsp); + + if (GetParam() == GameType::starfield) { + EXPECT_EQ(std::vector({blankFullEsm}), plugin->GetMasters()); + } else { + EXPECT_EQ(std::vector({blankEsm}), plugin->GetMasters()); + } +} + +TEST_P( + PluginInterfaceTest, + loadsArchiveForAnArchiveThatExactlyMatchesAnEsmFileBasenameShouldReturnTrueForAllGamesExceptMorrowindAndOblivion) { + bool loadsArchive = LoadPluginHeader(blankEsm)->LoadsArchive(); + + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || + GetParam() == GameType::tes4 || + GetParam() == GameType::oblivionRemastered) + EXPECT_FALSE(loadsArchive); + else + EXPECT_TRUE(loadsArchive); +} + +#ifdef _WIN32 +TEST_P( + PluginInterfaceTest, + loadsArchiveForAnArchiveThatExactlyMatchesANonAsciiEspFileBasenameShouldReturnTrueForAllGamesExceptMorrowindAndStarfield) { + bool loadsArchive = LoadPluginHeader(nonAsciiEsp)->LoadsArchive(); + + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || + GetParam() == GameType::starfield) + EXPECT_FALSE(loadsArchive); + else + EXPECT_TRUE(loadsArchive); +} +#endif + +TEST_P( + PluginInterfaceTest, + loadsArchiveForAnArchiveThatExactlyMatchesAnEspFileBasenameShouldReturnTrueForAllGamesExceptMorrowind) { + bool loadsArchive = LoadPluginHeader(blankEsp)->LoadsArchive(); + + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) + EXPECT_FALSE(loadsArchive); + else + EXPECT_TRUE(loadsArchive); +} + +TEST_P( + PluginInterfaceTest, + loadsArchiveForAnArchiveWithAFilenameWhichStartsWithTheEsmFileBasenameShouldReturnTrueForOnlyTheFalloutGames) { + bool loadsArchive = LoadPluginHeader(blankDifferentEsm)->LoadsArchive(); + + if (GetParam() == GameType::fo3 || GetParam() == GameType::fonv || + GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr) { + EXPECT_TRUE(loadsArchive); + } else { + EXPECT_FALSE(loadsArchive); + } +} + +TEST_P( + PluginInterfaceTest, + loadsArchiveForAnArchiveWithAFilenameWhichStartsWithTheEspFileBasenameShouldReturnTrueForOnlyOblivionAndTheFalloutGames) { + bool loadsArchive = LoadPluginHeader(blankDifferentEsp)->LoadsArchive(); + + if (GetParam() == GameType::tes4 || GetParam() == GameType::fo3 || + GetParam() == GameType::fonv || GetParam() == GameType::fo4 || + GetParam() == GameType::fo4vr || + GetParam() == GameType::oblivionRemastered) { + EXPECT_TRUE(loadsArchive); + } else { + EXPECT_FALSE(loadsArchive); + } +} + +#ifdef _WIN32 +TEST_P( + PluginInterfaceTest, + loadsArchiveForAnArchiveWithAFilenameWhichStartsWithTheNonAsciiEspFileBasenameShouldReturnTrueForOnlyOblivionAndTheFalloutGames) { + bool loadsArchive = LoadPluginHeader(otherNonAsciiEsp)->LoadsArchive(); + + if (GetParam() == GameType::tes4 || GetParam() == GameType::fo3 || + GetParam() == GameType::fonv || GetParam() == GameType::fo4 || + GetParam() == GameType::fo4vr || + GetParam() == GameType::oblivionRemastered) { + EXPECT_TRUE(loadsArchive); + } else { + EXPECT_FALSE(loadsArchive); + } +} +#endif + +TEST_P(PluginInterfaceTest, + loadsArchiveShouldReturnFalseForAPluginThatDoesNotLoadAnArchive) { + const auto pluginName = GetParam() == GameType::starfield + ? blankDifferentEsp + : blankDifferentMasterDependentEsp; + bool loadsArchive = LoadPluginHeader(pluginName)->LoadsArchive(); + + EXPECT_FALSE(loadsArchive); +} + +TEST_P( + PluginInterfaceTest, + isValidAsLightPluginShouldReturnTrueOnlyForASkyrimSEOrFallout4PluginWithNewFormIdsBetween0x800And0xFFFInclusive) { + bool valid = LoadPlugin(blankEsm)->IsValidAsLightPlugin(); + if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::tes5se || GetParam() == GameType::tes5vr || + GetParam() == GameType::starfield) { + EXPECT_TRUE(valid); + } else { + EXPECT_FALSE(valid); + } +} + +TEST_P( + PluginInterfaceTest, + isValidAsMediumPluginShouldReturnTrueOnlyForAStarfieldPluginWithNewFormIdsBetween0And0xFFFFInclusive) { + bool valid = LoadPlugin(blankEsm)->IsValidAsMediumPlugin(); + if (GetParam() == GameType::starfield) { + EXPECT_TRUE(valid); + } else { + EXPECT_FALSE(valid); + } +} + +TEST_P( + PluginInterfaceTest, + IsValidAsUpdatePluginShouldOnlyReturnTrueForAStarfieldPluginWithNoNewRecords) { + const auto sourcePluginName = + GetParam() == GameType::starfield ? blankFullEsm : blankEsp; + const auto updatePluginName = GetParam() == GameType::starfield + ? blankMasterDependentEsp + : blankDifferentPluginDependentEsp; + + std::vector> plugins; + plugins.push_back(LoadPlugin(sourcePluginName)); + plugins.push_back(LoadPlugin(updatePluginName)); + + EXPECT_FALSE(plugins[0]->IsValidAsUpdatePlugin()); + EXPECT_EQ(GetParam() == GameType::starfield, + plugins[1]->IsValidAsUpdatePlugin()); +} + +TEST_P(PluginInterfaceTest, + doRecordsOverlapShouldReturnFalseIfTheArgumentIsNotAPluginObject) { + const auto plugin1 = LoadPlugin(blankEsm); + TestPlugin plugin2; + + EXPECT_FALSE(plugin1->DoRecordsOverlap(plugin2)); +} + +TEST_P(PluginInterfaceTest, + doRecordsOverlapShouldReturnFalseForTwoPluginsWithOnlyHeadersLoaded) { + const auto plugin1 = LoadPluginHeader(blankEsm); + const auto plugin2 = LoadPluginHeader(blankMasterDependentEsm); + + EXPECT_FALSE(plugin1->DoRecordsOverlap(*plugin2)); + EXPECT_FALSE(plugin2->DoRecordsOverlap(*plugin1)); +} + +TEST_P(PluginInterfaceTest, + doRecordsOverlapShouldReturnFalseIfThePluginsHaveUnrelatedRecords) { + const auto plugin1 = LoadPlugin(blankEsm); + const auto plugin2 = LoadPlugin(blankEsp); + + EXPECT_FALSE(plugin1->DoRecordsOverlap(*plugin2)); + EXPECT_FALSE(plugin2->DoRecordsOverlap(*plugin1)); +} + +TEST_P(PluginInterfaceTest, + doRecordsOverlapShouldReturnTrueIfOnePluginOverridesTheOthersRecords) { + const auto plugin1Name = + GetParam() == GameType::starfield ? blankFullEsm : blankEsm; + + const auto plugin1 = LoadPlugin(plugin1Name); + const auto plugin2 = LoadPlugin(blankMasterDependentEsm); + + EXPECT_TRUE(plugin1->DoRecordsOverlap(*plugin2)); + EXPECT_TRUE(plugin2->DoRecordsOverlap(*plugin1)); +} + +} + +#endif diff --git a/cpp/src/tests/api/internals/main.cpp b/cpp/src/tests/api/internals/main.cpp index 1b0774e6..d070639d 100644 --- a/cpp/src/tests/api/internals/main.cpp +++ b/cpp/src/tests/api/internals/main.cpp @@ -4,15 +4,6 @@ #include "libloot-cpp/src/lib.rs.h" #include "rust/cxx.h" -#include "tests/api/internals/metadata/conditional_metadata_test.h" -#include "tests/api/internals/metadata/file_test.h" -#include "tests/api/internals/metadata/group_test.h" -#include "tests/api/internals/metadata/location_test.h" -#include "tests/api/internals/metadata/message_content_test.h" -#include "tests/api/internals/metadata/message_test.h" -#include "tests/api/internals/metadata/plugin_cleaning_data_test.h" -#include "tests/api/internals/metadata/plugin_metadata_test.h" -#include "tests/api/internals/metadata/tag_test.h" namespace rust { template @@ -28,7 +19,7 @@ namespace loot::rust { TEST(libloot_version, shouldReturnExpectedValue) { auto version = libloot_version(); - EXPECT_EQ(version, "0.26.0"); + EXPECT_EQ(version, "0.26.1"); } TEST(libloot_revision, shouldReturnExpectedValue) { diff --git a/cpp/src/tests/common_game_test_fixture.h b/cpp/src/tests/common_game_test_fixture.h index 316a5068..dc47b44f 100644 --- a/cpp/src/tests/common_game_test_fixture.h +++ b/cpp/src/tests/common_game_test_fixture.h @@ -39,7 +39,7 @@ along with LOOT. If not, see namespace loot { namespace test { -static const std::array ALL_GAME_TYPES = { +static const std::array ALL_GAME_TYPES = { GameType::tes3, GameType::tes4, GameType::tes5, @@ -51,6 +51,7 @@ static const std::array ALL_GAME_TYPES = { GameType::fo4vr, GameType::starfield, GameType::openmw, + GameType::oblivionRemastered, }; class CommonGameTestFixture : public ::testing::Test { @@ -236,8 +237,11 @@ protected: } } for (const auto& plugin : loadOrder) actual.push_back(plugin.second); - } else if (gameType_ == GameType::tes5) { - std::ifstream in(localPath / "loadorder.txt"); + } else if (gameType_ == GameType::tes5 || + gameType_ == GameType::oblivionRemastered) { + const auto& parentPath = + gameType_ == GameType::oblivionRemastered ? dataPath : localPath; + std::ifstream in(parentPath / "loadorder.txt"); while (in) { std::string line; std::getline(in, line); @@ -380,6 +384,17 @@ protected: WriteFile(path, bytes); } + static bool endsWith(const std::string& str, const std::string& suffix) { + if (str.length() < suffix.length()) { + return false; + } + + auto view = std::string_view(str); + view.remove_prefix(str.length() - suffix.length()); + + return view == suffix; + } + private: GameType gameType_; const std::filesystem::path rootTestPath; @@ -418,7 +433,8 @@ private: std::string getMasterFile() const { if (gameType_ == GameType::tes3 || gameType_ == GameType::openmw) return "Morrowind.esm"; - else if (gameType_ == GameType::tes4) + else if (gameType_ == GameType::tes4 || + gameType_ == GameType::oblivionRemastered) return "Oblivion.esm"; else if (gameType_ == GameType::tes5 || gameType_ == GameType::tes5se || gameType_ == GameType::tes5vr) @@ -440,6 +456,8 @@ private: return "resources/vfs"; } else if (gameType_ == GameType::tes3) { return "Data Files"; + } else if (gameType_ == GameType::oblivionRemastered) { + return "OblivionRemastered/Content/Dev/ObvData/Data"; } else { return "Data"; } @@ -451,6 +469,7 @@ private: case GameType::openmw: return 0x790DC6FB; case GameType::tes4: + case GameType::oblivionRemastered: return 0x374E2A6F; case GameType::starfield: return 0xDE586309; @@ -477,7 +496,9 @@ private: } } } else { - std::ofstream out(localPath / "Plugins.txt"); + const auto& parentPath = + gameType_ == GameType::oblivionRemastered ? dataPath : localPath; + std::ofstream out(parentPath / "Plugins.txt"); for (const auto& plugin : loadOrder) { if (supportsLightPlugins(gameType_)) { if (plugin.second) @@ -504,8 +525,11 @@ private: } modificationTime += std::chrono::seconds(60); } - } else if (gameType_ == GameType::tes5) { - std::ofstream out(localPath / "loadorder.txt"); + } else if (gameType_ == GameType::tes5 || + gameType_ == GameType::oblivionRemastered) { + const auto& parentPath = + gameType_ == GameType::oblivionRemastered ? dataPath : localPath; + std::ofstream out(parentPath / "loadorder.txt"); for (const auto& plugin : loadOrder) out << plugin.first << std::endl; } } @@ -514,17 +538,6 @@ private: return gameType == GameType::tes3 || gameType == GameType::tes4 || gameType == GameType::fo3 || gameType == GameType::fonv; } - - static bool endsWith(const std::string& str, const std::string& suffix) { - if (str.length() < suffix.length()) { - return false; - } - - auto view = std::string_view(str); - view.remove_prefix(str.length() - suffix.length()); - - return view == suffix; - } }; } } diff --git a/cpp/src/tests/test_helpers.h b/cpp/src/tests/test_helpers.h index a40d23c0..9b8d5a68 100644 --- a/cpp/src/tests/test_helpers.h +++ b/cpp/src/tests/test_helpers.h @@ -42,7 +42,8 @@ std::filesystem::path getSourcePluginsPath(GameType gameType) { using std::filesystem::absolute; if (gameType == GameType::tes3 || gameType == GameType::openmw) { return absolute("./testing-plugins/Morrowind/Data Files"); - } else if (gameType == GameType::tes4) { + } else if (gameType == GameType::tes4 || + gameType == GameType::oblivionRemastered) { return absolute("./testing-plugins/Oblivion/Data"); } else if (gameType == GameType::starfield) { return absolute("./testing-plugins/Starfield/Data"); diff --git a/ffi-errors/Cargo.toml b/ffi-errors/Cargo.toml index 600bcc53..90a01d20 100644 --- a/ffi-errors/Cargo.toml +++ b/ffi-errors/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot-ffi-errors" -version = "0.26.0" +version = "0.26.1" edition = "2024" [dependencies] diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml index 904239a1..59f0cd7b 100644 --- a/nodejs/Cargo.toml +++ b/nodejs/Cargo.toml @@ -1,7 +1,7 @@ [package] edition = "2021" name = "libloot-nodejs" -version = "0.26.0" +version = "0.26.1" [lib] crate-type = ["cdylib"] diff --git a/nodejs/__test__/index.spec.mjs b/nodejs/__test__/index.spec.mjs index 79b5d2c3..39100e59 100644 --- a/nodejs/__test__/index.spec.mjs +++ b/nodejs/__test__/index.spec.mjs @@ -3,7 +3,7 @@ import test from 'ava' import { liblootVersion, isCompatible, Group } from '../index.js' test('liblootVersion', t => { - t.is(liblootVersion(), "0.26.0") + t.is(liblootVersion(), "0.26.1") }); test('isCompatible', t => { diff --git a/nodejs/npm/linux-x64-gnu/package.json b/nodejs/npm/linux-x64-gnu/package.json index 870e000e..e07ad6b4 100644 --- a/nodejs/npm/linux-x64-gnu/package.json +++ b/nodejs/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "libloot-nodejs-linux-x64-gnu", - "version": "0.26.0", + "version": "0.26.1", "os": [ "linux" ], diff --git a/nodejs/npm/win32-x64-msvc/package.json b/nodejs/npm/win32-x64-msvc/package.json index 8d98ecfe..c68ab72c 100644 --- a/nodejs/npm/win32-x64-msvc/package.json +++ b/nodejs/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "libloot-nodejs-win32-x64-msvc", - "version": "0.26.0", + "version": "0.26.1", "os": [ "win32" ], diff --git a/nodejs/package.json b/nodejs/package.json index 5f534b89..44ad1324 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "libloot-nodejs", - "version": "0.26.0", + "version": "0.26.1", "main": "index.js", "types": "index.d.ts", "napi": { diff --git a/nodejs/src/game.rs b/nodejs/src/game.rs index 0932010f..8fb45d3a 100644 --- a/nodejs/src/game.rs +++ b/nodejs/src/game.rs @@ -19,6 +19,7 @@ pub enum GameType { Morrowind, Starfield, OpenMW, + OblivionRemastered, } impl TryFrom for GameType { @@ -37,6 +38,7 @@ impl TryFrom for GameType { libloot::GameType::Morrowind => Ok(GameType::Morrowind), libloot::GameType::Starfield => Ok(GameType::Starfield), libloot::GameType::OpenMW => Ok(GameType::OpenMW), + libloot::GameType::OblivionRemastered => Ok(GameType::OblivionRemastered), _ => Err(UnsupportedEnumValueError), } } @@ -56,6 +58,7 @@ impl From for libloot::GameType { GameType::Morrowind => libloot::GameType::Morrowind, GameType::Starfield => libloot::GameType::Starfield, GameType::OpenMW => libloot::GameType::OpenMW, + GameType::OblivionRemastered => libloot::GameType::OblivionRemastered, } } } diff --git a/python/Cargo.toml b/python/Cargo.toml index 529f1d93..40307e5a 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot_python" -version = "0.26.0" +version = "0.26.1" edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/python/src/game.rs b/python/src/game.rs index 84b7699d..2eabb52d 100644 --- a/python/src/game.rs +++ b/python/src/game.rs @@ -19,6 +19,7 @@ pub enum GameType { Morrowind, Starfield, OpenMW, + OblivionRemastered, } impl TryFrom for GameType { @@ -37,6 +38,7 @@ impl TryFrom for GameType { libloot::GameType::Morrowind => Ok(GameType::Morrowind), libloot::GameType::Starfield => Ok(GameType::Starfield), libloot::GameType::OpenMW => Ok(GameType::OpenMW), + libloot::GameType::OblivionRemastered => Ok(GameType::OblivionRemastered), _ => Err(UnsupportedEnumValueError), } } @@ -58,6 +60,7 @@ impl TryFrom for libloot::GameType { GameType::Morrowind => Ok(libloot::GameType::Morrowind), GameType::Starfield => Ok(libloot::GameType::Starfield), GameType::OpenMW => Ok(libloot::GameType::OpenMW), + GameType::OblivionRemastered => Ok(libloot::GameType::OblivionRemastered), } } } diff --git a/src/archive/find.rs b/src/archive/find.rs index 796708f7..8b9a4b81 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -27,7 +27,7 @@ pub fn find_associated_archives( // Oblivion .esp files can load archives which begin with the plugin // basename. - GameType::Oblivion => { + GameType::Oblivion | GameType::OblivionRemastered => { if has_ascii_extension(plugin_path, "esp") { find_associated_archives_with_arbitrary_suffixes(plugin_path, game_cache) } else { @@ -308,7 +308,10 @@ mod tests { if matches!( game_type, - GameType::Morrowind | GameType::OpenMW | GameType::Oblivion + GameType::Morrowind + | GameType::OpenMW + | GameType::Oblivion + | GameType::OblivionRemastered ) { assert!(archives.is_empty()); } else { @@ -397,6 +400,7 @@ mod tests { if matches!( game_type, GameType::Oblivion + | GameType::OblivionRemastered | GameType::Fallout3 | GameType::FalloutNV | GameType::Fallout4 diff --git a/src/game.rs b/src/game.rs index c70d5a3e..389d8f9c 100644 --- a/src/game.rs +++ b/src/game.rs @@ -58,6 +58,8 @@ pub enum GameType { Starfield, /// OpenMW OpenMW, + /// The Elder Scrolls IV: Oblivion Remastered + OblivionRemastered, } impl Display for GameType { @@ -74,6 +76,7 @@ impl Display for GameType { GameType::Morrowind => write!(f, "The Elder Scrolls III: Morrowind"), GameType::Starfield => write!(f, "Starfield"), GameType::OpenMW => write!(f, "OpenMW"), + GameType::OblivionRemastered => write!(f, "The Elder Scrolls IV: Oblivion Remastered"), } } } @@ -92,6 +95,7 @@ impl From for loadorder::GameId { GameType::Morrowind => loadorder::GameId::Morrowind, GameType::Starfield => loadorder::GameId::Starfield, GameType::OpenMW => loadorder::GameId::OpenMW, + GameType::OblivionRemastered => loadorder::GameId::OblivionRemastered, } } } @@ -99,7 +103,9 @@ impl From for loadorder::GameId { impl From for loot_condition_interpreter::GameType { fn from(value: GameType) -> Self { match value { - GameType::Oblivion => loot_condition_interpreter::GameType::Oblivion, + GameType::Oblivion | GameType::OblivionRemastered => { + loot_condition_interpreter::GameType::Oblivion + } GameType::Skyrim => loot_condition_interpreter::GameType::Skyrim, GameType::Fallout3 => loot_condition_interpreter::GameType::Fallout3, GameType::FalloutNV => loot_condition_interpreter::GameType::FalloutNV, @@ -117,7 +123,7 @@ impl From for loot_condition_interpreter::GameType { impl From for esplugin::GameId { fn from(value: GameType) -> Self { match value { - GameType::Oblivion => esplugin::GameId::Oblivion, + GameType::Oblivion | GameType::OblivionRemastered => esplugin::GameId::Oblivion, GameType::Skyrim => esplugin::GameId::Skyrim, GameType::Fallout3 => esplugin::GameId::Fallout3, GameType::FalloutNV => esplugin::GameId::FalloutNV, @@ -555,6 +561,9 @@ fn data_path(game_type: GameType, game_path: &Path) -> PathBuf { match game_type { GameType::Morrowind => game_path.join("Data Files"), GameType::OpenMW => game_path.join("resources/vfs"), + GameType::OblivionRemastered => { + game_path.join("OblivionRemastered/Content/Dev/ObvData/Data") + } _ => game_path.join("Data"), } } @@ -916,7 +925,10 @@ mod tests { fn should_succeed_for_morrowind_if_given_valid_game_path(game_type: GameType) { let fixture = Fixture::new(game_type); - if matches!(game_type, GameType::Morrowind | GameType::OpenMW) { + if matches!( + game_type, + GameType::Morrowind | GameType::OpenMW | GameType::OblivionRemastered + ) { assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); } else { assert!(Game::new(fixture.game_type, &fixture.game_path).is_err()); diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 3cff3a2e..f02402fc 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -170,7 +170,10 @@ impl Plugin { /// sometimes referred to as *master files* or simply *masters*, while the /// other meaning is always referenced in relation to another plugin. pub fn is_master(&self) -> bool { - if self.game_type == GameType::OpenMW { + if matches!( + self.game_type, + GameType::OpenMW | GameType::OblivionRemastered + ) { false } else { self.plugin @@ -535,7 +538,11 @@ mod tests { assert_eq!(plugin_name, plugin.name()); assert_eq!(expected_masters, plugin.masters().unwrap()); - assert_eq!(game_type != GameType::OpenMW, plugin.is_master()); + if matches!(game_type, GameType::OpenMW | GameType::OblivionRemastered) { + assert!(!plugin.is_master()); + } else { + assert!(plugin.is_master()); + } assert!(!plugin.is_empty()); assert!(plugin.version().is_none()); @@ -544,7 +551,9 @@ mod tests { GameType::Morrowind | GameType::OpenMW => { assert_eq!(1.2, plugin.header_version().unwrap()); } - GameType::Oblivion => assert_eq!(0.8, plugin.header_version().unwrap()), + GameType::Oblivion | GameType::OblivionRemastered => { + assert_eq!(0.8, plugin.header_version().unwrap()); + } GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), _ => assert_eq!(0.94, plugin.header_version().unwrap()), } @@ -606,7 +615,11 @@ mod tests { assert_eq!(plugin_name, plugin.name()); assert_eq!(expected_masters, plugin.masters().unwrap()); - assert_eq!(game_type != GameType::OpenMW, plugin.is_master()); + if matches!(game_type, GameType::OpenMW | GameType::OblivionRemastered) { + assert!(!plugin.is_master()); + } else { + assert!(plugin.is_master()); + } assert!(!plugin.is_empty()); assert!(plugin.version().is_none()); @@ -615,7 +628,9 @@ mod tests { GameType::Morrowind | GameType::OpenMW => { assert_eq!(1.2, plugin.header_version().unwrap()); } - GameType::Oblivion => assert_eq!(0.8, plugin.header_version().unwrap()), + GameType::Oblivion | GameType::OblivionRemastered => { + assert_eq!(0.8, plugin.header_version().unwrap()); + } GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), _ => assert_eq!(0.94, plugin.header_version().unwrap()), } @@ -623,7 +638,7 @@ mod tests { let expected_crc = match game_type { GameType::Morrowind | GameType::OpenMW => 3_317_676_987, GameType::Starfield => 1_422_425_298, - GameType::Oblivion => 3_759_349_588, + GameType::Oblivion | GameType::OblivionRemastered => 3_759_349_588, _ => 3_000_242_590, }; diff --git a/src/tests.rs b/src/tests.rs index 4cf5fd38..96ecef30 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -31,7 +31,9 @@ pub fn source_plugins_path(game_type: GameType) -> PathBuf { GameType::Morrowind | GameType::OpenMW => { absolute("./testing-plugins/Morrowind/Data Files") } - GameType::Oblivion => absolute("./testing-plugins/Oblivion/Data"), + GameType::Oblivion | GameType::OblivionRemastered => { + absolute("./testing-plugins/Oblivion/Data") + } GameType::Starfield => absolute("./testing-plugins/Starfield/Data"), GameType::Fallout3 | GameType::FalloutNV | GameType::Skyrim => { absolute("./testing-plugins/Skyrim/Data") @@ -44,7 +46,7 @@ pub fn source_plugins_path(game_type: GameType) -> PathBuf { fn master_file(game_type: GameType) -> &'static str { match game_type { GameType::Morrowind | GameType::OpenMW => "Morrowind.esm", - GameType::Oblivion => "Oblivion.esm", + GameType::Oblivion | GameType::OblivionRemastered => "Oblivion.esm", GameType::Skyrim | GameType::SkyrimSE | GameType::SkyrimVR => "Skyrim.esm", GameType::Fallout3 => "Fallout3.esm", GameType::FalloutNV => "FalloutNV.esm", @@ -155,7 +157,7 @@ fn set_load_order( mod_time += Duration::from_secs(60); } - } else if game_type == GameType::Skyrim { + } else if matches!(game_type, GameType::Skyrim | GameType::OblivionRemastered) { let mut file = File::create(local_path.join("loadorder.txt")).unwrap(); for (plugin, _) in load_order { writeln!(file, "{plugin}").unwrap(); @@ -167,6 +169,9 @@ fn data_path(game_type: GameType, game_path: &Path) -> PathBuf { match game_type { GameType::OpenMW => game_path.join("resources/vfs"), GameType::Morrowind => game_path.join("Data Files"), + GameType::OblivionRemastered => { + game_path.join("OblivionRemastered/Content/Dev/ObvData/Data") + } _ => game_path.join("Data"), } } @@ -315,7 +320,7 @@ impl Fixture { } #[test_parameter] -pub const ALL_GAME_TYPES: [GameType; 11] = [ +pub const ALL_GAME_TYPES: [GameType; 12] = [ GameType::Oblivion, GameType::Skyrim, GameType::Fallout3, @@ -327,6 +332,7 @@ pub const ALL_GAME_TYPES: [GameType; 11] = [ GameType::Morrowind, GameType::Starfield, GameType::OpenMW, + GameType::OblivionRemastered, ]; mod unicase { From 1317eea0559311db508c43430f22a6c328f5f615 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 5 May 2025 11:45:50 +0100 Subject: [PATCH 132/206] Use Rust Filename comparisons for C++ Filename operators To prevent inconsistencies between implementations. This means allocating new boxed Filenames for each comparison, but an extra string copy is avoided by making the C++ operators friends of the C++ class (which is ABI-safe). --- Cargo.lock | 1 - cpp/Cargo.toml | 1 - cpp/include/loot/metadata/filename.h | 4 ++++ cpp/src/api/metadata/filename.cpp | 6 ++++-- cpp/src/lib.rs | 23 ++++++++++++----------- cpp/src/metadata.rs | 2 +- 6 files changed, 21 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 697c9934..163ae5f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,7 +573,6 @@ dependencies = [ "delegate", "libloot", "libloot-ffi-errors", - "unicase", ] [[package]] diff --git a/cpp/Cargo.toml b/cpp/Cargo.toml index c22adf5d..827ad2ee 100644 --- a/cpp/Cargo.toml +++ b/cpp/Cargo.toml @@ -9,7 +9,6 @@ cxx = { version = "1.0", features = ["c++17"] } delegate = "0.13.2" libloot = { path = ".." } libloot-ffi-errors = { path = "../ffi-errors" } -unicase = "2.8.1" [build-dependencies] cxx-build = "1.0" diff --git a/cpp/include/loot/metadata/filename.h b/cpp/include/loot/metadata/filename.h index 3c7fc9c9..81584fb5 100644 --- a/cpp/include/loot/metadata/filename.h +++ b/cpp/include/loot/metadata/filename.h @@ -54,6 +54,10 @@ public: private: std::string filename_; + + LOOT_API friend bool operator==(const Filename& lhs, const Filename& rhs); + + LOOT_API friend bool operator<(const Filename& lhs, const Filename& rhs); }; /** diff --git a/cpp/src/api/metadata/filename.cpp b/cpp/src/api/metadata/filename.cpp index 9d24d90e..e05c2b8e 100644 --- a/cpp/src/api/metadata/filename.cpp +++ b/cpp/src/api/metadata/filename.cpp @@ -35,7 +35,8 @@ Filename::Filename(std::string_view filename) : filename_(filename) {} Filename::operator std::string() const { return filename_; } bool operator==(const Filename& lhs, const Filename& rhs) { - return loot::rust::compare_filenames(std::string(lhs), std::string(rhs)) == 0; + return loot::rust::new_filename(lhs.filename_) + ->eq(*loot::rust::new_filename(rhs.filename_)); } bool operator!=(const Filename& lhs, const Filename& rhs) { @@ -43,7 +44,8 @@ bool operator!=(const Filename& lhs, const Filename& rhs) { } bool operator<(const Filename& lhs, const Filename& rhs) { - return loot::rust::compare_filenames(std::string(lhs), std::string(rhs)) < 0; + return loot::rust::new_filename(lhs.filename_) + ->lt(*loot::rust::new_filename(rhs.filename_)); } bool operator>(const Filename& lhs, const Filename& rhs) { return rhs < lhs; } diff --git a/cpp/src/lib.rs b/cpp/src/lib.rs index b1e3f141..6d33bcb4 100644 --- a/cpp/src/lib.rs +++ b/cpp/src/lib.rs @@ -119,7 +119,6 @@ use std::{ ffi::{CString, c_char, c_uchar, c_uint, c_void}, sync::{Mutex, atomic::AtomicPtr}, }; -use unicase::UniCase; use libloot::set_logging_callback; pub use libloot::{is_compatible, libloot_revision, libloot_version}; @@ -181,14 +180,6 @@ pub type OptionalPluginMetadata = Optional; pub type OptionalCrc = Optional; -fn compare_filenames(lhs: &str, rhs: &str) -> i8 { - match UniCase::new(lhs).cmp(&UniCase::new(rhs)) { - std::cmp::Ordering::Less => -1, - std::cmp::Ordering::Equal => 0, - std::cmp::Ordering::Greater => 1, - } -} - fn set_log_level(level: ffi::LogLevel) -> Result<(), VerboseError> { libloot::set_log_level(level.try_into()?); Ok(()) @@ -295,8 +286,6 @@ mod ffi { contents: &[MessageContent], language: &str, ) -> OptionalMessageContentRef; - - fn compare_filenames(lhs: &str, rhs: &str) -> i8; } extern "Rust" { @@ -633,6 +622,18 @@ mod ffi { pub fn as_str(&self) -> &str; pub fn boxed_clone(&self) -> Box; + + pub fn eq(&self, other: &Filename) -> bool; + + pub fn ne(&self, other: &Filename) -> bool; + + pub fn lt(&self, other: &Filename) -> bool; + + pub fn le(&self, other: &Filename) -> bool; + + pub fn gt(&self, other: &Filename) -> bool; + + pub fn ge(&self, other: &Filename) -> bool; } extern "Rust" { diff --git a/cpp/src/metadata.rs b/cpp/src/metadata.rs index 7cbf6085..72c11ad5 100644 --- a/cpp/src/metadata.rs +++ b/cpp/src/metadata.rs @@ -446,7 +446,7 @@ impl From> for libloot::metadata::File { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, PartialOrd)] #[repr(transparent)] pub struct Filename(libloot::metadata::Filename); From 43d0904a6befb7239988a569071afc57eeef9078 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 5 May 2025 11:47:28 +0100 Subject: [PATCH 133/206] Don't use C++ regex for C++ NameMatches calls To prevent inconsistencies between the C++ and Rust regex implementations. This does mean a boxed copy of the PluginMetadata object is made for each call, but fortunately LOOT doesn't actually make any calls to NameMatches(). --- cpp/src/api/metadata/plugin_metadata.cpp | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/cpp/src/api/metadata/plugin_metadata.cpp b/cpp/src/api/metadata/plugin_metadata.cpp index 5163d278..38dc6669 100644 --- a/cpp/src/api/metadata/plugin_metadata.cpp +++ b/cpp/src/api/metadata/plugin_metadata.cpp @@ -80,11 +80,7 @@ std::string TrimDotGhostExtension(std::string&& filename) { namespace loot { // If the name passed ends in '.ghost', that should be trimmed. PluginMetadata::PluginMetadata(std::string_view n) : - name_(TrimDotGhostExtension(std::string(n))) { - if (IsRegexPlugin()) { - nameRegex_ = std::regex(name_, std::regex::ECMAScript | std::regex::icase); - } -} + name_(TrimDotGhostExtension(std::string(n))) {} void PluginMetadata::MergeMetadata(const PluginMetadata& plugin) { if (plugin.HasNameOnly()) @@ -193,16 +189,8 @@ bool PluginMetadata::IsRegexPlugin() const { } bool PluginMetadata::NameMatches(std::string_view pluginName) const { - if (IsRegexPlugin()) { - if (!nameRegex_.has_value()) { - throw std::runtime_error("Regex plugin does not have regex object"); - } - - return std::regex_match( - pluginName.begin(), pluginName.end(), nameRegex_.value()); - } - - return loot::rust::compare_filenames(name_, convert(pluginName)) == 0; + const auto metadata = convert(*this); + return metadata->name_matches(convert(pluginName)); } std::string PluginMetadata::AsYaml() const { From 9168e5e91e5f74b0e98aef8b1ba68913bf5c809b Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 5 May 2025 11:50:21 +0100 Subject: [PATCH 134/206] Remove commented-out include --- cpp/src/api/metadata/filename.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/cpp/src/api/metadata/filename.cpp b/cpp/src/api/metadata/filename.cpp index e05c2b8e..0552c7f1 100644 --- a/cpp/src/api/metadata/filename.cpp +++ b/cpp/src/api/metadata/filename.cpp @@ -26,7 +26,6 @@ #include -// #include "api/helpers/text.h" #include "libloot-cpp/src/lib.rs.h" namespace loot { From 531210afac75616653c7185bc352782aebe88f83 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Mon, 5 May 2025 11:54:58 +0100 Subject: [PATCH 135/206] Remove some outdated info from readme --- cpp/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/cpp/README.md b/cpp/README.md index 249f6cda..c493dc61 100644 --- a/cpp/README.md +++ b/cpp/README.md @@ -68,7 +68,6 @@ For the first layer of the wrapper, built using Cargo: - CXX doesn't provide integration between Rust's `Option<_>` and C++'s `std::optional`, so sentinel values are used to communicate the absence of a value in the wrapper's API: - For `Option<&str>`, `None` is represented using an empty string, which is how it's already done for many metadata object fields in the C++ implementation of libloot. - For `Option`, `None` is represented using `NaN`, since the C++ implementation of libloot's public API already says that `NaN` values get converted to `None`. - - Plugin CRCs (which are `Option` in Rust) are represented as `int64_t`, with `-1` representing `None`, since all `uint32_t` values are possibly valid CRCs. - All Rust errors are converted to `::rust::Error` exceptions that have a `what()` string that is the concatenation of the error's display string and all its recursive source error display strings. For some errors the `what()` string also includes some data that is parsed by the wrapper's second layer to differentiate certain error types. For the ABI-compatible second layer of the wrapper, built using CMake: From 5cb4c9f16562eddd24cebea1c73e2d1d2ea99837 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 8 May 2025 14:37:19 +0100 Subject: [PATCH 136/206] Refactor regex operations --- src/metadata/message.rs | 46 +++++++++++++++++++++------------ src/metadata/plugin_metadata.rs | 18 ++++++++++--- src/plugin/mod.rs | 24 ++++++++++------- 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 68913d1a..19a27df2 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -319,21 +319,7 @@ impl TryFromYaml for Message { .expect("hardcoded Boost placeholder regex should be valid") }); - let result = BOOST_REGEX.replace_all(&mc.text, |captures: &Captures| { - match captures[1].parse::() { - Ok(i) if i > 0 => format!("{{{}}}", i - 1), - Ok(_) => { - logging::warn!("Found zero-indexed placeholder using Boost syntax in string \"{}\"", mc.text); - captures[0].to_string() - }, - Err(e) => { - logging::error!("Unexpected failure to parse Boost placeholder index \"{}\": {}", &captures[1], e); - captures[0].to_string() - } - } - }); - - if let Cow::Owned(text) = result { + if let Cow::Owned(text) = replace_all(&BOOST_REGEX, &mc.text) { mc.text = text.into_boxed_str(); } } @@ -354,10 +340,10 @@ impl TryFromYaml for Message { mc.text = mc.text.replace(&placeholder, sub).into_boxed_str(); } - if let Ok(Some(m)) = FMT_REGEX.find(&mc.text) { + if let Some(m) = find_match(&FMT_REGEX, &mc.text) { return Err(ParseMetadataError::new( value.span.start, - MetadataParsingErrorReason::MissingSubstitution(m.as_str().to_owned()), + MetadataParsingErrorReason::MissingSubstitution(m.to_owned()), )); } } @@ -373,6 +359,32 @@ impl TryFromYaml for Message { } } +fn replace_all<'a>(regex: &Regex, text: &'a str) -> Cow<'a, str> { + regex.replace_all(text, |captures: &Captures| { + match captures[1].parse::() { + Ok(i) if i > 0 => format!("{{{}}}", i - 1), + Ok(_) => { + logging::warn!( + "Found zero-indexed placeholder using Boost syntax in string \"{text}\"" + ); + captures[0].to_string() + } + Err(e) => { + logging::error!( + "Unexpected failure to parse Boost placeholder index \"{}\": {}", + &captures[1], + e + ); + captures[0].to_string() + } + } + }) +} + +fn find_match<'a>(regex: &Regex, text: &'a str) -> Option<&'a str> { + regex.find(text).ok().flatten().map(|m| m.as_str()) +} + impl EmitYaml for MessageContent { fn emit_yaml(&self, emitter: &mut YamlEmitter) { emitter.begin_map(); diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index 98a4f201..9803785e 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -274,9 +274,7 @@ impl PluginName { fn matches(&self, other_name: &str) -> bool { if let Some(regex) = &self.regex { - regex.is_match(other_name).inspect_err(|e| { - logging::error!("Encountered an error while trying to match the regex {} to the string {}: {}", regex.as_str(), other_name, e); - }).unwrap_or(false) + is_regex_match(regex, other_name) } else { unicase::eq(self.string.as_ref(), other_name) } @@ -382,6 +380,20 @@ fn replace_capturing_groups(regex_string: &str) -> Cow<'_, str> { } } +fn is_regex_match(regex: &Regex, string: &str) -> bool { + regex + .is_match(string) + .inspect_err(|e| { + logging::error!( + "Encountered an error while trying to match the regex {} to the string {}: {}", + regex.as_str(), + string, + e + ); + }) + .unwrap_or(false) +} + impl TryFromYaml for PluginMetadata { fn try_from_yaml(value: &MarkedYaml) -> Result { let mapping = as_mapping(value, YamlObjectType::PluginMetadata)?; diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index f02402fc..d36c2dca 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -434,15 +434,7 @@ fn extract_version(description: &str) -> Result, Box Result, Box Result, Box> { + let captured_text = regex + .captures(text)? + .iter() + .flat_map(|captures| captures.iter()) + .flatten() + .skip(1) // Skip the first capture as that's the whole regex. + .map(|m| m.as_str().trim()) + .find(|v| !v.is_empty()) + .map(str::to_owned); + + Ok(captured_text) +} + #[cfg(test)] mod tests { use super::*; From 299782705a04e032421f3cb4fe3d86881a6a15eb Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 8 May 2025 16:00:46 +0100 Subject: [PATCH 137/206] Replace C++ internals tests They didn't add any value. The new tests are taken from C++ libloot and test things that are still relevant for the wrapper. --- cpp/src/tests/api/internals/main.cpp | 132 +++++++++++++++++---------- 1 file changed, 86 insertions(+), 46 deletions(-) diff --git a/cpp/src/tests/api/internals/main.cpp b/cpp/src/tests/api/internals/main.cpp index d070639d..ed295dc9 100644 --- a/cpp/src/tests/api/internals/main.cpp +++ b/cpp/src/tests/api/internals/main.cpp @@ -1,59 +1,99 @@ +/* LOOT + A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and + Fallout: New Vegas. + + Copyright (C) 2014-2016 WrinklyNinja + + This file is part of LOOT. + + LOOT is free software: you can redistribute + it and/or modify it under the terms of the GNU General Public License + as published by the Free Software Foundation, either version 3 of + the License, or (at your option) any later version. + + LOOT is distributed in the hope that it will + be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with LOOT. If not, see + . + */ #include -#include "libloot-cpp/src/lib.rs.h" -#include "rust/cxx.h" +#include -namespace rust { -template -bool operator==(const Vec& lhs, const Vec& rhs) { - if (lhs.size() != rhs.size()) { - return false; - } - return std::equal(lhs.begin(), lhs.end(), rhs.begin()); -} +#ifdef _WIN32 +TEST(Filesystem, + pathStringConstructorDoesNotConvertCharacterEncodingFromUtf8ToNative) { + std::string utf8 = u8"Andr\u00E9_settings.toml"; + std::u16string utf16 = u"Andr\u00E9_settings.toml"; + + ASSERT_EQ('\xc3', utf8[4]); + ASSERT_EQ('\xa9', utf8[5]); + + std::filesystem::path path(utf8); + + EXPECT_EQ(utf8, path.string()); + EXPECT_NE(utf8, path.u8string()); + EXPECT_NE(utf16, path.u16string()); } -namespace loot::rust { -TEST(libloot_version, shouldReturnExpectedValue) { - auto version = libloot_version(); +TEST( + Filesystem, + pathStringAndLocaleConstructorDoesNotConvertCharacterEncodingFromUtf8WithClassicLocale) { + std::string utf8 = u8"Andr\u00E9_settings.toml"; + std::u16string utf16 = u"Andr\u00E9_settings.toml"; - EXPECT_EQ(version, "0.26.1"); + ASSERT_EQ('\xc3', utf8[4]); + ASSERT_EQ('\xa9', utf8[5]); + + std::filesystem::path path(utf8, std::locale::classic()); + + EXPECT_EQ(utf8, path.string()); + + EXPECT_NE(utf8, path.u8string()); + EXPECT_NE(utf16, path.u16string()); +} +#else +TEST(Filesystem, pathStringConstructorUsesNativeEncodingOfUtf8) { + std::string utf8 = u8"Andr\u00E9_settings.toml"; + std::u16string utf16 = u"Andr\u00E9_settings.toml"; + + ASSERT_EQ('\xc3', utf8[4]); + ASSERT_EQ('\xa9', utf8[5]); + + std::filesystem::path path(utf8); + + EXPECT_EQ(utf8, path.string()); + EXPECT_EQ(utf8, path.u8string()); + EXPECT_EQ(utf16, path.u16string()); +} +#endif + +TEST(Filesystem, u8pathConvertsCharacterEncodingFromUtf8ToNative) { + std::string utf8 = u8"Andr\u00E9_settings.toml"; + std::u16string utf16 = u"Andr\u00E9_settings.toml"; + + ASSERT_EQ('\xc3', utf8[4]); + ASSERT_EQ('\xa9', utf8[5]); + + std::filesystem::path path = std::filesystem::u8path(utf8); + +#ifdef _WIN32 + EXPECT_NE(utf8, path.string()); +#else + EXPECT_EQ(utf8, path.string()); +#endif + + EXPECT_EQ(utf8, path.u8string()); + EXPECT_EQ(utf16, path.u16string()); } -TEST(libloot_revision, shouldReturnExpectedValue) { - auto revision = libloot_revision(); - - EXPECT_EQ(revision, "unknown"); -} - -TEST(new_game, shouldThrowIfGivenNonsense) { - EXPECT_THROW(new_game(GameType::Fallout3, "foo"), ::rust::Error); -} - -TEST(Message, creation) { - auto content = new_message_content( - "a message", - ::rust::String(std::string(message_content_default_language()))); - - auto message = new_message(MessageType::Say, "message2", "invalid condition"); - - std::vector<::rust::Box> contents; - contents.push_back(std::move(content)); - auto multi_message = multilingual_message( - MessageType::Say, - ::rust::Slice>(contents), - "invalid condition"); - - EXPECT_EQ(multi_message->content()[0].text(), "a message"); - EXPECT_EQ(multi_message->content()[0].language(), "en"); - EXPECT_EQ(MessageType::Say, multi_message->message_type()); - EXPECT_EQ(multi_message->condition(), "invalid condition"); -} -} - -int main(int argc, char** argv) { +int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); } From 4fbc075c7125511c4bbabb69bfb9b7c0e9cda5d2 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 8 May 2025 16:01:08 +0100 Subject: [PATCH 138/206] Refactor conversion from Rust strings to C++ paths --- cpp/src/api/game.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cpp/src/api/game.cpp b/cpp/src/api/game.cpp index 34f05e58..fafac74d 100644 --- a/cpp/src/api/game.cpp +++ b/cpp/src/api/game.cpp @@ -67,6 +67,10 @@ loot::rust::GameType convert(loot::GameType gameType) { } } +std::filesystem::path to_path(const rust::String& string) { + return std::filesystem::u8path(string.begin(), string.end()); +} + rust::Box constructGame( const loot::GameType gameType, const std::filesystem::path& gamePath, @@ -115,9 +119,8 @@ DatabaseInterface& Game::GetDatabase() { return database_; } std::vector Game::GetAdditionalDataPaths() const { try { std::vector paths; - for (const auto& path_str : game_->additional_data_paths()) { - paths.push_back( - std::filesystem::u8path(path_str.begin(), path_str.end())); + for (const auto& path_string : game_->additional_data_paths()) { + paths.push_back(to_path(path_string)); } return paths; @@ -225,8 +228,7 @@ bool Game::IsLoadOrderAmbiguous() const { std::filesystem::path Game::GetActivePluginsFilePath() const { try { - const auto path_string = game_->active_plugins_file_path(); - return std::filesystem::u8path(path_string.begin(), path_string.end()); + return to_path(game_->active_plugins_file_path()); } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); } From 0947b23f2b40ca1a58026ec5420bd33965c1a275 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 8 May 2025 16:02:36 +0100 Subject: [PATCH 139/206] Fix missing CTest discovery of internals tests --- cpp/cmake/tests.cmake | 1 + 1 file changed, 1 insertion(+) diff --git a/cpp/cmake/tests.cmake b/cpp/cmake/tests.cmake index fbd1b515..c3c14776 100644 --- a/cpp/cmake/tests.cmake +++ b/cpp/cmake/tests.cmake @@ -106,6 +106,7 @@ add_dependencies(libloot_tests loot) target_link_libraries(libloot_tests PRIVATE loot GTest::gtest_main) enable_testing() +gtest_discover_tests(libloot_internals_tests DISCOVERY_TIMEOUT 10) gtest_discover_tests(libloot_tests DISCOVERY_TIMEOUT 10) ############################## From 2fced8fb890a12fc12c3299bb516118e20866d8f Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 8 May 2025 16:18:58 +0100 Subject: [PATCH 140/206] Sign CI archives --- .github/workflows/ci.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63b19461..25e98b0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,9 +185,20 @@ jobs: echo "filename=libloot-${VERSION}-${PLATFORM}.${EXTENSION}" >> $GITHUB_OUTPUT + - name: Import GPG key + run: echo -n "${{ secrets.GPG_SIGNING_KEY }}" | gpg --import + if: github.event_name == 'push' + + - name: Sign archive + working-directory: cpp/build/package + run: gpg --output "${{ steps.archive-name.outputs.filename }}.sig" --detach-sig "${{ steps.archive-name.outputs.filename }}" + if: github.event_name == 'push' + - name: Upload archive uses: actions/upload-artifact@v4 with: name: ${{ steps.archive-name.outputs.filename }} - path: cpp/build/package/${{ steps.archive-name.outputs.filename }} + path: | + cpp/build/package/${{ steps.archive-name.outputs.filename }} + cpp/build/package/${{ steps.archive-name.outputs.filename }}.sig if: github.event_name == 'push' From a4ff2ba4bba1b9e2b047996da325049181488364 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 8 May 2025 16:23:06 +0100 Subject: [PATCH 141/206] Fix some indentation in tests.cmake --- cpp/cmake/tests.cmake | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cpp/cmake/tests.cmake b/cpp/cmake/tests.cmake index c3c14776..e0f196b4 100644 --- a/cpp/cmake/tests.cmake +++ b/cpp/cmake/tests.cmake @@ -114,18 +114,18 @@ gtest_discover_tests(libloot_tests DISCOVERY_TIMEOUT 10) ############################## target_include_directories(libloot_internals_tests PRIVATE -${LIBLOOT_INCLUDE_DIRS}) + ${LIBLOOT_INCLUDE_DIRS}) target_include_directories(libloot_internals_tests SYSTEM PRIVATE -${LIBLOOT_COMMON_SYSTEM_INCLUDE_DIRS}) + ${LIBLOOT_COMMON_SYSTEM_INCLUDE_DIRS}) target_include_directories(libloot_tests PRIVATE ${LIBLOOT_INCLUDE_DIRS}) target_include_directories(libloot_tests SYSTEM PRIVATE ${LIBLOOT_COMMON_SYSTEM_INCLUDE_DIRS}) if(CMAKE_SYSTEM_NAME STREQUAL "Windows") -target_compile_definitions(libloot_internals_tests PRIVATE - UNICODE _UNICODE LOOT_STATIC) + target_compile_definitions(libloot_internals_tests PRIVATE + UNICODE _UNICODE LOOT_STATIC) target_compile_definitions(libloot_tests PRIVATE UNICODE _UNICODE) if(NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") From 7b870488c4d8b2dc2b89d265e0b7536b3d064060 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Thu, 8 May 2025 16:32:33 +0100 Subject: [PATCH 142/206] Copy docs from C++ libloot --- .github/workflows/ci.yml | 20 + README.md | 4 +- cpp/CMakeLists.txt | 9 +- cpp/README.md | 21 +- cpp/docs/api/Doxyfile | 2481 +++++++++++++++++ cpp/docs/api/changelog.rst | 2198 +++++++++++++++ cpp/docs/api/credits.rst | 16 + cpp/docs/api/introduction.rst | 16 + cpp/docs/api/miscellaneous.rst | 93 + cpp/docs/api/reference.rst | 115 + cpp/docs/api/sorting.rst | 231 ++ cpp/docs/conf.py | 356 +++ cpp/docs/index.rst | 30 + .../licenses/Boost Software License v1.0.txt | 23 + cpp/docs/licenses/GNU FDL v1.3.txt | 423 +++ cpp/docs/licenses/GNU GPL v3.txt | 624 +++++ cpp/docs/licenses/MIT License (spdlog).txt | 21 + cpp/docs/licenses/MIT License (yaml-cpp).txt | 19 + cpp/docs/licenses/notice.rst | 17 + cpp/docs/licenses/texts.rst | 44 + cpp/docs/metadata/changelog.rst | 253 ++ cpp/docs/metadata/conditions.rst | 298 ++ .../metadata/data_structures/cleaning.rst | 63 + cpp/docs/metadata/data_structures/file.rst | 59 + cpp/docs/metadata/data_structures/group.rst | 76 + cpp/docs/metadata/data_structures/index.rst | 17 + .../data_structures/localised_content.rst | 41 + .../metadata/data_structures/location.rst | 38 + cpp/docs/metadata/data_structures/message.rst | 117 + cpp/docs/metadata/data_structures/plugin.rst | 154 + cpp/docs/metadata/data_structures/tag.rst | 38 + cpp/docs/metadata/file_structure.rst | 75 + cpp/docs/metadata/introduction.rst | 23 + cpp/docs/requirements.txt | 3 + 34 files changed, 8007 insertions(+), 9 deletions(-) create mode 100644 cpp/docs/api/Doxyfile create mode 100644 cpp/docs/api/changelog.rst create mode 100644 cpp/docs/api/credits.rst create mode 100644 cpp/docs/api/introduction.rst create mode 100644 cpp/docs/api/miscellaneous.rst create mode 100644 cpp/docs/api/reference.rst create mode 100644 cpp/docs/api/sorting.rst create mode 100644 cpp/docs/conf.py create mode 100644 cpp/docs/index.rst create mode 100644 cpp/docs/licenses/Boost Software License v1.0.txt create mode 100644 cpp/docs/licenses/GNU FDL v1.3.txt create mode 100644 cpp/docs/licenses/GNU GPL v3.txt create mode 100644 cpp/docs/licenses/MIT License (spdlog).txt create mode 100644 cpp/docs/licenses/MIT License (yaml-cpp).txt create mode 100644 cpp/docs/licenses/notice.rst create mode 100644 cpp/docs/licenses/texts.rst create mode 100644 cpp/docs/metadata/changelog.rst create mode 100644 cpp/docs/metadata/conditions.rst create mode 100644 cpp/docs/metadata/data_structures/cleaning.rst create mode 100644 cpp/docs/metadata/data_structures/file.rst create mode 100644 cpp/docs/metadata/data_structures/group.rst create mode 100644 cpp/docs/metadata/data_structures/index.rst create mode 100644 cpp/docs/metadata/data_structures/localised_content.rst create mode 100644 cpp/docs/metadata/data_structures/location.rst create mode 100644 cpp/docs/metadata/data_structures/message.rst create mode 100644 cpp/docs/metadata/data_structures/plugin.rst create mode 100644 cpp/docs/metadata/data_structures/tag.rst create mode 100644 cpp/docs/metadata/file_structure.rst create mode 100644 cpp/docs/metadata/introduction.rst create mode 100644 cpp/docs/requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25e98b0c..b8a0d4b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,6 +159,26 @@ jobs: working-directory: cpp run: ctest --test-dir build --build-config ${{ env.CMAKE_CONFIG }} --output-on-failure --parallel + - name: Install packages for building docs (Linux) + working-directory: cpp + run: | + sudo apt-get update + sudo apt-get install -y --no-upgrade doxygen + pip3 install --user -r docs/requirements.txt + if: runner.os == 'Linux' + + - name: Install packages for building docs (Windows) + working-directory: cpp + run: | + nuget install doxygen -Version 1.8.14 + echo "${{ github.workspace }}\Doxygen.1.8.14\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + python -m pip install -r docs/requirements.txt + if: runner.os == 'Windows' + + - name: Build docs + working-directory: cpp + run: sphinx-build -b html docs build/docs/html + - name: Package the C++ wrapper working-directory: cpp/build run: cpack -C ${{ env.CMAKE_CONFIG }} diff --git a/README.md b/README.md index e18da3c9..11bf5da7 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,10 @@ cargo test ### API documentation -The public API has doc comments copied from libloot, and the API documentation can be built and viewed using: +The API documentation can be built and viewed using: ``` cargo doc --open ``` + +The C++ wrapper also has more general documentation. diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 14db90ee..6d7d6d7e 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -275,11 +275,10 @@ endif() install(DIRECTORY "${CMAKE_SOURCE_DIR}/include/" DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) -# TODO: Copy across the docs into this repository? -# if(LIBLOOT_INSTALL_DOCS) -# install(DIRECTORY "${CMAKE_BINARY_DIR}/docs/html/" -# DESTINATION ${CMAKE_INSTALL_DOCDIR}) -# endif() +if(LIBLOOT_INSTALL_DOCS) + install(DIRECTORY "${CMAKE_BINARY_DIR}/docs/html/" + DESTINATION ${CMAKE_INSTALL_DOCDIR}) +endif() install(EXPORT liblootTargets FILE liblootTargets.cmake diff --git a/cpp/README.md b/cpp/README.md index c493dc61..7c72c16c 100644 --- a/cpp/README.md +++ b/cpp/README.md @@ -42,7 +42,7 @@ cmake -B build . -DCMAKE_BUILD_TYPE=Debug cmake --build build --parallel ``` -### Tests & Packaging +### Tests The build process also builds a copy of the public API tests from C++ libloot v0.26.1 by default. To skip building the tests, pass `-DLIBLOOT_BUILD_TESTS=OFF` when first running CMake. @@ -52,14 +52,29 @@ If built, the tests can be run using: ctest --test-dir build --output-on-failure --parallel -V ``` +### Documentation + +The documentation is built using [Doxygen](http://www.stack.nl/~dimitri/doxygen/), [Breathe](https://breathe.readthedocs.io/en/latest/) and [Sphinx](http://www.sphinx-doc.org/en/stable/). Install Doxygen and Python and make sure they're accessible from your `PATH`, then run: + +``` +py -m venv .venv +.venv\Scripts\activate +pip install -r docs/requirements.txt +sphinx-build -b html docs build/docs/html +``` + +If running on Linux, replace `.venv\Scripts\activate` with `.venv/bin/activate`. + +Alternatively, you can use Docker to avoid changing your development environment, by running `docker run -it --rm -v ${PWD}/docs:/docs/docs -v ${PWD}/build:/docs/build -v ${PWD}/include:/docs/include sphinxdoc/sphinx:7.3.7 bash` to obtain a shell that you can use to run `apt-get update && apt-get install -y doxygen` and then the two commands above. + +### Packaging + To package the build: ``` cpack --config build/CPackConfig.cmake -C RelWithDebInfo ``` -This repository (and so the created package) doesn't currently include any of libloot's documentation, besides the API documentation included in the Rust source code. - ## Usage notes For the first layer of the wrapper, built using Cargo: diff --git a/cpp/docs/api/Doxyfile b/cpp/docs/api/Doxyfile new file mode 100644 index 00000000..93b4f714 --- /dev/null +++ b/cpp/docs/api/Doxyfile @@ -0,0 +1,2481 @@ +# Doxyfile 1.8.11 + +# This file describes the settings to be used by the documentation system +# doxygen (www.doxygen.org) for a project. +# +# All text after a double hash (##) is considered a comment and is placed in +# front of the TAG it is preceding. +# +# All text after a single hash (#) is considered a comment and will be ignored. +# The format is: +# TAG = value [value, ...] +# For lists, items can also be appended using: +# TAG += value [value, ...] +# Values that contain spaces should be placed between quotes (\" \"). + +#--------------------------------------------------------------------------- +# Project related configuration options +#--------------------------------------------------------------------------- + +# This tag specifies the encoding used for all characters in the config file +# that follow. The default is UTF-8 which is also the encoding used for all text +# before the first occurrence of this tag. Doxygen uses libiconv (or the iconv +# built into libc) for the transcoding. See http://www.gnu.org/software/libiconv +# for the list of possible encodings. +# The default value is: UTF-8. + +DOXYFILE_ENCODING = UTF-8 + +# The PROJECT_NAME tag is a single word (or a sequence of words surrounded by +# double-quotes, unless you are using Doxywizard) that should identify the +# project for which the documentation is generated. This name is used in the +# title of most generated pages and in a few other places. +# The default value is: My Project. + +PROJECT_NAME = "libloot" + +# The PROJECT_NUMBER tag can be used to enter a project or revision number. This +# could be handy for archiving the generated documentation or if some version +# control system is used. + +PROJECT_NUMBER = + +# Using the PROJECT_BRIEF tag one can provide an optional one line description +# for a project that appears at the top of each page and should give viewer a +# quick idea about the purpose of the project. Keep the description short. + +PROJECT_BRIEF = "A load order optimisation tool for various games by Bethesda." + +# With the PROJECT_LOGO tag one can specify a logo or an icon that is included +# in the documentation. The maximum height of the logo should not exceed 55 +# pixels and the maximum width should not exceed 200 pixels. Doxygen will copy +# the logo to the output directory. + +PROJECT_LOGO = + +# The OUTPUT_DIRECTORY tag is used to specify the (relative or absolute) path +# into which the generated documentation will be written. If a relative path is +# entered, it will be relative to the location where doxygen was started. If +# left blank the current directory will be used. + +OUTPUT_DIRECTORY = build/docs + +# If the CREATE_SUBDIRS tag is set to YES then doxygen will create 4096 sub- +# directories (in 2 levels) under the output directory of each output format and +# will distribute the generated files over these directories. Enabling this +# option can be useful when feeding doxygen a huge amount of source files, where +# putting all generated files in the same directory would otherwise causes +# performance problems for the file system. +# The default value is: NO. + +CREATE_SUBDIRS = NO + +# If the ALLOW_UNICODE_NAMES tag is set to YES, doxygen will allow non-ASCII +# characters to appear in the names of generated files. If set to NO, non-ASCII +# characters will be escaped, for example _xE3_x81_x84 will be used for Unicode +# U+3044. +# The default value is: NO. + +ALLOW_UNICODE_NAMES = NO + +# The OUTPUT_LANGUAGE tag is used to specify the language in which all +# documentation generated by doxygen is written. Doxygen will use this +# information to generate all constant output in the proper language. +# Possible values are: Afrikaans, Arabic, Armenian, Brazilian, Catalan, Chinese, +# Chinese-Traditional, Croatian, Czech, Danish, Dutch, English (United States), +# Esperanto, Farsi (Persian), Finnish, French, German, Greek, Hungarian, +# Indonesian, Italian, Japanese, Japanese-en (Japanese with English messages), +# Korean, Korean-en (Korean with English messages), Latvian, Lithuanian, +# Macedonian, Norwegian, Persian (Farsi), Polish, Portuguese, Romanian, Russian, +# Serbian, Serbian-Cyrillic, Slovak, Slovene, Spanish, Swedish, Turkish, +# Ukrainian and Vietnamese. +# The default value is: English. + +OUTPUT_LANGUAGE = English + +# If the BRIEF_MEMBER_DESC tag is set to YES, doxygen will include brief member +# descriptions after the members that are listed in the file and class +# documentation (similar to Javadoc). Set to NO to disable this. +# The default value is: YES. + +BRIEF_MEMBER_DESC = YES + +# If the REPEAT_BRIEF tag is set to YES, doxygen will prepend the brief +# description of a member or function before the detailed description +# +# Note: If both HIDE_UNDOC_MEMBERS and BRIEF_MEMBER_DESC are set to NO, the +# brief descriptions will be completely suppressed. +# The default value is: YES. + +REPEAT_BRIEF = YES + +# This tag implements a quasi-intelligent brief description abbreviator that is +# used to form the text in various listings. Each string in this list, if found +# as the leading text of the brief description, will be stripped from the text +# and the result, after processing the whole list, is used as the annotated +# text. Otherwise, the brief description is used as-is. If left blank, the +# following values are used ($name is automatically replaced with the name of +# the entity):The $name class, The $name widget, The $name file, is, provides, +# specifies, contains, represents, a, an and the. + +ABBREVIATE_BRIEF = "The $name class" \ + "The $name widget" \ + "The $name file" \ + is \ + provides \ + specifies \ + contains \ + represents \ + a \ + an \ + the + +# If the ALWAYS_DETAILED_SEC and REPEAT_BRIEF tags are both set to YES then +# doxygen will generate a detailed section even if there is only a brief +# description. +# The default value is: NO. + +ALWAYS_DETAILED_SEC = NO + +# If the INLINE_INHERITED_MEMB tag is set to YES, doxygen will show all +# inherited members of a class in the documentation of that class as if those +# members were ordinary class members. Constructors, destructors and assignment +# operators of the base classes will not be shown. +# The default value is: NO. + +INLINE_INHERITED_MEMB = NO + +# If the FULL_PATH_NAMES tag is set to YES, doxygen will prepend the full path +# before files name in the file list and in the header files. If set to NO the +# shortest path that makes the file name unique will be used +# The default value is: YES. + +FULL_PATH_NAMES = NO + +# The STRIP_FROM_PATH tag can be used to strip a user-defined part of the path. +# Stripping is only done if one of the specified strings matches the left-hand +# part of the path. The tag can be used to show relative paths in the file list. +# If left blank the directory from which doxygen is run is used as the path to +# strip. +# +# Note that you can specify absolute paths here, but also relative paths, which +# will be relative from the directory where doxygen is started. +# This tag requires that the tag FULL_PATH_NAMES is set to YES. + +STRIP_FROM_PATH = + +# The STRIP_FROM_INC_PATH tag can be used to strip a user-defined part of the +# path mentioned in the documentation of a class, which tells the reader which +# header file to include in order to use a class. If left blank only the name of +# the header file containing the class definition is used. Otherwise one should +# specify the list of include paths that are normally passed to the compiler +# using the -I flag. + +STRIP_FROM_INC_PATH = + +# If the SHORT_NAMES tag is set to YES, doxygen will generate much shorter (but +# less readable) file names. This can be useful is your file systems doesn't +# support long names like on DOS, Mac, or CD-ROM. +# The default value is: NO. + +SHORT_NAMES = NO + +# If the JAVADOC_AUTOBRIEF tag is set to YES then doxygen will interpret the +# first line (until the first dot) of a Javadoc-style comment as the brief +# description. If set to NO, the Javadoc-style will behave just like regular Qt- +# style comments (thus requiring an explicit @brief command for a brief +# description.) +# The default value is: NO. + +JAVADOC_AUTOBRIEF = NO + +# If the QT_AUTOBRIEF tag is set to YES then doxygen will interpret the first +# line (until the first dot) of a Qt-style comment as the brief description. If +# set to NO, the Qt-style will behave just like regular Qt-style comments (thus +# requiring an explicit \brief command for a brief description.) +# The default value is: NO. + +QT_AUTOBRIEF = NO + +# The MULTILINE_CPP_IS_BRIEF tag can be set to YES to make doxygen treat a +# multi-line C++ special comment block (i.e. a block of //! or /// comments) as +# a brief description. This used to be the default behavior. The new default is +# to treat a multi-line C++ comment block as a detailed description. Set this +# tag to YES if you prefer the old behavior instead. +# +# Note that setting this tag to YES also means that rational rose comments are +# not recognized any more. +# The default value is: NO. + +MULTILINE_CPP_IS_BRIEF = NO + +# If the INHERIT_DOCS tag is set to YES then an undocumented member inherits the +# documentation from any documented member that it re-implements. +# The default value is: YES. + +INHERIT_DOCS = YES + +# If the SEPARATE_MEMBER_PAGES tag is set to YES then doxygen will produce a new +# page for each member. If set to NO, the documentation of a member will be part +# of the file/class/namespace that contains it. +# The default value is: NO. + +SEPARATE_MEMBER_PAGES = NO + +# The TAB_SIZE tag can be used to set the number of spaces in a tab. Doxygen +# uses this value to replace tabs by spaces in code fragments. +# Minimum value: 1, maximum value: 16, default value: 4. + +TAB_SIZE = 4 + +# This tag can be used to specify a number of aliases that act as commands in +# the documentation. An alias has the form: +# name=value +# For example adding +# "sideeffect=@par Side Effects:\n" +# will allow you to put the command \sideeffect (or @sideeffect) in the +# documentation, which will result in a user-defined paragraph with heading +# "Side Effects:". You can put \n's in the value part of an alias to insert +# newlines. + +ALIASES = + +# This tag can be used to specify a number of word-keyword mappings (TCL only). +# A mapping has the form "name=value". For example adding "class=itcl::class" +# will allow you to use the command class in the itcl::class meaning. + +TCL_SUBST = + +# Set the OPTIMIZE_OUTPUT_FOR_C tag to YES if your project consists of C sources +# only. Doxygen will then generate output that is more tailored for C. For +# instance, some of the names that are used will be different. The list of all +# members will be omitted, etc. +# The default value is: NO. + +OPTIMIZE_OUTPUT_FOR_C = NO + +# Set the OPTIMIZE_OUTPUT_JAVA tag to YES if your project consists of Java or +# Python sources only. Doxygen will then generate output that is more tailored +# for that language. For instance, namespaces will be presented as packages, +# qualified scopes will look different, etc. +# The default value is: NO. + +OPTIMIZE_OUTPUT_JAVA = NO + +# Set the OPTIMIZE_FOR_FORTRAN tag to YES if your project consists of Fortran +# sources. Doxygen will then generate output that is tailored for Fortran. +# The default value is: NO. + +OPTIMIZE_FOR_FORTRAN = NO + +# Set the OPTIMIZE_OUTPUT_VHDL tag to YES if your project consists of VHDL +# sources. Doxygen will then generate output that is tailored for VHDL. +# The default value is: NO. + +OPTIMIZE_OUTPUT_VHDL = NO + +# Doxygen selects the parser to use depending on the extension of the files it +# parses. With this tag you can assign which parser to use for a given +# extension. Doxygen has a built-in mapping, but you can override or extend it +# using this tag. The format is ext=language, where ext is a file extension, and +# language is one of the parsers supported by doxygen: IDL, Java, Javascript, +# C#, C, C++, D, PHP, Objective-C, Python, Fortran (fixed format Fortran: +# FortranFixed, free formatted Fortran: FortranFree, unknown formatted Fortran: +# Fortran. In the later case the parser tries to guess whether the code is fixed +# or free formatted code, this is the default for Fortran type files), VHDL. For +# instance to make doxygen treat .inc files as Fortran files (default is PHP), +# and .f files as C (default is Fortran), use: inc=Fortran f=C. +# +# Note: For files without extension you can use no_extension as a placeholder. +# +# Note that for custom extensions you also need to set FILE_PATTERNS otherwise +# the files are not read by doxygen. + +EXTENSION_MAPPING = + +# If the MARKDOWN_SUPPORT tag is enabled then doxygen pre-processes all comments +# according to the Markdown format, which allows for more readable +# documentation. See http://daringfireball.net/projects/markdown/ for details. +# The output of markdown processing is further processed by doxygen, so you can +# mix doxygen, HTML, and XML commands with Markdown formatting. Disable only in +# case of backward compatibilities issues. +# The default value is: YES. + +MARKDOWN_SUPPORT = YES + +# When enabled doxygen tries to link words that correspond to documented +# classes, or namespaces to their corresponding documentation. Such a link can +# be prevented in individual cases by putting a % sign in front of the word or +# globally by setting AUTOLINK_SUPPORT to NO. +# The default value is: YES. + +AUTOLINK_SUPPORT = YES + +# If you use STL classes (i.e. std::string, std::vector, etc.) but do not want +# to include (a tag file for) the STL sources as input, then you should set this +# tag to YES in order to let doxygen match functions declarations and +# definitions whose arguments contain STL classes (e.g. func(std::string); +# versus func(std::string) {}). This also make the inheritance and collaboration +# diagrams that involve STL classes more complete and accurate. +# The default value is: NO. + +BUILTIN_STL_SUPPORT = YES + +# If you use Microsoft's C++/CLI language, you should set this option to YES to +# enable parsing support. +# The default value is: NO. + +CPP_CLI_SUPPORT = NO + +# Set the SIP_SUPPORT tag to YES if your project consists of sip (see: +# http://www.riverbankcomputing.co.uk/software/sip/intro) sources only. Doxygen +# will parse them like normal C++ but will assume all classes use public instead +# of private inheritance when no explicit protection keyword is present. +# The default value is: NO. + +SIP_SUPPORT = NO + +# For Microsoft's IDL there are propget and propput attributes to indicate +# getter and setter methods for a property. Setting this option to YES will make +# doxygen to replace the get and set methods by a property in the documentation. +# This will only work if the methods are indeed getting or setting a simple +# type. If this is not the case, or you want to show the methods anyway, you +# should set this option to NO. +# The default value is: YES. + +IDL_PROPERTY_SUPPORT = YES + +# If member grouping is used in the documentation and the DISTRIBUTE_GROUP_DOC +# tag is set to YES then doxygen will reuse the documentation of the first +# member in the group (if any) for the other members of the group. By default +# all members of a group must be documented explicitly. +# The default value is: NO. + +DISTRIBUTE_GROUP_DOC = NO + +# If one adds a struct or class to a group and this option is enabled, then also +# any nested class or struct is added to the same group. By default this option +# is disabled and one has to add nested compounds explicitly via \ingroup. +# The default value is: NO. + +GROUP_NESTED_COMPOUNDS = NO + +# Set the SUBGROUPING tag to YES to allow class member groups of the same type +# (for instance a group of public functions) to be put as a subgroup of that +# type (e.g. under the Public Functions section). Set it to NO to prevent +# subgrouping. Alternatively, this can be done per class using the +# \nosubgrouping command. +# The default value is: YES. + +SUBGROUPING = YES + +# When the INLINE_GROUPED_CLASSES tag is set to YES, classes, structs and unions +# are shown inside the group in which they are included (e.g. using \ingroup) +# instead of on a separate page (for HTML and Man pages) or section (for LaTeX +# and RTF). +# +# Note that this feature does not work in combination with +# SEPARATE_MEMBER_PAGES. +# The default value is: NO. + +INLINE_GROUPED_CLASSES = NO + +# When the INLINE_SIMPLE_STRUCTS tag is set to YES, structs, classes, and unions +# with only public data fields or simple typedef fields will be shown inline in +# the documentation of the scope in which they are defined (i.e. file, +# namespace, or group documentation), provided this scope is documented. If set +# to NO, structs, classes, and unions are shown on a separate page (for HTML and +# Man pages) or section (for LaTeX and RTF). +# The default value is: NO. + +INLINE_SIMPLE_STRUCTS = NO + +# When TYPEDEF_HIDES_STRUCT tag is enabled, a typedef of a struct, union, or +# enum is documented as struct, union, or enum with the name of the typedef. So +# typedef struct TypeS {} TypeT, will appear in the documentation as a struct +# with name TypeT. When disabled the typedef will appear as a member of a file, +# namespace, or class. And the struct will be named TypeS. This can typically be +# useful for C code in case the coding convention dictates that all compound +# types are typedef'ed and only the typedef is referenced, never the tag name. +# The default value is: NO. + +TYPEDEF_HIDES_STRUCT = NO + +# The size of the symbol lookup cache can be set using LOOKUP_CACHE_SIZE. This +# cache is used to resolve symbols given their name and scope. Since this can be +# an expensive process and often the same symbol appears multiple times in the +# code, doxygen keeps a cache of pre-resolved symbols. If the cache is too small +# doxygen will become slower. If the cache is too large, memory is wasted. The +# cache size is given by this formula: 2^(16+LOOKUP_CACHE_SIZE). The valid range +# is 0..9, the default is 0, corresponding to a cache size of 2^16=65536 +# symbols. At the end of a run doxygen will report the cache usage and suggest +# the optimal cache size from a speed point of view. +# Minimum value: 0, maximum value: 9, default value: 0. + +LOOKUP_CACHE_SIZE = 0 + +#--------------------------------------------------------------------------- +# Build related configuration options +#--------------------------------------------------------------------------- + +# If the EXTRACT_ALL tag is set to YES, doxygen will assume all entities in +# documentation are documented, even if no documentation was available. Private +# class members and static file members will be hidden unless the +# EXTRACT_PRIVATE respectively EXTRACT_STATIC tags are set to YES. +# Note: This will also disable the warnings about undocumented members that are +# normally produced when WARNINGS is set to YES. +# The default value is: NO. + +EXTRACT_ALL = NO + +# If the EXTRACT_PRIVATE tag is set to YES, all private members of a class will +# be included in the documentation. +# The default value is: NO. + +EXTRACT_PRIVATE = NO + +# If the EXTRACT_PACKAGE tag is set to YES, all members with package or internal +# scope will be included in the documentation. +# The default value is: NO. + +EXTRACT_PACKAGE = NO + +# If the EXTRACT_STATIC tag is set to YES, all static members of a file will be +# included in the documentation. +# The default value is: NO. + +EXTRACT_STATIC = NO + +# If the EXTRACT_LOCAL_CLASSES tag is set to YES, classes (and structs) defined +# locally in source files will be included in the documentation. If set to NO, +# only classes defined in header files are included. Does not have any effect +# for Java sources. +# The default value is: YES. + +EXTRACT_LOCAL_CLASSES = YES + +# This flag is only useful for Objective-C code. If set to YES, local methods, +# which are defined in the implementation section but not in the interface are +# included in the documentation. If set to NO, only methods in the interface are +# included. +# The default value is: NO. + +EXTRACT_LOCAL_METHODS = NO + +# If this flag is set to YES, the members of anonymous namespaces will be +# extracted and appear in the documentation as a namespace called +# 'anonymous_namespace{file}', where file will be replaced with the base name of +# the file that contains the anonymous namespace. By default anonymous namespace +# are hidden. +# The default value is: NO. + +EXTRACT_ANON_NSPACES = NO + +# If the HIDE_UNDOC_MEMBERS tag is set to YES, doxygen will hide all +# undocumented members inside documented classes or files. If set to NO these +# members will be included in the various overviews, but no documentation +# section is generated. This option has no effect if EXTRACT_ALL is enabled. +# The default value is: NO. + +HIDE_UNDOC_MEMBERS = NO + +# If the HIDE_UNDOC_CLASSES tag is set to YES, doxygen will hide all +# undocumented classes that are normally visible in the class hierarchy. If set +# to NO, these classes will be included in the various overviews. This option +# has no effect if EXTRACT_ALL is enabled. +# The default value is: NO. + +HIDE_UNDOC_CLASSES = NO + +# If the HIDE_FRIEND_COMPOUNDS tag is set to YES, doxygen will hide all friend +# (class|struct|union) declarations. If set to NO, these declarations will be +# included in the documentation. +# The default value is: NO. + +HIDE_FRIEND_COMPOUNDS = NO + +# If the HIDE_IN_BODY_DOCS tag is set to YES, doxygen will hide any +# documentation blocks found inside the body of a function. If set to NO, these +# blocks will be appended to the function's detailed documentation block. +# The default value is: NO. + +HIDE_IN_BODY_DOCS = NO + +# The INTERNAL_DOCS tag determines if documentation that is typed after a +# \internal command is included. If the tag is set to NO then the documentation +# will be excluded. Set it to YES to include the internal documentation. +# The default value is: NO. + +INTERNAL_DOCS = NO + +# If the CASE_SENSE_NAMES tag is set to NO then doxygen will only generate file +# names in lower-case letters. If set to YES, upper-case letters are also +# allowed. This is useful if you have classes or files whose names only differ +# in case and if your file system supports case sensitive file names. Windows +# and Mac users are advised to set this option to NO. +# The default value is: system dependent. + +CASE_SENSE_NAMES = YES + +# If the HIDE_SCOPE_NAMES tag is set to NO then doxygen will show members with +# their full class and namespace scopes in the documentation. If set to YES, the +# scope will be hidden. +# The default value is: NO. + +HIDE_SCOPE_NAMES = YES + +# If the HIDE_COMPOUND_REFERENCE tag is set to NO (default) then doxygen will +# append additional text to a page's title, such as Class Reference. If set to +# YES the compound reference will be hidden. +# The default value is: NO. + +HIDE_COMPOUND_REFERENCE= NO + +# If the SHOW_INCLUDE_FILES tag is set to YES then doxygen will put a list of +# the files that are included by a file in the documentation of that file. +# The default value is: YES. + +SHOW_INCLUDE_FILES = YES + +# If the SHOW_GROUPED_MEMB_INC tag is set to YES then Doxygen will add for each +# grouped member an include statement to the documentation, telling the reader +# which file to include in order to use the member. +# The default value is: NO. + +SHOW_GROUPED_MEMB_INC = NO + +# If the FORCE_LOCAL_INCLUDES tag is set to YES then doxygen will list include +# files with double quotes in the documentation rather than with sharp brackets. +# The default value is: NO. + +FORCE_LOCAL_INCLUDES = NO + +# If the INLINE_INFO tag is set to YES then a tag [inline] is inserted in the +# documentation for inline members. +# The default value is: YES. + +INLINE_INFO = YES + +# If the SORT_MEMBER_DOCS tag is set to YES then doxygen will sort the +# (detailed) documentation of file and class members alphabetically by member +# name. If set to NO, the members will appear in declaration order. +# The default value is: YES. + +SORT_MEMBER_DOCS = YES + +# If the SORT_BRIEF_DOCS tag is set to YES then doxygen will sort the brief +# descriptions of file, namespace and class members alphabetically by member +# name. If set to NO, the members will appear in declaration order. Note that +# this will also influence the order of the classes in the class list. +# The default value is: NO. + +SORT_BRIEF_DOCS = NO + +# If the SORT_MEMBERS_CTORS_1ST tag is set to YES then doxygen will sort the +# (brief and detailed) documentation of class members so that constructors and +# destructors are listed first. If set to NO the constructors will appear in the +# respective orders defined by SORT_BRIEF_DOCS and SORT_MEMBER_DOCS. +# Note: If SORT_BRIEF_DOCS is set to NO this option is ignored for sorting brief +# member documentation. +# Note: If SORT_MEMBER_DOCS is set to NO this option is ignored for sorting +# detailed member documentation. +# The default value is: NO. + +SORT_MEMBERS_CTORS_1ST = NO + +# If the SORT_GROUP_NAMES tag is set to YES then doxygen will sort the hierarchy +# of group names into alphabetical order. If set to NO the group names will +# appear in their defined order. +# The default value is: NO. + +SORT_GROUP_NAMES = NO + +# If the SORT_BY_SCOPE_NAME tag is set to YES, the class list will be sorted by +# fully-qualified names, including namespaces. If set to NO, the class list will +# be sorted only by class name, not including the namespace part. +# Note: This option is not very useful if HIDE_SCOPE_NAMES is set to YES. +# Note: This option applies only to the class list, not to the alphabetical +# list. +# The default value is: NO. + +SORT_BY_SCOPE_NAME = NO + +# If the STRICT_PROTO_MATCHING option is enabled and doxygen fails to do proper +# type resolution of all parameters of a function it will reject a match between +# the prototype and the implementation of a member function even if there is +# only one candidate or it is obvious which candidate to choose by doing a +# simple string match. By disabling STRICT_PROTO_MATCHING doxygen will still +# accept a match between prototype and implementation in such cases. +# The default value is: NO. + +STRICT_PROTO_MATCHING = NO + +# The GENERATE_TODOLIST tag can be used to enable (YES) or disable (NO) the todo +# list. This list is created by putting \todo commands in the documentation. +# The default value is: YES. + +GENERATE_TODOLIST = YES + +# The GENERATE_TESTLIST tag can be used to enable (YES) or disable (NO) the test +# list. This list is created by putting \test commands in the documentation. +# The default value is: YES. + +GENERATE_TESTLIST = YES + +# The GENERATE_BUGLIST tag can be used to enable (YES) or disable (NO) the bug +# list. This list is created by putting \bug commands in the documentation. +# The default value is: YES. + +GENERATE_BUGLIST = YES + +# The GENERATE_DEPRECATEDLIST tag can be used to enable (YES) or disable (NO) +# the deprecated list. This list is created by putting \deprecated commands in +# the documentation. +# The default value is: YES. + +GENERATE_DEPRECATEDLIST= YES + +# The ENABLED_SECTIONS tag can be used to enable conditional documentation +# sections, marked by \if ... \endif and \cond +# ... \endcond blocks. + +ENABLED_SECTIONS = + +# The MAX_INITIALIZER_LINES tag determines the maximum number of lines that the +# initial value of a variable or macro / define can have for it to appear in the +# documentation. If the initializer consists of more lines than specified here +# it will be hidden. Use a value of 0 to hide initializers completely. The +# appearance of the value of individual variables and macros / defines can be +# controlled using \showinitializer or \hideinitializer command in the +# documentation regardless of this setting. +# Minimum value: 0, maximum value: 10000, default value: 30. + +MAX_INITIALIZER_LINES = 30 + +# Set the SHOW_USED_FILES tag to NO to disable the list of files generated at +# the bottom of the documentation of classes and structs. If set to YES, the +# list will mention the files that were used to generate the documentation. +# The default value is: YES. + +SHOW_USED_FILES = YES + +# Set the SHOW_FILES tag to NO to disable the generation of the Files page. This +# will remove the Files entry from the Quick Index and from the Folder Tree View +# (if specified). +# The default value is: YES. + +SHOW_FILES = YES + +# Set the SHOW_NAMESPACES tag to NO to disable the generation of the Namespaces +# page. This will remove the Namespaces entry from the Quick Index and from the +# Folder Tree View (if specified). +# The default value is: YES. + +SHOW_NAMESPACES = YES + +# The FILE_VERSION_FILTER tag can be used to specify a program or script that +# doxygen should invoke to get the current version for each file (typically from +# the version control system). Doxygen will invoke the program by executing (via +# popen()) the command command input-file, where command is the value of the +# FILE_VERSION_FILTER tag, and input-file is the name of an input file provided +# by doxygen. Whatever the program writes to standard output is used as the file +# version. For an example see the documentation. + +FILE_VERSION_FILTER = + +# The LAYOUT_FILE tag can be used to specify a layout file which will be parsed +# by doxygen. The layout file controls the global structure of the generated +# output files in an output format independent way. To create the layout file +# that represents doxygen's defaults, run doxygen with the -l option. You can +# optionally specify a file name after the option, if omitted DoxygenLayout.xml +# will be used as the name of the layout file. +# +# Note that if you run doxygen from a directory containing a file called +# DoxygenLayout.xml, doxygen will parse it automatically even if the LAYOUT_FILE +# tag is left empty. + +LAYOUT_FILE = + +# The CITE_BIB_FILES tag can be used to specify one or more bib files containing +# the reference definitions. This must be a list of .bib files. The .bib +# extension is automatically appended if omitted. This requires the bibtex tool +# to be installed. See also http://en.wikipedia.org/wiki/BibTeX for more info. +# For LaTeX the style of the bibliography can be controlled using +# LATEX_BIB_STYLE. To use this feature you need bibtex and perl available in the +# search path. See also \cite for info how to create references. + +CITE_BIB_FILES = + +#--------------------------------------------------------------------------- +# Configuration options related to warning and progress messages +#--------------------------------------------------------------------------- + +# The QUIET tag can be used to turn on/off the messages that are generated to +# standard output by doxygen. If QUIET is set to YES this implies that the +# messages are off. +# The default value is: NO. + +QUIET = NO + +# The WARNINGS tag can be used to turn on/off the warning messages that are +# generated to standard error (stderr) by doxygen. If WARNINGS is set to YES +# this implies that the warnings are on. +# +# Tip: Turn warnings on while writing the documentation. +# The default value is: YES. + +WARNINGS = YES + +# If the WARN_IF_UNDOCUMENTED tag is set to YES then doxygen will generate +# warnings for undocumented members. If EXTRACT_ALL is set to YES then this flag +# will automatically be disabled. +# The default value is: YES. + +WARN_IF_UNDOCUMENTED = YES + +# If the WARN_IF_DOC_ERROR tag is set to YES, doxygen will generate warnings for +# potential errors in the documentation, such as not documenting some parameters +# in a documented function, or documenting parameters that don't exist or using +# markup commands wrongly. +# The default value is: YES. + +WARN_IF_DOC_ERROR = YES + +# This WARN_NO_PARAMDOC option can be enabled to get warnings for functions that +# are documented, but have no documentation for their parameters or return +# value. If set to NO, doxygen will only warn about wrong or incomplete +# parameter documentation, but not about the absence of documentation. +# The default value is: NO. + +WARN_NO_PARAMDOC = NO + +# If the WARN_AS_ERROR tag is set to YES then doxygen will immediately stop when +# a warning is encountered. +# The default value is: NO. + +WARN_AS_ERROR = NO + +# The WARN_FORMAT tag determines the format of the warning messages that doxygen +# can produce. The string should contain the $file, $line, and $text tags, which +# will be replaced by the file and line number from which the warning originated +# and the warning text. Optionally the format may contain $version, which will +# be replaced by the version of the file (if it could be obtained via +# FILE_VERSION_FILTER) +# The default value is: $file:$line: $text. + +WARN_FORMAT = "$file:$line: $text" + +# The WARN_LOGFILE tag can be used to specify a file to which warning and error +# messages should be written. If left blank the output is written to standard +# error (stderr). + +WARN_LOGFILE = + +#--------------------------------------------------------------------------- +# Configuration options related to the input files +#--------------------------------------------------------------------------- + +# The INPUT tag is used to specify the files and/or directories that contain +# documented source files. You may enter file names like myfile.cpp or +# directories like /usr/src/myproject. Separate the files or directories with +# spaces. See also FILE_PATTERNS and EXTENSION_MAPPING +# Note: If this tag is empty the current directory is searched. + +INPUT = include/loot \ + include/loot/enum \ + include/loot/exception \ + include/loot/metadata \ + include/loot/struct + +# This tag can be used to specify the character encoding of the source files +# that doxygen parses. Internally doxygen uses the UTF-8 encoding. Doxygen uses +# libiconv (or the iconv built into libc) for the transcoding. See the libiconv +# documentation (see: http://www.gnu.org/software/libiconv) for the list of +# possible encodings. +# The default value is: UTF-8. + +INPUT_ENCODING = UTF-8 + +# If the value of the INPUT tag contains directories, you can use the +# FILE_PATTERNS tag to specify one or more wildcard patterns (like *.cpp and +# *.h) to filter out the source-files in the directories. +# +# Note that for custom extensions or not directly supported extensions you also +# need to set EXTENSION_MAPPING for the extension otherwise the files are not +# read by doxygen. +# +# If left blank the following patterns are tested:*.c, *.cc, *.cxx, *.cpp, +# *.c++, *.java, *.ii, *.ixx, *.ipp, *.i++, *.inl, *.idl, *.ddl, *.odl, *.h, +# *.hh, *.hxx, *.hpp, *.h++, *.cs, *.d, *.php, *.php4, *.php5, *.phtml, *.inc, +# *.m, *.markdown, *.md, *.mm, *.dox, *.py, *.pyw, *.f90, *.f, *.for, *.tcl, +# *.vhd, *.vhdl, *.ucf, *.qsf, *.as and *.js. + +FILE_PATTERNS = *.c \ + *.cc \ + *.cxx \ + *.cpp \ + *.c++ \ + *.java \ + *.ii \ + *.ixx \ + *.ipp \ + *.i++ \ + *.inl \ + *.idl \ + *.ddl \ + *.odl \ + *.h \ + *.hh \ + *.hxx \ + *.hpp \ + *.h++ \ + *.cs \ + *.d \ + *.php \ + *.php4 \ + *.php5 \ + *.phtml \ + *.inc \ + *.m \ + *.markdown \ + *.md \ + *.mm \ + *.dox \ + *.py \ + *.pyw \ + *.f90 \ + *.f \ + *.for \ + *.tcl \ + *.vhd \ + *.vhdl \ + *.ucf \ + *.qsf \ + *.as \ + *.js + +# The RECURSIVE tag can be used to specify whether or not subdirectories should +# be searched for input files as well. +# The default value is: NO. + +RECURSIVE = NO + +# The EXCLUDE tag can be used to specify files and/or directories that should be +# excluded from the INPUT source files. This way you can easily exclude a +# subdirectory from a directory tree whose root is specified with the INPUT tag. +# +# Note that relative paths are relative to the directory from which doxygen is +# run. + +EXCLUDE = + +# The EXCLUDE_SYMLINKS tag can be used to select whether or not files or +# directories that are symbolic links (a Unix file system feature) are excluded +# from the input. +# The default value is: NO. + +EXCLUDE_SYMLINKS = NO + +# If the value of the INPUT tag contains directories, you can use the +# EXCLUDE_PATTERNS tag to specify one or more wildcard patterns to exclude +# certain files from those directories. +# +# Note that the wildcards are matched against the file with absolute path, so to +# exclude all test directories for example use the pattern */test/* + +EXCLUDE_PATTERNS = + +# The EXCLUDE_SYMBOLS tag can be used to specify one or more symbol names +# (namespaces, classes, functions, etc.) that should be excluded from the +# output. The symbol name can be a fully qualified name, a word, or if the +# wildcard * is used, a substring. Examples: ANamespace, AClass, +# AClass::ANamespace, ANamespace::*Test +# +# Note that the wildcards are matched against the file with absolute path, so to +# exclude all test directories use the pattern */test/* + +EXCLUDE_SYMBOLS = + +# The EXAMPLE_PATH tag can be used to specify one or more files or directories +# that contain example code fragments that are included (see the \include +# command). + +EXAMPLE_PATH = + +# If the value of the EXAMPLE_PATH tag contains directories, you can use the +# EXAMPLE_PATTERNS tag to specify one or more wildcard pattern (like *.cpp and +# *.h) to filter out the source-files in the directories. If left blank all +# files are included. + +EXAMPLE_PATTERNS = * + +# If the EXAMPLE_RECURSIVE tag is set to YES then subdirectories will be +# searched for input files to be used with the \include or \dontinclude commands +# irrespective of the value of the RECURSIVE tag. +# The default value is: NO. + +EXAMPLE_RECURSIVE = NO + +# The IMAGE_PATH tag can be used to specify one or more files or directories +# that contain images that are to be included in the documentation (see the +# \image command). + +IMAGE_PATH = + +# The INPUT_FILTER tag can be used to specify a program that doxygen should +# invoke to filter for each input file. Doxygen will invoke the filter program +# by executing (via popen()) the command: +# +# +# +# where is the value of the INPUT_FILTER tag, and is the +# name of an input file. Doxygen will then use the output that the filter +# program writes to standard output. If FILTER_PATTERNS is specified, this tag +# will be ignored. +# +# Note that the filter must not add or remove lines; it is applied before the +# code is scanned, but not when the output code is generated. If lines are added +# or removed, the anchors will not be placed correctly. +# +# Note that for custom extensions or not directly supported extensions you also +# need to set EXTENSION_MAPPING for the extension otherwise the files are not +# properly processed by doxygen. + +INPUT_FILTER = + +# The FILTER_PATTERNS tag can be used to specify filters on a per file pattern +# basis. Doxygen will compare the file name with each pattern and apply the +# filter if there is a match. The filters are a list of the form: pattern=filter +# (like *.cpp=my_cpp_filter). See INPUT_FILTER for further information on how +# filters are used. If the FILTER_PATTERNS tag is empty or if none of the +# patterns match the file name, INPUT_FILTER is applied. +# +# Note that for custom extensions or not directly supported extensions you also +# need to set EXTENSION_MAPPING for the extension otherwise the files are not +# properly processed by doxygen. + +FILTER_PATTERNS = + +# If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using +# INPUT_FILTER) will also be used to filter the input files that are used for +# producing the source files to browse (i.e. when SOURCE_BROWSER is set to YES). +# The default value is: NO. + +FILTER_SOURCE_FILES = NO + +# The FILTER_SOURCE_PATTERNS tag can be used to specify source filters per file +# pattern. A pattern will override the setting for FILTER_PATTERN (if any) and +# it is also possible to disable source filtering for a specific pattern using +# *.ext= (so without naming a filter). +# This tag requires that the tag FILTER_SOURCE_FILES is set to YES. + +FILTER_SOURCE_PATTERNS = + +# If the USE_MDFILE_AS_MAINPAGE tag refers to the name of a markdown file that +# is part of the input, its contents will be placed on the main page +# (index.html). This can be useful if you have a project on for instance GitHub +# and want to reuse the introduction page also for the doxygen output. + +USE_MDFILE_AS_MAINPAGE = mainpage.md + +#--------------------------------------------------------------------------- +# Configuration options related to source browsing +#--------------------------------------------------------------------------- + +# If the SOURCE_BROWSER tag is set to YES then a list of source files will be +# generated. Documented entities will be cross-referenced with these sources. +# +# Note: To get rid of all source code in the generated output, make sure that +# also VERBATIM_HEADERS is set to NO. +# The default value is: NO. + +SOURCE_BROWSER = NO + +# Setting the INLINE_SOURCES tag to YES will include the body of functions, +# classes and enums directly into the documentation. +# The default value is: NO. + +INLINE_SOURCES = NO + +# Setting the STRIP_CODE_COMMENTS tag to YES will instruct doxygen to hide any +# special comment blocks from generated source code fragments. Normal C, C++ and +# Fortran comments will always remain visible. +# The default value is: YES. + +STRIP_CODE_COMMENTS = YES + +# If the REFERENCED_BY_RELATION tag is set to YES then for each documented +# function all documented functions referencing it will be listed. +# The default value is: NO. + +REFERENCED_BY_RELATION = NO + +# If the REFERENCES_RELATION tag is set to YES then for each documented function +# all documented entities called/used by that function will be listed. +# The default value is: NO. + +REFERENCES_RELATION = NO + +# If the REFERENCES_LINK_SOURCE tag is set to YES and SOURCE_BROWSER tag is set +# to YES then the hyperlinks from functions in REFERENCES_RELATION and +# REFERENCED_BY_RELATION lists will link to the source code. Otherwise they will +# link to the documentation. +# The default value is: YES. + +REFERENCES_LINK_SOURCE = YES + +# If SOURCE_TOOLTIPS is enabled (the default) then hovering a hyperlink in the +# source code will show a tooltip with additional information such as prototype, +# brief description and links to the definition and documentation. Since this +# will make the HTML file larger and loading of large files a bit slower, you +# can opt to disable this feature. +# The default value is: YES. +# This tag requires that the tag SOURCE_BROWSER is set to YES. + +SOURCE_TOOLTIPS = YES + +# If the USE_HTAGS tag is set to YES then the references to source code will +# point to the HTML generated by the htags(1) tool instead of doxygen built-in +# source browser. The htags tool is part of GNU's global source tagging system +# (see http://www.gnu.org/software/global/global.html). You will need version +# 4.8.6 or higher. +# +# To use it do the following: +# - Install the latest version of global +# - Enable SOURCE_BROWSER and USE_HTAGS in the config file +# - Make sure the INPUT points to the root of the source tree +# - Run doxygen as normal +# +# Doxygen will invoke htags (and that will in turn invoke gtags), so these +# tools must be available from the command line (i.e. in the search path). +# +# The result: instead of the source browser generated by doxygen, the links to +# source code will now point to the output of htags. +# The default value is: NO. +# This tag requires that the tag SOURCE_BROWSER is set to YES. + +USE_HTAGS = NO + +# If the VERBATIM_HEADERS tag is set the YES then doxygen will generate a +# verbatim copy of the header file for each class for which an include is +# specified. Set to NO to disable this. +# See also: Section \class. +# The default value is: YES. + +VERBATIM_HEADERS = YES + +# If the CLANG_ASSISTED_PARSING tag is set to YES then doxygen will use the +# clang parser (see: http://clang.llvm.org/) for more accurate parsing at the +# cost of reduced performance. This can be particularly helpful with template +# rich C++ code for which doxygen's built-in parser lacks the necessary type +# information. +# Note: The availability of this option depends on whether or not doxygen was +# generated with the -Duse-libclang=ON option for CMake. +# The default value is: NO. + +CLANG_ASSISTED_PARSING = NO + +# If clang assisted parsing is enabled you can provide the compiler with command +# line options that you would normally use when invoking the compiler. Note that +# the include paths will already be set by doxygen for the files and directories +# specified with INPUT and INCLUDE_PATH. +# This tag requires that the tag CLANG_ASSISTED_PARSING is set to YES. + +CLANG_OPTIONS = + +#--------------------------------------------------------------------------- +# Configuration options related to the alphabetical class index +#--------------------------------------------------------------------------- + +# If the ALPHABETICAL_INDEX tag is set to YES, an alphabetical index of all +# compounds will be generated. Enable this if the project contains a lot of +# classes, structs, unions or interfaces. +# The default value is: YES. + +ALPHABETICAL_INDEX = YES + +# The COLS_IN_ALPHA_INDEX tag can be used to specify the number of columns in +# which the alphabetical index list will be split. +# Minimum value: 1, maximum value: 20, default value: 5. +# This tag requires that the tag ALPHABETICAL_INDEX is set to YES. + +COLS_IN_ALPHA_INDEX = 5 + +# In case all classes in a project start with a common prefix, all classes will +# be put under the same header in the alphabetical index. The IGNORE_PREFIX tag +# can be used to specify a prefix (or a list of prefixes) that should be ignored +# while generating the index headers. +# This tag requires that the tag ALPHABETICAL_INDEX is set to YES. + +IGNORE_PREFIX = + +#--------------------------------------------------------------------------- +# Configuration options related to the HTML output +#--------------------------------------------------------------------------- + +# If the GENERATE_HTML tag is set to YES, doxygen will generate HTML output +# The default value is: YES. + +GENERATE_HTML = NO + +# The HTML_OUTPUT tag is used to specify where the HTML docs will be put. If a +# relative path is entered the value of OUTPUT_DIRECTORY will be put in front of +# it. +# The default directory is: html. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_OUTPUT = html + +# The HTML_FILE_EXTENSION tag can be used to specify the file extension for each +# generated HTML page (for example: .htm, .php, .asp). +# The default value is: .html. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_FILE_EXTENSION = .html + +# The HTML_HEADER tag can be used to specify a user-defined HTML header file for +# each generated HTML page. If the tag is left blank doxygen will generate a +# standard header. +# +# To get valid HTML the header file that includes any scripts and style sheets +# that doxygen needs, which is dependent on the configuration options used (e.g. +# the setting GENERATE_TREEVIEW). It is highly recommended to start with a +# default header using +# doxygen -w html new_header.html new_footer.html new_stylesheet.css +# YourConfigFile +# and then modify the file new_header.html. See also section "Doxygen usage" +# for information on how to generate the default header that doxygen normally +# uses. +# Note: The header is subject to change so you typically have to regenerate the +# default header when upgrading to a newer version of doxygen. For a description +# of the possible markers and block names see the documentation. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_HEADER = + +# The HTML_FOOTER tag can be used to specify a user-defined HTML footer for each +# generated HTML page. If the tag is left blank doxygen will generate a standard +# footer. See HTML_HEADER for more information on how to generate a default +# footer and what special commands can be used inside the footer. See also +# section "Doxygen usage" for information on how to generate the default footer +# that doxygen normally uses. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_FOOTER = + +# The HTML_STYLESHEET tag can be used to specify a user-defined cascading style +# sheet that is used by each HTML page. It can be used to fine-tune the look of +# the HTML output. If left blank doxygen will generate a default style sheet. +# See also section "Doxygen usage" for information on how to generate the style +# sheet that doxygen normally uses. +# Note: It is recommended to use HTML_EXTRA_STYLESHEET instead of this tag, as +# it is more robust and this tag (HTML_STYLESHEET) will in the future become +# obsolete. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_STYLESHEET = + +# The HTML_EXTRA_STYLESHEET tag can be used to specify additional user-defined +# cascading style sheets that are included after the standard style sheets +# created by doxygen. Using this option one can overrule certain style aspects. +# This is preferred over using HTML_STYLESHEET since it does not replace the +# standard style sheet and is therefore more robust against future updates. +# Doxygen will copy the style sheet files to the output directory. +# Note: The order of the extra style sheet files is of importance (e.g. the last +# style sheet in the list overrules the setting of the previous ones in the +# list). For an example see the documentation. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_EXTRA_STYLESHEET = + +# The HTML_EXTRA_FILES tag can be used to specify one or more extra images or +# other source files which should be copied to the HTML output directory. Note +# that these files will be copied to the base HTML output directory. Use the +# $relpath^ marker in the HTML_HEADER and/or HTML_FOOTER files to load these +# files. In the HTML_STYLESHEET file, use the file name only. Also note that the +# files will be copied as-is; there are no commands or markers available. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_EXTRA_FILES = + +# The HTML_COLORSTYLE_HUE tag controls the color of the HTML output. Doxygen +# will adjust the colors in the style sheet and background images according to +# this color. Hue is specified as an angle on a colorwheel, see +# http://en.wikipedia.org/wiki/Hue for more information. For instance the value +# 0 represents red, 60 is yellow, 120 is green, 180 is cyan, 240 is blue, 300 +# purple, and 360 is red again. +# Minimum value: 0, maximum value: 359, default value: 220. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COLORSTYLE_HUE = 220 + +# The HTML_COLORSTYLE_SAT tag controls the purity (or saturation) of the colors +# in the HTML output. For a value of 0 the output will use grayscales only. A +# value of 255 will produce the most vivid colors. +# Minimum value: 0, maximum value: 255, default value: 100. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COLORSTYLE_SAT = 100 + +# The HTML_COLORSTYLE_GAMMA tag controls the gamma correction applied to the +# luminance component of the colors in the HTML output. Values below 100 +# gradually make the output lighter, whereas values above 100 make the output +# darker. The value divided by 100 is the actual gamma applied, so 80 represents +# a gamma of 0.8, The value 220 represents a gamma of 2.2, and 100 does not +# change the gamma. +# Minimum value: 40, maximum value: 240, default value: 80. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COLORSTYLE_GAMMA = 80 + +# If the HTML_TIMESTAMP tag is set to YES then the footer of each generated HTML +# page will contain the date and time when the page was generated. Setting this +# to YES can help to show when doxygen was last run and thus if the +# documentation is up to date. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_TIMESTAMP = NO + +# If the HTML_DYNAMIC_SECTIONS tag is set to YES then the generated HTML +# documentation will contain sections that can be hidden and shown after the +# page has loaded. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_DYNAMIC_SECTIONS = NO + +# With HTML_INDEX_NUM_ENTRIES one can control the preferred number of entries +# shown in the various tree structured indices initially; the user can expand +# and collapse entries dynamically later on. Doxygen will expand the tree to +# such a level that at most the specified number of entries are visible (unless +# a fully collapsed tree already exceeds this amount). So setting the number of +# entries 1 will produce a full collapsed tree by default. 0 is a special value +# representing an infinite number of entries and will result in a full expanded +# tree by default. +# Minimum value: 0, maximum value: 9999, default value: 100. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_INDEX_NUM_ENTRIES = 100 + +# If the GENERATE_DOCSET tag is set to YES, additional index files will be +# generated that can be used as input for Apple's Xcode 3 integrated development +# environment (see: http://developer.apple.com/tools/xcode/), introduced with +# OSX 10.5 (Leopard). To create a documentation set, doxygen will generate a +# Makefile in the HTML output directory. Running make will produce the docset in +# that directory and running make install will install the docset in +# ~/Library/Developer/Shared/Documentation/DocSets so that Xcode will find it at +# startup. See http://developer.apple.com/tools/creatingdocsetswithdoxygen.html +# for more information. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_DOCSET = NO + +# This tag determines the name of the docset feed. A documentation feed provides +# an umbrella under which multiple documentation sets from a single provider +# (such as a company or product suite) can be grouped. +# The default value is: Doxygen generated docs. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_FEEDNAME = "Doxygen generated docs" + +# This tag specifies a string that should uniquely identify the documentation +# set bundle. This should be a reverse domain-name style string, e.g. +# com.mycompany.MyDocSet. Doxygen will append .docset to the name. +# The default value is: org.doxygen.Project. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_BUNDLE_ID = org.doxygen.Project + +# The DOCSET_PUBLISHER_ID tag specifies a string that should uniquely identify +# the documentation publisher. This should be a reverse domain-name style +# string, e.g. com.mycompany.MyDocSet.documentation. +# The default value is: org.doxygen.Publisher. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_PUBLISHER_ID = org.doxygen.Publisher + +# The DOCSET_PUBLISHER_NAME tag identifies the documentation publisher. +# The default value is: Publisher. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_PUBLISHER_NAME = Publisher + +# If the GENERATE_HTMLHELP tag is set to YES then doxygen generates three +# additional HTML index files: index.hhp, index.hhc, and index.hhk. The +# index.hhp is a project file that can be read by Microsoft's HTML Help Workshop +# (see: http://www.microsoft.com/en-us/download/details.aspx?id=21138) on +# Windows. +# +# The HTML Help Workshop contains a compiler that can convert all HTML output +# generated by doxygen into a single compiled HTML file (.chm). Compiled HTML +# files are now used as the Windows 98 help format, and will replace the old +# Windows help format (.hlp) on all Windows platforms in the future. Compressed +# HTML files also contain an index, a table of contents, and you can search for +# words in the documentation. The HTML workshop also contains a viewer for +# compressed HTML files. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_HTMLHELP = NO + +# The CHM_FILE tag can be used to specify the file name of the resulting .chm +# file. You can add a path in front of the file if the result should not be +# written to the html output directory. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +CHM_FILE = + +# The HHC_LOCATION tag can be used to specify the location (absolute path +# including file name) of the HTML help compiler (hhc.exe). If non-empty, +# doxygen will try to run the HTML help compiler on the generated index.hhp. +# The file has to be specified with full path. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +HHC_LOCATION = + +# The GENERATE_CHI flag controls if a separate .chi index file is generated +# (YES) or that it should be included in the master .chm file (NO). +# The default value is: NO. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +GENERATE_CHI = NO + +# The CHM_INDEX_ENCODING is used to encode HtmlHelp index (hhk), content (hhc) +# and project file content. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +CHM_INDEX_ENCODING = + +# The BINARY_TOC flag controls whether a binary table of contents is generated +# (YES) or a normal table of contents (NO) in the .chm file. Furthermore it +# enables the Previous and Next buttons. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +BINARY_TOC = NO + +# The TOC_EXPAND flag can be set to YES to add extra items for group members to +# the table of contents of the HTML help documentation and to the tree view. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +TOC_EXPAND = NO + +# If the GENERATE_QHP tag is set to YES and both QHP_NAMESPACE and +# QHP_VIRTUAL_FOLDER are set, an additional index file will be generated that +# can be used as input for Qt's qhelpgenerator to generate a Qt Compressed Help +# (.qch) of the generated HTML documentation. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_QHP = NO + +# If the QHG_LOCATION tag is specified, the QCH_FILE tag can be used to specify +# the file name of the resulting .qch file. The path specified is relative to +# the HTML output folder. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QCH_FILE = + +# The QHP_NAMESPACE tag specifies the namespace to use when generating Qt Help +# Project output. For more information please see Qt Help Project / Namespace +# (see: http://qt-project.org/doc/qt-4.8/qthelpproject.html#namespace). +# The default value is: org.doxygen.Project. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_NAMESPACE = org.doxygen.Project + +# The QHP_VIRTUAL_FOLDER tag specifies the namespace to use when generating Qt +# Help Project output. For more information please see Qt Help Project / Virtual +# Folders (see: http://qt-project.org/doc/qt-4.8/qthelpproject.html#virtual- +# folders). +# The default value is: doc. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_VIRTUAL_FOLDER = doc + +# If the QHP_CUST_FILTER_NAME tag is set, it specifies the name of a custom +# filter to add. For more information please see Qt Help Project / Custom +# Filters (see: http://qt-project.org/doc/qt-4.8/qthelpproject.html#custom- +# filters). +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_CUST_FILTER_NAME = + +# The QHP_CUST_FILTER_ATTRS tag specifies the list of the attributes of the +# custom filter to add. For more information please see Qt Help Project / Custom +# Filters (see: http://qt-project.org/doc/qt-4.8/qthelpproject.html#custom- +# filters). +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_CUST_FILTER_ATTRS = + +# The QHP_SECT_FILTER_ATTRS tag specifies the list of the attributes this +# project's filter section matches. Qt Help Project / Filter Attributes (see: +# http://qt-project.org/doc/qt-4.8/qthelpproject.html#filter-attributes). +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_SECT_FILTER_ATTRS = + +# The QHG_LOCATION tag can be used to specify the location of Qt's +# qhelpgenerator. If non-empty doxygen will try to run qhelpgenerator on the +# generated .qhp file. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHG_LOCATION = + +# If the GENERATE_ECLIPSEHELP tag is set to YES, additional index files will be +# generated, together with the HTML files, they form an Eclipse help plugin. To +# install this plugin and make it available under the help contents menu in +# Eclipse, the contents of the directory containing the HTML and XML files needs +# to be copied into the plugins directory of eclipse. The name of the directory +# within the plugins directory should be the same as the ECLIPSE_DOC_ID value. +# After copying Eclipse needs to be restarted before the help appears. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_ECLIPSEHELP = NO + +# A unique identifier for the Eclipse help plugin. When installing the plugin +# the directory name containing the HTML and XML files should also have this +# name. Each documentation set should have its own identifier. +# The default value is: org.doxygen.Project. +# This tag requires that the tag GENERATE_ECLIPSEHELP is set to YES. + +ECLIPSE_DOC_ID = org.doxygen.Project + +# If you want full control over the layout of the generated HTML pages it might +# be necessary to disable the index and replace it with your own. The +# DISABLE_INDEX tag can be used to turn on/off the condensed index (tabs) at top +# of each HTML page. A value of NO enables the index and the value YES disables +# it. Since the tabs in the index contain the same information as the navigation +# tree, you can set this option to YES if you also set GENERATE_TREEVIEW to YES. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +DISABLE_INDEX = NO + +# The GENERATE_TREEVIEW tag is used to specify whether a tree-like index +# structure should be generated to display hierarchical information. If the tag +# value is set to YES, a side panel will be generated containing a tree-like +# index structure (just like the one that is generated for HTML Help). For this +# to work a browser that supports JavaScript, DHTML, CSS and frames is required +# (i.e. any modern browser). Windows users are probably better off using the +# HTML help feature. Via custom style sheets (see HTML_EXTRA_STYLESHEET) one can +# further fine-tune the look of the index. As an example, the default style +# sheet generated by doxygen has an example that shows how to put an image at +# the root of the tree instead of the PROJECT_NAME. Since the tree basically has +# the same information as the tab index, you could consider setting +# DISABLE_INDEX to YES when enabling this option. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_TREEVIEW = NO + +# The ENUM_VALUES_PER_LINE tag can be used to set the number of enum values that +# doxygen will group on one line in the generated HTML documentation. +# +# Note that a value of 0 will completely suppress the enum values from appearing +# in the overview section. +# Minimum value: 0, maximum value: 20, default value: 4. +# This tag requires that the tag GENERATE_HTML is set to YES. + +ENUM_VALUES_PER_LINE = 4 + +# If the treeview is enabled (see GENERATE_TREEVIEW) then this tag can be used +# to set the initial width (in pixels) of the frame in which the tree is shown. +# Minimum value: 0, maximum value: 1500, default value: 250. +# This tag requires that the tag GENERATE_HTML is set to YES. + +TREEVIEW_WIDTH = 250 + +# If the EXT_LINKS_IN_WINDOW option is set to YES, doxygen will open links to +# external symbols imported via tag files in a separate window. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +EXT_LINKS_IN_WINDOW = NO + +# Use this tag to change the font size of LaTeX formulas included as images in +# the HTML documentation. When you change the font size after a successful +# doxygen run you need to manually remove any form_*.png images from the HTML +# output directory to force them to be regenerated. +# Minimum value: 8, maximum value: 50, default value: 10. +# This tag requires that the tag GENERATE_HTML is set to YES. + +FORMULA_FONTSIZE = 10 + +# Use the FORMULA_TRANPARENT tag to determine whether or not the images +# generated for formulas are transparent PNGs. Transparent PNGs are not +# supported properly for IE 6.0, but are supported on all modern browsers. +# +# Note that when changing this option you need to delete any form_*.png files in +# the HTML output directory before the changes have effect. +# The default value is: YES. +# This tag requires that the tag GENERATE_HTML is set to YES. + +FORMULA_TRANSPARENT = YES + +# Enable the USE_MATHJAX option to render LaTeX formulas using MathJax (see +# http://www.mathjax.org) which uses client side Javascript for the rendering +# instead of using pre-rendered bitmaps. Use this if you do not have LaTeX +# installed or if you want to formulas look prettier in the HTML output. When +# enabled you may also need to install MathJax separately and configure the path +# to it using the MATHJAX_RELPATH option. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +USE_MATHJAX = NO + +# When MathJax is enabled you can set the default output format to be used for +# the MathJax output. See the MathJax site (see: +# http://docs.mathjax.org/en/latest/output.html) for more details. +# Possible values are: HTML-CSS (which is slower, but has the best +# compatibility), NativeMML (i.e. MathML) and SVG. +# The default value is: HTML-CSS. +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_FORMAT = HTML-CSS + +# When MathJax is enabled you need to specify the location relative to the HTML +# output directory using the MATHJAX_RELPATH option. The destination directory +# should contain the MathJax.js script. For instance, if the mathjax directory +# is located at the same level as the HTML output directory, then +# MATHJAX_RELPATH should be ../mathjax. The default value points to the MathJax +# Content Delivery Network so you can quickly see the result without installing +# MathJax. However, it is strongly recommended to install a local copy of +# MathJax from http://www.mathjax.org before deployment. +# The default value is: http://cdn.mathjax.org/mathjax/latest. +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_RELPATH = http://cdn.mathjax.org/mathjax/latest + +# The MATHJAX_EXTENSIONS tag can be used to specify one or more MathJax +# extension names that should be enabled during MathJax rendering. For example +# MATHJAX_EXTENSIONS = TeX/AMSmath TeX/AMSsymbols +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_EXTENSIONS = + +# The MATHJAX_CODEFILE tag can be used to specify a file with javascript pieces +# of code that will be used on startup of the MathJax code. See the MathJax site +# (see: http://docs.mathjax.org/en/latest/output.html) for more details. For an +# example see the documentation. +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_CODEFILE = + +# When the SEARCHENGINE tag is enabled doxygen will generate a search box for +# the HTML output. The underlying search engine uses javascript and DHTML and +# should work on any modern browser. Note that when using HTML help +# (GENERATE_HTMLHELP), Qt help (GENERATE_QHP), or docsets (GENERATE_DOCSET) +# there is already a search function so this one should typically be disabled. +# For large projects the javascript based search engine can be slow, then +# enabling SERVER_BASED_SEARCH may provide a better solution. It is possible to +# search using the keyboard; to jump to the search box use + S +# (what the is depends on the OS and browser, but it is typically +# , /