From b8ab6d2faa479d8394d2c13195f398347f3d6460 Mon Sep 17 00:00:00 2001 From: Gleb Mazovetskiy Date: Mon, 4 Nov 2024 23:19:15 +0000 Subject: [PATCH] Migrate from SimpleIni to our own implementation Our implementation has a more modern interface and only supports the features that we care about. It always outputs `\n` as newlines and does not output BOM. The modern interface eliminates awkward `c_str()/data()` conversions. This implementation preserves comments and the file order of sections and keys. New keys are written in insertion order. We now also support modifying and adding default comments, which may be a useful thing to do for the especially tricky ini options (this PR doesn't add any but adds the ability to do so). Sadly, this increases the RG99 binary size by 24 KiB. I'm guessing this is because the map implementation generates quite a bit of code. Note that while it might seem that using `std::string` for every key and value would do a lot of allocations, most of these strings are small and thus benefit from Small String Optimization (= no allocations). --- .devcontainer/Dockerfile | 2 +- .github/workflows/Linux_aarch64.yml | 3 +- .github/workflows/Linux_x86.yml | 4 +- .github/workflows/Linux_x86_64.yml | 3 +- 3rdParty/simpleini/CMakeLists.txt | 12 - CMake/Dependencies.cmake | 16 - Packaging/nix/debian-cross-aarch64-prep.sh | 2 +- Source/CMakeLists.txt | 13 +- Source/options.cpp | 305 +++++---------- Source/options.h | 5 +- Source/utils/ini.cpp | 415 +++++++++++++++++++++ Source/utils/ini.hpp | 121 ++++++ Source/utils/string_or_view.hpp | 1 + Source/utils/utf8.cpp | 6 +- test/CMakeLists.txt | 2 + test/ini_test.cpp | 86 +++++ tools/make_src_dist.py | 4 +- vcpkg.json | 1 - 18 files changed, 749 insertions(+), 252 deletions(-) delete mode 100644 3rdParty/simpleini/CMakeLists.txt create mode 100644 Source/utils/ini.cpp create mode 100644 Source/utils/ini.hpp create mode 100644 test/ini_test.cpp diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 22616bc45..38cd816e8 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -7,7 +7,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install tar curl zip unzip bash-completion build-essential ripgrep htop \ ninja-build ccache g++ mold gdb clang-format clang-tidy \ rpm pkg-config cmake git smpq gettext libsdl2-dev libsdl2-image-dev libsodium-dev \ - libpng-dev libbz2-dev libfmt-dev libgtest-dev libgmock-dev libbenchmark-dev libsimpleini-dev zsh \ + libpng-dev libbz2-dev libfmt-dev libgtest-dev libgmock-dev libbenchmark-dev zsh \ qtbase5-dev qt6-base-dev ristretto \ && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* diff --git a/.github/workflows/Linux_aarch64.yml b/.github/workflows/Linux_aarch64.yml index 7c6bcb52f..7470f8bdc 100644 --- a/.github/workflows/Linux_aarch64.yml +++ b/.github/workflows/Linux_aarch64.yml @@ -74,12 +74,11 @@ jobs: CMAKE_BUILD_TYPE: ${{github.event_name == 'release' && 'Release' || 'RelWithDebInfo'}} # We set DEVILUTIONX_SYSTEM_LIBFMT=OFF because its soversion changes frequently. # We set DEVILUTIONX_SYSTEM_LIBSODIUM=OFF because its soversion changes frequently. - # We set DEVILUTIONX_SYSTEM_SIMPLEINI=OFF because we require v4.19+, still missing from many distributions. # We set DEVILUTIONX_SYSTEM_BZIP2=OFF because Fedora and Debian do not agree on how to link it. run: | cmake -S. -Bbuild -DCMAKE_TOOLCHAIN_FILE=../CMake/platforms/aarch64-linux-gnu-clang-static-libc++.toolchain.cmake \ -DCMAKE_BUILD_TYPE=${{env.CMAKE_BUILD_TYPE}} -DCMAKE_INSTALL_PREFIX=/usr -DCPACK=ON -DDEVILUTIONX_SYSTEM_LIBFMT=OFF \ - -DDEVILUTIONX_SYSTEM_LIBSODIUM=OFF -DDEVILUTIONX_SYSTEM_SIMPLEINI=OFF -DDEVILUTIONX_SYSTEM_BZIP2=OFF && \ + -DDEVILUTIONX_SYSTEM_LIBSODIUM=OFF -DDEVILUTIONX_SYSTEM_BZIP2=OFF && \ cmake --build build -j $(getconf _NPROCESSORS_ONLN) --target package - name: Package diff --git a/.github/workflows/Linux_x86.yml b/.github/workflows/Linux_x86.yml index 3802710ea..8a350cb50 100644 --- a/.github/workflows/Linux_x86.yml +++ b/.github/workflows/Linux_x86.yml @@ -65,14 +65,12 @@ jobs: CMAKE_BUILD_TYPE: ${{github.event_name == 'release' && 'Release' || 'RelWithDebInfo'}} # We set DEVILUTIONX_SYSTEM_LIBFMT=OFF because its soversion changes frequently. # We set DEVILUTIONX_SYSTEM_LIBSODIUM=OFF because its soversion changes frequently. - # We set DEVILUTIONX_SYSTEM_SIMPLEINI=OFF because we require v4.19+, still missing from many distributions, # We set DEVILUTIONX_SYSTEM_BZIP2=OFF because Fedora and Debian do not agree on how to link it. - # and there is no libsimpleini-dev:i386 for Ubuntu 20.04. run: | cmake -S. -Bbuild -DCMAKE_TOOLCHAIN_FILE=../CMake/platforms/linux_i386.toolchain.cmake \ -DCMAKE_BUILD_TYPE=${{env.CMAKE_BUILD_TYPE}} -DCMAKE_INSTALL_PREFIX=/usr -DCPACK=ON \ -DBUILD_TESTING=OFF -DDEVILUTIONX_SYSTEM_LIBFMT=OFF -DDEVILUTIONX_SYSTEM_LIBSODIUM=OFF \ - -DDEVILUTIONX_SYSTEM_SIMPLEINI=OFF -DDEVILUTIONX_SYSTEM_BZIP2=OFF && \ + -DDEVILUTIONX_SYSTEM_BZIP2=OFF && \ cmake --build build -j $(getconf _NPROCESSORS_ONLN) --target package - name: Package diff --git a/.github/workflows/Linux_x86_64.yml b/.github/workflows/Linux_x86_64.yml index 3c322bbfa..e34989409 100644 --- a/.github/workflows/Linux_x86_64.yml +++ b/.github/workflows/Linux_x86_64.yml @@ -63,12 +63,11 @@ jobs: CMAKE_BUILD_TYPE: ${{github.event_name == 'release' && 'Release' || 'RelWithDebInfo'}} # We set DEVILUTIONX_SYSTEM_LIBFMT=OFF because its soversion changes frequently. # We set DEVILUTIONX_SYSTEM_LIBSODIUM=OFF because its soversion changes frequently. - # We set DEVILUTIONX_SYSTEM_SIMPLEINI=OFF because we require v4.19+, still missing from many distributions. # We set DEVILUTIONX_SYSTEM_BZIP2=OFF because Fedora and Debian do not agree on how to link it. run: | cmake -S. -Bbuild -DCMAKE_BUILD_TYPE=${{env.CMAKE_BUILD_TYPE}} -DCMAKE_INSTALL_PREFIX=/usr -DCPACK=ON \ -DDISCORD_INTEGRATION=ON -DBUILD_TESTING=OFF -DDEVILUTIONX_SYSTEM_LIBFMT=OFF \ - -DDEVILUTIONX_SYSTEM_LIBSODIUM=OFF -DDEVILUTIONX_SYSTEM_SIMPLEINI=OFF -DDEVILUTIONX_SYSTEM_BZIP2=OFF && \ + -DDEVILUTIONX_SYSTEM_LIBSODIUM=OFF -DDEVILUTIONX_SYSTEM_BZIP2=OFF && \ cmake --build build -j $(getconf _NPROCESSORS_ONLN) --target package - name: Package diff --git a/3rdParty/simpleini/CMakeLists.txt b/3rdParty/simpleini/CMakeLists.txt deleted file mode 100644 index e32fcf74d..000000000 --- a/3rdParty/simpleini/CMakeLists.txt +++ /dev/null @@ -1,12 +0,0 @@ -include(functions/FetchContent_MakeAvailableExcludeFromAll) - -include(FetchContent) -FetchContent_Declare(simpleini - URL https://github.com/brofield/simpleini/archive/56499b5af5d2195c6acfc58c4630b70e0c9c4c21.tar.gz - URL_HASH MD5=02a561cea03ea11acb65848318ec4a81 -) -FetchContent_MakeAvailableExcludeFromAll(simpleini) - -add_library(simpleini INTERFACE) -target_include_directories(simpleini INTERFACE ${simpleini_SOURCE_DIR}) -add_library(simpleini::simpleini ALIAS simpleini) diff --git a/CMake/Dependencies.cmake b/CMake/Dependencies.cmake index cd71bd863..4240de6ef 100644 --- a/CMake/Dependencies.cmake +++ b/CMake/Dependencies.cmake @@ -201,22 +201,6 @@ if(WIN32 AND NOT UWP_LIB) add_subdirectory(3rdParty/find_steam_game) endif() -if(NOT DEFINED DEVILUTIONX_SYSTEM_SIMPLEINI) - find_package(simpleini 4.19 QUIET) - if(simpleini_FOUND) - message("-- Found simpleini") - else() - message("-- Suitable system simpleini package not found, will use simpleini from source") - set(DEVILUTIONX_SYSTEM_SIMPLEINI OFF) - endif() -endif() -dependency_options("simpleini" DEVILUTIONX_SYSTEM_SIMPLEINI ON DEVILUTIONX_STATIC_SIMPLEINI) -if(DEVILUTIONX_SYSTEM_SIMPLEINI) - find_package(simpleini 4.19 REQUIRED) -else() - add_subdirectory(3rdParty/simpleini) -endif() - if(SUPPORTS_MPQ) add_subdirectory(3rdParty/libmpq) endif() diff --git a/Packaging/nix/debian-cross-aarch64-prep.sh b/Packaging/nix/debian-cross-aarch64-prep.sh index 22704264c..7619a971b 100755 --- a/Packaging/nix/debian-cross-aarch64-prep.sh +++ b/Packaging/nix/debian-cross-aarch64-prep.sh @@ -22,7 +22,7 @@ fi PACKAGES=( cmake git smpq gettext dpkg-cross libc-dev-arm64-cross libsdl2-dev:arm64 libsdl2-image-dev:arm64 libsodium-dev:arm64 - libsimpleini-dev:arm64 libpng-dev:arm64 libbz2-dev:arm64 libfmt-dev:arm64 + libpng-dev:arm64 libbz2-dev:arm64 libfmt-dev:arm64 libspeechd-dev:arm64 ) diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index f29184104..317b859dc 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -367,6 +367,17 @@ target_link_libraries(libdevilutionx_format_int PUBLIC libdevilutionx_strings ) +add_devilutionx_object_library(libdevilutionx_ini + utils/ini.cpp +) +target_link_libraries(libdevilutionx_ini PUBLIC + fmt::fmt + tl + unordered_dense::unordered_dense + libdevilutionx_strings + libdevilutionx_utf8 +) + add_library(libdevilutionx_log INTERFACE) target_include_directories(libdevilutionx_log INTERFACE ${PROJECT_SOURCE_DIR}/Source) @@ -402,13 +413,13 @@ target_link_libraries(libdevilutionx PUBLIC DevilutionX::SDL fmt::fmt libsmackerdec - simpleini::simpleini tl unordered_dense::unordered_dense libdevilutionx_codec libdevilutionx_crawl libdevilutionx_format_int libdevilutionx_file_util + libdevilutionx_ini libdevilutionx_parse_int libdevilutionx_strings libdevilutionx_utf8 diff --git a/Source/options.cpp b/Source/options.cpp index 84b7fee86..06e52eedd 100644 --- a/Source/options.cpp +++ b/Source/options.cpp @@ -5,14 +5,15 @@ */ #include +#include #include #include +#include +#include +#include #include -#define SI_NO_CONVERSION -#include - #include "control.h" #include "controls/controller.h" #include "controls/game_controls.h" @@ -28,8 +29,10 @@ #include "utils/algorithm/container.hpp" #include "utils/display.h" #include "utils/file_util.h" +#include "utils/ini.hpp" #include "utils/language.h" #include "utils/log.hpp" +#include "utils/logged_fstream.hpp" #include "utils/paths.h" #include "utils/str_cat.hpp" #include "utils/str_split.hpp" @@ -58,6 +61,8 @@ namespace devilution { namespace { +std::optional ini; + #if defined(__ANDROID__) || (defined(TARGET_OS_IPHONE) && TARGET_OS_IPHONE == 1) constexpr OptionEntryFlags OnlyIfSupportsWindowed = OptionEntryFlags::Invisible; #else @@ -79,167 +84,45 @@ std::string GetIniPath() return path; } -CSimpleIni &GetIni() +void LoadIni() { - static CSimpleIni ini; - static bool isIniLoaded = false; - if (!isIniLoaded) { - auto path = GetIniPath(); - FILE *file = OpenFile(path.c_str(), "rb"); - ini.SetSpaces(false); - ini.SetMultiKey(); - if (file != nullptr) { - ini.LoadFile(file); - std::fclose(file); - } - isIniLoaded = true; - } - return ini; -} - -bool IniChanged = false; - -/** - * @brief Checks if a ini entry is changed by comparing value before and after - */ -class IniChangedChecker { -public: - IniChangedChecker(const char *sectionName, const char *keyName) - { - this->sectionName_ = sectionName; - this->keyName_ = keyName; - oldValue_ = GetValue(); - if (!oldValue_) { - // No entry found in original ini => new entry => changed - IniChanged = true; - } - } - ~IniChangedChecker() - { - auto newValue = GetValue(); - if (oldValue_ != newValue) - IniChanged = true; - } - -private: - std::optional GetValue() - { - std::list values; - if (!GetIni().GetAllValues(sectionName_, keyName_, values)) - return std::nullopt; - std::string ret; - for (auto &entry : values) { - if (entry.pItem != nullptr) - ret.append(entry.pItem); - ret.append("\n"); + std::vector buffer; + auto path = GetIniPath(); + FILE *file = OpenFile(path.c_str(), "rb"); + if (file != nullptr) { + uintmax_t size; + if (GetFileSize(path.c_str(), &size)) { + buffer.resize(size); + if (std::fread(buffer.data(), size, 1, file) != 1) { + const char *errorMessage = std::strerror(errno); + if (errorMessage == nullptr) errorMessage = ""; + LogError(LogCategory::System, "std::fread: failed with \"{}\"", errorMessage); + buffer.clear(); + } } - return ret; - } - - std::optional oldValue_; - const char *sectionName_; - const char *keyName_; -}; - -int GetIniInt(const char *keyname, const char *valuename, int defaultValue) -{ - return GetIni().GetLongValue(keyname, valuename, defaultValue); -} - -bool GetIniBool(const char *sectionName, const char *keyName, bool defaultValue) -{ - return GetIni().GetBoolValue(sectionName, keyName, defaultValue); -} - -float GetIniFloat(const char *sectionName, const char *keyName, float defaultValue) -{ - return (float)GetIni().GetDoubleValue(sectionName, keyName, defaultValue); -} - -bool GetIniValue(std::string_view sectionName, std::string_view keyName, char *string, size_t stringSize, const char *defaultString = "") -{ - std::string sectionNameStr { sectionName }; - std::string keyNameStr { keyName }; - const char *value = GetIni().GetValue(sectionNameStr.c_str(), keyNameStr.c_str()); - if (value == nullptr) { - CopyUtf8(string, defaultString, stringSize); - return false; - } - CopyUtf8(string, value, stringSize); - return true; -} - -bool GetIniStringVector(const char *sectionName, const char *keyName, std::vector &stringValues) -{ - std::list values; - if (!GetIni().GetAllValues(sectionName, keyName, values)) { - return false; - } - for (auto &entry : values) { - stringValues.emplace_back(entry.pItem); - } - return true; -} - -void SetIniValue(const char *keyname, const char *valuename, int value) -{ - IniChangedChecker changedChecker(keyname, valuename); - GetIni().SetLongValue(keyname, valuename, value, nullptr, false, true); -} - -void SetIniValue(const char *keyname, const char *valuename, bool value) -{ - IniChangedChecker changedChecker(keyname, valuename); - GetIni().SetLongValue(keyname, valuename, value ? 1 : 0, nullptr, false, true); -} - -void SetIniValue(const char *keyname, const char *valuename, float value) -{ - IniChangedChecker changedChecker(keyname, valuename); - GetIni().SetDoubleValue(keyname, valuename, value, nullptr, true); -} - -void SetIniValue(const char *sectionName, const char *keyName, const char *value) -{ - IniChangedChecker changedChecker(sectionName, keyName); - auto &ini = GetIni(); - ini.SetValue(sectionName, keyName, value, nullptr, true); -} - -void SetIniValue(std::string_view sectionName, std::string_view keyName, std::string_view value) -{ - std::string sectionNameStr { sectionName }; - std::string keyNameStr { keyName }; - std::string valueStr { value }; - SetIniValue(sectionNameStr.c_str(), keyNameStr.c_str(), valueStr.c_str()); -} - -void SetIniValue(const char *keyname, const char *valuename, const std::vector &stringValues) -{ - IniChangedChecker changedChecker(keyname, valuename); - bool firstSet = true; - for (auto &value : stringValues) { - GetIni().SetValue(keyname, valuename, value.c_str(), nullptr, firstSet); - firstSet = false; + std::fclose(file); } - if (firstSet) - GetIni().SetValue(keyname, valuename, "", nullptr, true); + tl::expected result = Ini::parse(std::string_view(buffer.data(), buffer.size())); + if (!result.has_value()) app_fatal(result.error()); + ini.emplace(std::move(result).value()); } void SaveIni() { - if (!IniChanged) - return; + if (!ini.has_value()) return; + if (!ini->changed()) return; RecursivelyCreateDir(paths::ConfigPath().c_str()); const std::string iniPath = GetIniPath(); - FILE *file = OpenFile(iniPath.c_str(), "wb"); - if (file != nullptr) { - GetIni().SaveFile(file, true); - std::fclose(file); - } else { - LogError("Failed to write ini file to {}: {}", iniPath, std::strerror(errno)); + LoggedFStream out; + if (!out.Open(iniPath.c_str(), "wb")) { + LogError("Failed to open ini file for writing at {}: {}", iniPath, std::strerror(errno)); + return; } - IniChanged = false; + const std::string newContents = ini->serialize(); + if (out.Write(newContents.data(), newContents.size())) { + ini->markAsUnchanged(); + } + out.Close(); } #if SDL_VERSION_ATLEAST(2, 0, 0) @@ -353,25 +236,32 @@ bool HardwareCursorSupported() void LoadOptions() { + LoadIni(); for (OptionCategoryBase *pCategory : sgOptions.GetCategories()) { for (OptionEntryBase *pEntry : pCategory->GetEntries()) { pEntry->LoadFromIni(pCategory->GetKey()); } } - GetIniValue("Hellfire", "SItem", sgOptions.Hellfire.szItem, sizeof(sgOptions.Hellfire.szItem), ""); - - GetIniValue("Network", "Bind Address", sgOptions.Network.szBindAddress, sizeof(sgOptions.Network.szBindAddress), "0.0.0.0"); - GetIniValue("Network", "Previous Game ID", sgOptions.Network.szPreviousZTGame, sizeof(sgOptions.Network.szPreviousZTGame), ""); - GetIniValue("Network", "Previous Host", sgOptions.Network.szPreviousHost, sizeof(sgOptions.Network.szPreviousHost), ""); + ini->getUtf8Buf("Hellfire", "SItem", sgOptions.Hellfire.szItem, sizeof(sgOptions.Hellfire.szItem)); + ini->getUtf8Buf("Network", "Bind Address", "0.0.0.0", sgOptions.Network.szBindAddress, sizeof(sgOptions.Network.szBindAddress)); + ini->getUtf8Buf("Network", "Previous Game ID", sgOptions.Network.szPreviousZTGame, sizeof(sgOptions.Network.szPreviousZTGame)); + ini->getUtf8Buf("Network", "Previous Host", sgOptions.Network.szPreviousHost, sizeof(sgOptions.Network.szPreviousHost)); - for (size_t i = 0; i < QUICK_MESSAGE_OPTIONS; i++) - GetIniStringVector("NetMsg", QuickMessages[i].key, sgOptions.Chat.szHotKeyMsgs[i]); + for (size_t i = 0; i < QUICK_MESSAGE_OPTIONS; i++) { + std::span values = ini->get("NetMsg", QuickMessages[i].key); + std::vector &result = sgOptions.Chat.szHotKeyMsgs[i]; + result.clear(); + result.reserve(values.size()); + for (const Ini::Value &value : values) { + result.emplace_back(value.value); + } + } - GetIniValue("Controller", "Mapping", sgOptions.Controller.szMapping, sizeof(sgOptions.Controller.szMapping), ""); - sgOptions.Controller.fDeadzone = GetIniFloat("Controller", "deadzone", 0.07F); + ini->getUtf8Buf("Controller", "Mapping", sgOptions.Controller.szMapping, sizeof(sgOptions.Controller.szMapping)); + sgOptions.Controller.fDeadzone = ini->getFloat("Controller", "deadzone", 0.07F); #ifdef __vita__ - sgOptions.Controller.bRearTouch = GetIniBool("Controller", "Enable Rear Touchpad", true); + sgOptions.Controller.bRearTouch = ini->getBool("Controller", "Enable Rear Touchpad", true); #endif if (demo::IsRunning()) @@ -389,19 +279,20 @@ void SaveOptions() } } - SetIniValue("Hellfire", "SItem", sgOptions.Hellfire.szItem); + ini->set("Hellfire", "SItem", sgOptions.Hellfire.szItem); - SetIniValue("Network", "Bind Address", sgOptions.Network.szBindAddress); - SetIniValue("Network", "Previous Game ID", sgOptions.Network.szPreviousZTGame); - SetIniValue("Network", "Previous Host", sgOptions.Network.szPreviousHost); + ini->set("Network", "Bind Address", sgOptions.Network.szBindAddress); + ini->set("Network", "Previous Game ID", sgOptions.Network.szPreviousZTGame); + ini->set("Network", "Previous Host", sgOptions.Network.szPreviousHost); - for (size_t i = 0; i < QUICK_MESSAGE_OPTIONS; i++) - SetIniValue("NetMsg", QuickMessages[i].key, sgOptions.Chat.szHotKeyMsgs[i]); + for (size_t i = 0; i < QUICK_MESSAGE_OPTIONS; i++) { + ini->set("NetMsg", QuickMessages[i].key, sgOptions.Chat.szHotKeyMsgs[i]); + } - SetIniValue("Controller", "Mapping", sgOptions.Controller.szMapping); - SetIniValue("Controller", "deadzone", sgOptions.Controller.fDeadzone); + ini->set("Controller", "Mapping", sgOptions.Controller.szMapping); + ini->set("Controller", "deadzone", sgOptions.Controller.fDeadzone); #ifdef __vita__ - SetIniValue("Controller", "Enable Rear Touchpad", sgOptions.Controller.bRearTouch); + ini->set("Controller", "Enable Rear Touchpad", sgOptions.Controller.bRearTouch); #endif SaveIni(); @@ -431,11 +322,11 @@ void OptionEntryBase::NotifyValueChanged() void OptionEntryBoolean::LoadFromIni(std::string_view category) { - value = GetIniBool(category.data(), key.data(), defaultValue); + value = ini->getBool(category, key, defaultValue); } void OptionEntryBoolean::SaveToIni(std::string_view category) const { - SetIniValue(category.data(), key.data(), value); + ini->set(category, key, value); } void OptionEntryBoolean::SetValue(bool value) { @@ -462,11 +353,11 @@ std::string_view OptionEntryListBase::GetValueDescription() const void OptionEntryEnumBase::LoadFromIni(std::string_view category) { - value = GetIniInt(category.data(), key.data(), defaultValue); + value = ini->getInt(category, key, defaultValue); } void OptionEntryEnumBase::SaveToIni(std::string_view category) const { - SetIniValue(category.data(), key.data(), value); + ini->set(category, key, value); } void OptionEntryEnumBase::SetValueInternal(int value) { @@ -501,7 +392,7 @@ void OptionEntryEnumBase::SetActiveListIndex(size_t index) void OptionEntryIntBase::LoadFromIni(std::string_view category) { - value = GetIniInt(category.data(), key.data(), defaultValue); + value = ini->getInt(category, key, defaultValue); if (c_find(entryValues, value) == entryValues.end()) { entryValues.insert(c_lower_bound(entryValues, value), value); entryNames.clear(); @@ -509,7 +400,7 @@ void OptionEntryIntBase::LoadFromIni(std::string_view category) } void OptionEntryIntBase::SaveToIni(std::string_view category) const { - SetIniValue(category.data(), key.data(), value); + ini->set(category, key, value); } void OptionEntryIntBase::SetValueInternal(int value) { @@ -675,12 +566,12 @@ OptionEntryResolution::OptionEntryResolution() } void OptionEntryResolution::LoadFromIni(std::string_view category) { - size = { GetIniInt(category.data(), "Width", DEFAULT_WIDTH), GetIniInt(category.data(), "Height", DEFAULT_HEIGHT) }; + size = { ini->getInt(category, "Width", DEFAULT_WIDTH), ini->getInt(category, "Height", DEFAULT_HEIGHT) }; } void OptionEntryResolution::SaveToIni(std::string_view category) const { - SetIniValue(category.data(), "Width", size.width); - SetIniValue(category.data(), "Height", size.height); + ini->set(category, "Width", size.width); + ini->set(category, "Height", size.height); } void OptionEntryResolution::InvalidateList() @@ -821,8 +712,8 @@ OptionEntryResampler::OptionEntryResampler() } void OptionEntryResampler::LoadFromIni(std::string_view category) { - char resamplerStr[32]; - if (GetIniValue(category, key, resamplerStr, sizeof(resamplerStr))) { + std::string_view resamplerStr = ini->getString(category, key); + if (!resamplerStr.empty()) { std::optional resampler = ResamplerFromString(resamplerStr); if (resampler) { resampler_ = *resampler; @@ -836,7 +727,7 @@ void OptionEntryResampler::LoadFromIni(std::string_view category) void OptionEntryResampler::SaveToIni(std::string_view category) const { - SetIniValue(category, key, ResamplerToString(resampler_)); + ini->set(category, key, ResamplerToString(resampler_)); } size_t OptionEntryResampler::GetListSize() const @@ -878,15 +769,13 @@ OptionEntryAudioDevice::OptionEntryAudioDevice() } void OptionEntryAudioDevice::LoadFromIni(std::string_view category) { - char deviceStr[100]; - GetIniValue(category, key, deviceStr, sizeof(deviceStr), ""); - deviceName_ = deviceStr; + deviceName_ = ini->getString(category, key); } void OptionEntryAudioDevice::SaveToIni(std::string_view category) const { #if SDL_VERSION_ATLEAST(2, 0, 0) - SetIniValue(category, key, deviceName_); + ini->set(category, key, deviceName_); #endif } @@ -1162,11 +1051,10 @@ OptionEntryLanguageCode::OptionEntryLanguageCode() } void OptionEntryLanguageCode::LoadFromIni(std::string_view category) { - if (GetIniValue(category, key, szCode, sizeof(szCode))) { - if (HasTranslation(szCode)) { - // User preferred language is available - return; - } + ini->getUtf8Buf(category, key, szCode, sizeof(szCode)); + if (szCode[0] != '\0' && HasTranslation(szCode)) { + // User preferred language is available + return; } // Might be a first run or the user has attempted to load a translation that doesn't exist via manual ini edit. Try @@ -1205,7 +1093,7 @@ void OptionEntryLanguageCode::LoadFromIni(std::string_view category) } void OptionEntryLanguageCode::SaveToIni(std::string_view category) const { - SetIniValue(category, key, szCode); + ini->set(category, key, szCode); } void OptionEntryLanguageCode::CheckLanguagesAreInitialized() const @@ -1391,22 +1279,22 @@ std::string_view KeymapperOptions::Action::GetName() const void KeymapperOptions::Action::LoadFromIni(std::string_view category) { - std::array result; - if (!GetIniValue(category.data(), key.data(), result.data(), result.size())) { + const std::span iniValues = ini->get(category, key); + if (iniValues.empty()) { SetValue(defaultKey); return; // Use the default key if no key has been set. } - std::string readKey = result.data(); - if (readKey.empty()) { + const std::string_view iniValue = iniValues.back().value; + if (iniValue.empty()) { SetValue(SDLK_UNKNOWN); return; } - auto keyIt = sgOptions.Keymapper.keyNameToKeyID.find(readKey); + auto keyIt = sgOptions.Keymapper.keyNameToKeyID.find(iniValue); if (keyIt == sgOptions.Keymapper.keyNameToKeyID.end()) { // Use the default key if the key is unknown. - Log("Keymapper: unknown key '{}'", readKey); + Log("Keymapper: unknown key '{}'", iniValue); SetValue(defaultKey); return; } @@ -1419,7 +1307,7 @@ void KeymapperOptions::Action::SaveToIni(std::string_view category) const { if (boundKey == SDLK_UNKNOWN) { // Just add an empty config entry if the action is unbound. - SetIniValue(category.data(), key.data(), ""); + ini->set(category, key, std::string {}); return; } auto keyNameIt = sgOptions.Keymapper.keyIDToKeyName.find(boundKey); @@ -1427,7 +1315,7 @@ void KeymapperOptions::Action::SaveToIni(std::string_view category) const LogVerbose("Keymapper: no name found for key {} bound to {}", boundKey, key); return; } - SetIniValue(category.data(), key.data(), keyNameIt->second.c_str()); + ini->set(category, key, keyNameIt->second); } std::string_view KeymapperOptions::Action::GetValueDescription() const @@ -1611,15 +1499,16 @@ std::string_view PadmapperOptions::Action::GetName() const void PadmapperOptions::Action::LoadFromIni(std::string_view category) { - std::array result; - if (!GetIniValue(category.data(), key.data(), result.data(), result.size())) { + const std::span iniValues = ini->get(category, key); + if (iniValues.empty()) { SetValue(defaultInput); return; // Use the default button combo if no mapping has been set. } + const std::string_view iniValue = iniValues.back().value; std::string modName; std::string buttonName; - auto parts = SplitByChar(result.data(), '+'); + auto parts = SplitByChar(iniValue, '+'); auto it = parts.begin(); if (it == parts.end()) { SetValue(ControllerButtonCombo {}); @@ -1660,7 +1549,7 @@ void PadmapperOptions::Action::SaveToIni(std::string_view category) const { if (boundInput.button == ControllerButton_NONE) { // Just add an empty config entry if the action is unbound. - SetIniValue(category.data(), key.data(), ""); + ini->set(category, key, ""); return; } std::string inputName = sgOptions.Padmapper.buttonToButtonName[static_cast(boundInput.button)]; @@ -1676,7 +1565,7 @@ void PadmapperOptions::Action::SaveToIni(std::string_view category) const } inputName = StrCat(modifierName, "+", inputName); } - SetIniValue(category.data(), key.data(), inputName.data()); + ini->set(category, key, inputName.data()); } void PadmapperOptions::Action::UpdateValueDescription() const diff --git a/Source/options.h b/Source/options.h index 3c9125735..3ae6c1f51 100644 --- a/Source/options.h +++ b/Source/options.h @@ -16,6 +16,7 @@ #include "engine/sound_defs.hpp" #include "pack.h" #include "utils/enum_traits.h" +#include "utils/string_view_hash.hpp" namespace devilution { @@ -710,7 +711,7 @@ private: std::forward_list actions; ankerl::unordered_dense::segmented_map> keyIDToAction; ankerl::unordered_dense::segmented_map keyIDToKeyName; - ankerl::unordered_dense::segmented_map keyNameToKeyID; + ankerl::unordered_dense::segmented_map keyNameToKeyID; }; /** The Padmapper maps gamepad buttons to actions. */ @@ -782,7 +783,7 @@ private: std::forward_list actions; std::array::value> buttonToReleaseAction; std::array::value> buttonToButtonName; - ankerl::unordered_dense::segmented_map buttonNameToButton; + ankerl::unordered_dense::segmented_map buttonNameToButton; bool committed = false; const Action *FindAction(ControllerButton button) const; diff --git a/Source/utils/ini.cpp b/Source/utils/ini.cpp new file mode 100644 index 000000000..35d39afdc --- /dev/null +++ b/Source/utils/ini.cpp @@ -0,0 +1,415 @@ +#include "utils/ini.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "utils/algorithm/container.hpp" +#include "utils/str_cat.hpp" +#include "utils/string_view_hash.hpp" +#include "utils/utf8.hpp" + +namespace devilution { + +// We avoid including the "appfat.h" to avoid depending on SDL in tests. +[[noreturn]] extern void app_fatal(std::string_view str); + +namespace { + +// Returns a pointer to the first non-leading whitespace. +// Only ' ' and '\t' are considered whitespace. +// Requires: begin <= end. +const char *SkipLeadingWhitespace(const char *begin, const char *end) +{ + while (begin != end && (*begin == ' ' || *begin == '\t')) { + ++begin; + } + return begin; +} + +// Returns a pointer to the last non-whitespace. +// Only ' ' and '\t' are considered whitespace. +// Requires: begin <= end. +const char *SkipTrailingWhitespace(const char *begin, const char *end) +{ + while (begin != end && (*(end - 1) == ' ' || *(end - 1) == '\t')) { + --end; + } + return end; +} + +// Skips UTF-8 byte order mark. +// See https://en.wikipedia.org/wiki/Byte_order_mark +const char *SkipUtf8Bom(const char *begin, const char *end) +{ + if (end - begin >= 3 && begin[0] == '\xEF' && begin[1] == '\xBB' && begin[2] == '\xBF') { + return begin + 3; + } + return begin; +} + +} // namespace + +Ini::Values::Values() + : rep_(std::vector {}) +{ +} + +Ini::Values::Values(const Value &data) + : rep_(data) +{ +} + +std::span Ini::Values::get() const +{ + if (std::holds_alternative(rep_)) { + return { &std::get(rep_), 1 }; + } + return std::get>(rep_); +} + +std::span Ini::Values::get() +{ + if (std::holds_alternative(rep_)) { + return { &std::get(rep_), 1 }; + } + return std::get>(rep_); +} + +void Ini::Values::append(const Ini::Value &value) +{ + if (std::holds_alternative(rep_)) { + rep_ = std::vector { std::get(rep_), value }; + return; + } + std::get>(rep_).push_back(value); +} + +tl::expected Ini::parse(std::string_view buffer) +{ + Ini::FileData fileData; + ankerl::unordered_dense::map *sectionEntries = nullptr; + const char *eof = buffer.data() + buffer.size(); + const char *lineBegin = SkipUtf8Bom(buffer.data(), eof); + size_t lineNum = 0; + + const char *commentBegin = nullptr; + const char *nextLineBegin; + for (; lineBegin < eof; lineBegin = nextLineBegin) { + ++lineNum; + const char *lineEnd = static_cast(memchr(lineBegin, '\n', eof - lineBegin)); + if (lineEnd == nullptr) { + lineEnd = eof; + nextLineBegin = eof; + } else { + nextLineBegin = lineEnd + 1; + if (lineBegin + 1 < lineEnd && *(lineEnd - 1) == '\r') --lineEnd; + } + const char *keyBegin = SkipLeadingWhitespace(lineBegin, lineEnd); + if (keyBegin == lineEnd) continue; + + if (*keyBegin == ';') { + if (commentBegin == nullptr) commentBegin = lineBegin; + continue; + } + std::string_view comment; + if (commentBegin != nullptr) { + comment = std::string_view { commentBegin, lineBegin }; + } + + if (*keyBegin == '[') { + const char *keyEnd = ++keyBegin; + while (keyEnd < lineEnd && *keyEnd != ']') { + ++keyEnd; + } + if (keyEnd == lineEnd) { + return tl::make_unexpected(fmt::format("line {}: unclosed section name {}", lineNum, std::string_view(keyBegin, keyEnd))); + } + if (const char *after = SkipTrailingWhitespace(keyEnd + 1, lineEnd); after != lineEnd) { + return tl::make_unexpected(fmt::format("line {}: content after section [{}]: {}", lineNum, std::string_view(keyBegin, keyEnd), std::string_view(after, lineEnd))); + } + const std::string_view sectionName = std::string_view(keyBegin, keyEnd); + auto it = fileData.sections.find(sectionName); + if (it == fileData.sections.end()) { + it = fileData.sections.emplace_hint(it, sectionName, + SectionData { + .comment = std::string(comment), + .entries = {}, + .index = static_cast(fileData.sections.size()), + }); + } + sectionEntries = &it->second.entries; + commentBegin = nullptr; + continue; + } + + if (sectionEntries == nullptr) return tl::unexpected(fmt::format("line {}: key not in any section", lineNum)); + const char *eqPos = static_cast(memchr(keyBegin, '=', lineEnd - keyBegin)); + if (eqPos == nullptr) { + return tl::make_unexpected(fmt::format("line {}: key {} has no value", lineNum, std::string_view(keyBegin, lineEnd))); + } + const std::string_view key = std::string_view(keyBegin, SkipTrailingWhitespace(keyBegin, eqPos)); + const std::string_view value = std::string_view(SkipLeadingWhitespace(eqPos + 1, lineEnd), lineEnd); + if (const auto it = sectionEntries->find(key); it != sectionEntries->end()) { + it->second.values.append(Value { std::string(comment), std::string(value) }); + } else { + sectionEntries->emplace_hint(it, key, + ValuesData { + .values = Values { Value { + .comment = std::string(comment), + .value = std::string(value), + } }, + .index = static_cast(sectionEntries->size()), + }); + } + commentBegin = nullptr; + } + return Ini(std::move(fileData)); +} + +std::span Ini::get(std::string_view section, std::string_view key) const +{ + const auto sectionIt = data_.sections.find(section); + if (sectionIt == data_.sections.end()) return {}; + const auto it = sectionIt->second.entries.find(key); + if (it == sectionIt->second.entries.end()) return {}; + return it->second.values.get(); +} + +std::string_view Ini::getString(std::string_view section, std::string_view key, std::string_view defaultValue) const +{ + const std::span xs = get(section, key); + if (xs.empty() || xs.back().value.empty()) return defaultValue; + return xs.back().value; +} + +int Ini::getInt(std::string_view section, std::string_view key, int defaultValue) const +{ + const std::span xs = get(section, key); + if (xs.empty() || xs.back().value.empty()) return defaultValue; + const std::string_view str = xs.back().value; + int value; + const std::from_chars_result result = std::from_chars(str.data(), str.data() + str.size(), value); + if (result.ec != std::errc()) { + app_fatal(fmt::format("ini: Failed to parse {}.{}={} as int", section, key, str)); + return defaultValue; + } + return value; +} + +bool Ini::getBool(std::string_view section, std::string_view key, bool defaultValue) const +{ + const std::span xs = get(section, key); + if (xs.empty() || xs.back().value.empty()) return defaultValue; + const std::string_view str = xs.back().value; + if (str == "0") return false; + if (str == "1") return true; + app_fatal(fmt::format("ini: Failed to parse {}.{}={} as bool", section, key, str)); +} + +float Ini::getFloat(std::string_view section, std::string_view key, float defaultValue) const +{ + const std::span xs = get(section, key); + if (xs.empty() || xs.back().value.empty()) return defaultValue; + const std::string_view str = xs.back().value; + float value; + const std::from_chars_result result = std::from_chars(str.data(), str.data() + str.size(), value); + if (result.ec != std::errc()) { + app_fatal(fmt::format("ini: Failed to parse {}.{}={} as float", section, key, str)); + return defaultValue; + } + return value; +} + +void Ini::getUtf8Buf(std::string_view section, std::string_view key, std::string_view defaultValue, char *dst, size_t dstSize) const +{ + CopyUtf8(dst, getString(section, key, defaultValue), dstSize); +} + +void Ini::set(std::string_view section, std::string_view key, Ini::Values &&values) +{ + const std::span updated = values.get(); + + auto sectionIt = data_.sections.find(section); + if (sectionIt == data_.sections.end()) { + // Deleting a key from a non-existing section + if (updated.empty()) return; + + // Adding a new section and key + data_.sections.emplace_hint(sectionIt, section, + SectionData { + .comment = {}, + .entries = { { std::string(key), ValuesData { .values = std::move(values), .index = 0 } } }, + .index = static_cast(data_.sections.size()), + }); + changed_ = true; + return; + } + const auto it = sectionIt->second.entries.find(key); + if (it == sectionIt->second.entries.end()) { + // Deleting a non-existing key + if (updated.empty()) return; + + // Adding a new key to an existing section + sectionIt->second.entries.emplace(key, + ValuesData { + .values = std::move(values), + .index = static_cast(sectionIt->second.entries.size()), + }); + changed_ = true; + return; + } + + // Deleting an existing key + if (updated.empty()) { + sectionIt->second.entries.erase(it); + if (sectionIt->second.entries.empty()) data_.sections.erase(sectionIt); + changed_ = true; + return; + } + + // Overriding an existing key + const std::span original = it->second.values.get(); + if (original.size() == updated.size()) { + bool equal = true; + for (size_t i = 0; i < original.size(); ++i) { + if (original[i].value != updated[i].value) { + equal = false; + break; + } + } + if (equal) return; + } + + // Preserve existing comments where not overriden. + for (size_t i = 0, n = std::min(original.size(), updated.size()); i < n; ++i) { + if (!updated[i].comment.has_value() && original[i].comment.has_value()) { + updated[i].comment = std::move(original[i].comment); + } + } + it->second.values = std::move(values); + changed_ = true; +} + +void Ini::set(std::string_view section, std::string_view key, std::span strings) +{ + if (strings.empty()) { + set(section, key, Values {}); + } else if (strings.size() == 1) { + set(section, key, Values { Value { .comment = {}, .value = strings[0] } }); + } else { + Values values; + auto &items = std::get>(values.rep_); + items.reserve(strings.size()); + for (const std::string &str : strings) { + items.push_back(Value { .comment = {}, .value = str }); + } + set(section, key, std::move(values)); + } +} + +void Ini::set(std::string_view section, std::string_view key, std::string &&value) +{ + set(section, key, Values { Value { .comment = {}, .value = std::move(value) } }); +} + +void Ini::set(std::string_view section, std::string_view key, std::string_view value) +{ + + set(section, key, std::string(value)); +} + +void Ini::set(std::string_view section, std::string_view key, int value) +{ + set(section, key, StrCat(value)); +} + +void Ini::set(std::string_view section, std::string_view key, bool value) +{ + set(section, key, std::string(value ? "1" : "0")); +} + +void Ini::set(std::string_view section, std::string_view key, float value) +{ + constexpr size_t BufSize = 64; + char buf[BufSize] {}; + const std::to_chars_result result = std::to_chars(buf, buf + BufSize, value); + if (result.ec != std::errc()) { + app_fatal("float->string failed"); // should never happen + } + set(section, key, std::string_view(buf, result.ptr)); +} + +namespace { + +template +bool OrderByValueIndex(const std::pair &a, const std::pair &b) +{ + return a.second.index < b.second.index; +}; + +// Appends a possibly multi-line comment, converting \r\n to \n. +void AppendComment(std::string_view comment, std::string &out) +{ + bool prevR = false; + for (const char c : comment) { + if (prevR) { + prevR = c != '\r'; + out += c; + } else if (c == '\r') { + prevR = true; + } else { + out += c; + } + } +} + +void AppendSection(std::string_view sectionName, std::string &out) +{ + out.append("[").append(sectionName).append("]\n"); +} + +void AppendKeyValue(std::string_view key, std::string_view value, std::string &out) +{ + out.append(key).append("=").append(value).append("\n"); +} + +} // namespace + +std::string Ini::serialize() const +{ + std::string result; + std::vector> sections(data_.sections.begin(), data_.sections.end()); + c_sort(sections, OrderByValueIndex); + + std::vector> entries; + for (auto &[sectionName, section] : sections) { + if (!result.empty()) result += '\n'; + if (!section.comment.empty()) AppendComment(section.comment, result); + AppendSection(sectionName, result); + entries.assign(section.entries.begin(), section.entries.end()); + c_sort(entries, OrderByValueIndex); + for (const auto &[key, entry] : entries) { + for (const auto &[comment, value] : entry.values.get()) { + if (comment.has_value() && !comment->empty()) AppendComment(*comment, result); + AppendKeyValue(key, value, result); + } + } + } + return result; +} + +} // namespace devilution diff --git a/Source/utils/ini.hpp b/Source/utils/ini.hpp new file mode 100644 index 000000000..dee4cbb97 --- /dev/null +++ b/Source/utils/ini.hpp @@ -0,0 +1,121 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "utils/string_view_hash.hpp" + +namespace devilution { + +class Ini { +public: + // A single value associated with a section and key. + struct Value { + // When setting a value, `nullopt` results + // in preserving the existing comment if any. + std::optional comment; + std::string value; + }; + + // All the values associated with a section and key. + class Values { + public: + /** + * @brief Constructs an empty set of values. + * + * If passed to `set`, the key is deleted. + */ + Values(); + + explicit Values(const Value &data); + + [[nodiscard]] std::span get() const; + [[nodiscard]] std::span get(); + void append(const Value &value); + + private: + // Most keys only have a single value, so we use + // a representation that avoids allocations in that case. + std::variant> rep_; + + friend class Ini; + }; + + static tl::expected parse(std::string_view buffer); + [[nodiscard]] std::string serialize() const; + + /** @return all the values associated with this section and key in the ini */ + [[nodiscard]] std::span get(std::string_view section, std::string_view key) const; + + /** @return the default value if the ini value is unset or empty */ + [[nodiscard]] std::string_view getString(std::string_view section, std::string_view key, std::string_view defaultValue = {}) const; + + /** @return the default value if the ini value is unset or empty */ + [[nodiscard]] bool getBool(std::string_view section, std::string_view key, bool defaultValue) const; + + /** @return the default value if the ini value is unset or empty */ + [[nodiscard]] int getInt(std::string_view section, std::string_view key, int defaultValue) const; + + /** @return the default value if the ini value is unset or empty */ + [[nodiscard]] float getFloat(std::string_view section, std::string_view key, float defaultValue) const; + + void getUtf8Buf(std::string_view section, std::string_view key, std::string_view defaultValue, char *dst, size_t dstSize) const; + + void getUtf8Buf(std::string_view section, std::string_view key, char *dst, size_t dstSize) const + { + getUtf8Buf(section, key, /*defaultValue=*/ {}, dst, dstSize); + } + + [[nodiscard]] bool changed() const { return changed_; } + void markAsUnchanged() { changed_ = false; } + + // If values are empty, deletes the entry. + void set(std::string_view section, std::string_view key, Values &&values); + + void set(std::string_view section, std::string_view key, std::span value); + void set(std::string_view section, std::string_view key, std::string &&value); + void set(std::string_view section, std::string_view key, std::string_view value); + void set(std::string_view section, std::string_view key, const char *value) + { + set(section, key, std::string_view(value)); + } + void set(std::string_view section, std::string_view key, bool value); + void set(std::string_view section, std::string_view key, int value); + void set(std::string_view section, std::string_view key, float value); + +private: + struct ValuesData { + Values values; + uint32_t index; + }; + + struct SectionData { + std::string comment; + ankerl::unordered_dense::map entries; + uint32_t index; + }; + + struct FileData { + ankerl::unordered_dense::map sections; + }; + + explicit Ini(FileData &&data) + : data_(std::move(data)) + { + } + + FileData data_; + bool changed_ = false; +}; + +} // namespace devilution diff --git a/Source/utils/string_or_view.hpp b/Source/utils/string_or_view.hpp index 1d38c75bd..879384741 100644 --- a/Source/utils/string_or_view.hpp +++ b/Source/utils/string_or_view.hpp @@ -14,6 +14,7 @@ public: { } + StringOrView(const StringOrView &) = default; StringOrView(StringOrView &&) noexcept = default; StringOrView(std::string &&str) diff --git a/Source/utils/utf8.cpp b/Source/utils/utf8.cpp index 2c28694ff..50f46975e 100644 --- a/Source/utils/utf8.cpp +++ b/Source/utils/utf8.cpp @@ -41,7 +41,11 @@ std::string_view TruncateUtf8(std::string_view str, std::size_t len) void CopyUtf8(char *dest, std::string_view source, std::size_t bytes) { source = TruncateUtf8(source, bytes - 1); - std::memcpy(dest, source.data(), source.size()); + // source.empty() can mean source.data() == nullptr. + // It is UB to pass a null pointer to memcpy, so we guard against it. + if (!source.empty()) { + std::memcpy(dest, source.data(), source.size()); + } dest[source.size()] = '\0'; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index ce9b73473..a8d4f3e46 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -43,6 +43,7 @@ set(standalone_tests crawl_test file_util_test format_int_test + ini_test parse_int_test str_cat_test utf8_test @@ -93,6 +94,7 @@ target_link_libraries(crawl_benchmark PRIVATE libdevilutionx_crawl) target_link_libraries(dun_render_benchmark PRIVATE libdevilutionx_so) target_link_libraries(file_util_test PRIVATE libdevilutionx_file_util app_fatal_for_testing) target_link_libraries(format_int_test PRIVATE libdevilutionx_format_int language_for_testing) +target_link_libraries(ini_test PRIVATE libdevilutionx_ini app_fatal_for_testing) target_link_libraries(parse_int_test PRIVATE libdevilutionx_parse_int) target_link_libraries(str_cat_test PRIVATE libdevilutionx_strings) target_link_libraries(utf8_test PRIVATE libdevilutionx_utf8) diff --git a/test/ini_test.cpp b/test/ini_test.cpp new file mode 100644 index 000000000..8f489a3c6 --- /dev/null +++ b/test/ini_test.cpp @@ -0,0 +1,86 @@ +#include "utils/ini.hpp" + +#include +#include + +#include + +namespace devilution { +namespace { + +using ::testing::ElementsAre; +using ::testing::Eq; +using ::testing::Field; + +} // namespace + +TEST(IniTest, BasicTest) +{ + tl::expected result = Ini::parse(R"( +; Section A comment +[sectionA] +key1 = value1 +key2 = value2 +; comment multi 1 +multi = a +; comment multi 2 +multi = b +int=-3 +float=2.5 +; bool yes comment +bool yes=1 +bool no=0 + +; Section B comment line 1 +; Section B comment line 2 +[sectionB] +key = value +)"); + + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_EQ(result->getString("sectionA", "key1"), "value1"); + EXPECT_EQ(result->getString("sectionA", "key2"), "value2"); + { + const std::span multiVals = result->get("sectionA", "multi"); + std::vector multiStrs; + for (const Ini::Value &val : multiVals) { + multiStrs.push_back(val.value); + } + EXPECT_THAT(multiStrs, ElementsAre(Eq("a"), Eq("b"))); + } + EXPECT_EQ(result->getInt("sectionA", "int", 0), -3); + EXPECT_NEAR(result->getFloat("sectionA", "float", 0.0f), 2.5f, 0.001f); + EXPECT_EQ(result->getString("sectionB", "key"), "value"); + + result->set("newSection", "newKey", "hello"); + result->set("sectionA", "key1", "newValue"); + result->set("sectionA", "int", 1337); + result->set("sectionA", "bool yes", false); + const std::vector newMulti { "x", "y", "z" }; + result->set("sectionA", "multi", newMulti); + result->set("sectionA", "float", 10.5F); + EXPECT_EQ(result->serialize(), std::string_view(R"(; Section A comment +[sectionA] +key1=newValue +key2=value2 +; comment multi 1 +multi=x +; comment multi 2 +multi=y +multi=z +int=1337 +float=10.5 +; bool yes comment +bool yes=0 +bool no=0 + +; Section B comment line 1 +; Section B comment line 2 +[sectionB] +key=value + +[newSection] +newKey=hello +)")); +} +} // namespace devilution diff --git a/tools/make_src_dist.py b/tools/make_src_dist.py index f633814d9..c1db1fb64 100755 --- a/tools/make_src_dist.py +++ b/tools/make_src_dist.py @@ -33,10 +33,10 @@ import subprocess import sys # We only package the dependencies that are: -# 1. Uncommon in package managers (sdl_audiolib and simpleini). +# 1. Uncommon in package managers (sdl_audiolib). # 2. Require devilutionx forks (all others). _DEPS = ['asio', 'libmpq', 'libsmackerdec', - 'libzt', 'sdl_audiolib', 'simpleini', 'unordered_dense'] + 'libzt', 'sdl_audiolib', 'unordered_dense'] _ALWAYS_VENDORED_DEPS = ['asio', 'libmpq', 'libsmackerdec', 'libzt'] # These dependencies are not vendored by default. diff --git a/vcpkg.json b/vcpkg.json index 86982b1c2..f052d7cd5 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -4,7 +4,6 @@ "dependencies": [ "fmt", "bzip2", - "simpleini", "lua" ], "builtin-baseline": "29b2ea2d4b6197e66ef346e62ccbba35b55b7de5",