diff --git a/.gitignore b/.gitignore index 408404e..3cf87a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ # ---> C++ +# LSP Cache +.cache/ + # Build dirs build/ diff --git a/Garbage/Include/Garbage/SimpleConf.hpp b/Garbage/Include/Garbage/SimpleConf.hpp new file mode 100644 index 0000000..2972ea5 --- /dev/null +++ b/Garbage/Include/Garbage/SimpleConf.hpp @@ -0,0 +1,314 @@ +#ifndef GARBAGE_SIMPLE_CONF_HPP +#define GARBAGE_SIMPLE_CONF_HPP + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Garbage +{ + namespace fs = std::filesystem; + + enum class SimpleConfErrorKind + { + ReadError, + ConversionError, + ParseError, + LookupError, + }; + + constexpr + std::string AsString(SimpleConfErrorKind err) + { + switch (err) { + case SimpleConfErrorKind::ReadError: + return "Read Error"; + case SimpleConfErrorKind::ConversionError: + return "Conversion Error"; + case SimpleConfErrorKind::ParseError: + return "Parse Error"; + case SimpleConfErrorKind::LookupError: + return "Lookup Error"; + default: + return "Unknown Error"; + } + } + + class SimpleConfError : public std::runtime_error + { + public: + SimpleConfError(SimpleConfErrorKind kind, std::string_view message) + : std::runtime_error(AsString(kind) + ": " + std::string(message)) + {} + }; + + namespace SimpleConfImplementation + { + class ReadError : public SimpleConfError + { + public: + ReadError(std::string_view message) + : SimpleConfError(SimpleConfErrorKind::ReadError, message) + {} + }; + + class ConversionError : public SimpleConfError + { + public: + ConversionError(std::string_view message) + : SimpleConfError(SimpleConfErrorKind::ConversionError, message) + {} + }; + + class ParseError : public SimpleConfError + { + public: + ParseError(std::size_t line, std::string_view message) + : SimpleConfError(SimpleConfErrorKind::ParseError, + std::format("[Line {}] {}", line, message)) + {} + }; + + class LookupError : public SimpleConfError + { + public: + LookupError(std::string_view message) + : SimpleConfError(SimpleConfErrorKind::LookupError, message) + {} + }; + + template + constexpr + T Convert(std::string_view rawValue) + { + if constexpr (std::is_same_v) { + return std::string(rawValue); + } + + if constexpr (std::is_integral_v || std::is_floating_point_v) { + T result{}; + auto [ptr, ec] = std::from_chars(rawValue.data(), + rawValue.data() + rawValue.size(), + result); + + if (ptr != rawValue.data() + rawValue.size()) { + throw ConversionError("Could not parse the whole value"); + } + else if (ec == std::errc::invalid_argument) { + throw ConversionError("The value is not a valid number"); + } + else if (ec == std::errc::result_out_of_range) { + throw ConversionError("The value is too large to store in the requested type"); + } + else if (ec != std::errc()) { + throw ConversionError("Unknown error"); + } + + return result; + } + + throw ConversionError("Can't convert the raw value to the requested type"); + } + + using PairsType = std::unordered_map; + + constexpr + void Parse(const std::string& rawConf, PairsType& pairs) + { + std::size_t current = 0; + std::size_t currentLine = 1; + + enum class State + { + Initial, + ReadingKey, + ReadingValue, + CommentLine, + } currentState = State::Initial; + + std::size_t keyBegin = 0; + std::size_t keyEnd = 0; + std::size_t valueBegin = 0; + std::size_t valueEnd = 0; + + auto addPair = [&] { + if (keyBegin == keyEnd) { + throw ParseError(currentLine, "Empty key is not allowed"); + } + // Allow empty values? + + std::string_view key(rawConf.data() + keyBegin, keyEnd - keyBegin); + std::string_view value(rawConf.data() + valueBegin, valueEnd - valueBegin); + if (value.size() == 0) { + value = std::string_view(nullptr, 0); + } + pairs[key] = value; + + keyBegin = 0; + keyEnd = 0; + valueBegin = 0; + valueEnd = 0; + }; + + while (current < rawConf.size()) { + switch (currentState) { + break; case State::Initial: + if (std::isspace(rawConf[current])) { + if (rawConf[current] == '\n') { + ++currentLine; + } + ++current; + } + else if (rawConf[current] == '#') { + currentState = State::CommentLine; + } + else if (rawConf[current] == '=') { + throw ParseError(currentLine, "Empty key is not allowed"); + } + else { + keyBegin = current; + keyEnd = current + 1; // We know there is at least one char + currentState = State::ReadingKey; + } + break; case State::ReadingKey: + if (rawConf[current] == '=') { + currentState = State::ReadingValue; + } + else if (rawConf[current] == '#') { + throw ParseError(currentLine, "Unexpected comment start"); + } + else if (!std::isspace(rawConf[current])) { + keyEnd = current + 1; + } + else if (rawConf[current] == '\n') { + throw ParseError(currentLine, "End of line while reading key"); + } + ++current; + break; case State::ReadingValue: + if (rawConf[current] == '\n') { + addPair(); + + ++currentLine; + currentState = State::Initial; + } + else if (rawConf[current] == '=') { + throw ParseError(currentLine, "Multiple key/value separators are illegal"); + } + else if (rawConf[current] == '#') { + addPair(); + currentState = State::CommentLine; + } + else { + if (!std::isspace(rawConf[current])) { + if (valueBegin == 0) { + valueBegin = current; + valueEnd = current + 1; + } + else { + valueEnd = current + 1; + } + } + } + ++current; + break; case State::CommentLine: + if (rawConf[current] == '\n') { + ++currentLine; + currentState = State::Initial; + } + ++current; + break; default: + throw ParseError(currentLine, "Invalid parser state (should be impossible)"); + } + } + + if (currentState == State::ReadingKey) { + throw ParseError(currentLine, "End of config while reading the key"); + } else if (currentState == State::ReadingValue) { + addPair(); + } + } + } + + class SimpleConf + { + public: + struct Required {}; + + SimpleConf(const fs::path& path) + { + using namespace SimpleConfImplementation; + + std::error_code ec; + if (!fs::exists(path, ec) || ec) { + throw ReadError("The specified file does not exist"); + } + if (!fs::is_regular_file(path, ec) || ec) { + throw ReadError("The the specified path is not a regular file"); + } + + std::size_t fileSize = fs::file_size(path, ec); + if (ec) { + throw ReadError("Could not read the file size"); + } + + std::ifstream file(path, std::ios::binary); + if (!file) { + throw ReadError("Could not open the file for reading"); + } + + mConfContent = std::string(fileSize, '\0'); + file.read(mConfContent.data(), fileSize); + + Parse(mConfContent, mPairs); + } + + SimpleConf(std::string&& strConfig) + : mConfContent(std::move(strConfig)) + { + using namespace SimpleConfImplementation; + + Parse(mConfContent, mPairs); + } + + template + std::optional GetOptional(std::string_view key) + { + using namespace SimpleConfImplementation; + + if (auto it = mPairs.find(key); it != mPairs.end()) { + return Convert(it->second); + } + + return std::nullopt; + } + + template + T Get(std::string_view key, const T& defaultValue = {}) + { + using namespace SimpleConfImplementation; + + if (auto it = mPairs.find(key); it != mPairs.end()) { + return Convert(it->second); + } + + if constexpr (std::is_same_v) { + throw LookupError("Required config key not found"); + } + + return defaultValue; + } + + private: + + std::string mConfContent; + SimpleConfImplementation::PairsType mPairs; + }; +} + +#endif // GARBAGE_SIMPLE_CONF_HPP diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index 40c74bb..475c6f3 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -2,3 +2,4 @@ include(${PROJECT_SOURCE_DIR}/CMake/Catch2.cmake) add_subdirectory(Base64) add_subdirectory(SQLite) +add_subdirectory(SimpleConf) diff --git a/Tests/SimpleConf/CMakeLists.txt b/Tests/SimpleConf/CMakeLists.txt new file mode 100644 index 0000000..227164a --- /dev/null +++ b/Tests/SimpleConf/CMakeLists.txt @@ -0,0 +1,29 @@ +add_executable(TestSimpleConf) + +target_compile_features(TestSimpleConf + PRIVATE + cxx_std_20 +) + +target_link_libraries(TestSimpleConf + PRIVATE + Garbage + Catch2::Catch2WithMain +) + +target_sources(TestSimpleConf + PRIVATE + Comments.cpp + Conversions.cpp + EmptyConfigs.cpp + Optionality.cpp + Parsing.cpp + WhitespaceBehaviour.cpp +) + +add_custom_command( + TARGET TestSimpleConf POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_CURRENT_SOURCE_DIR}/TestConfigs + ${CMAKE_CURRENT_BINARY_DIR}/TestConfigs +) diff --git a/Tests/SimpleConf/Comments.cpp b/Tests/SimpleConf/Comments.cpp new file mode 100644 index 0000000..1e7d481 --- /dev/null +++ b/Tests/SimpleConf/Comments.cpp @@ -0,0 +1,15 @@ +#include +#include + +TEST_CASE("Comments and empty lines are ignored") +{ + using Required = Garbage::SimpleConf::Required; + using LookupError = Garbage::SimpleConfImplementation::LookupError; + + std::filesystem::path path("TestConfigs/Comments.conf"); + + Garbage::SimpleConf config(path); + + REQUIRE_THROWS_AS((config.Get("this shouldn't")), LookupError); + REQUIRE(config.Get("while this") == "is accessible"); +} diff --git a/Tests/SimpleConf/Conversions.cpp b/Tests/SimpleConf/Conversions.cpp new file mode 100644 index 0000000..c3b6488 --- /dev/null +++ b/Tests/SimpleConf/Conversions.cpp @@ -0,0 +1,41 @@ +#include +#include + +TEST_CASE("Check if type conversions work correctly") +{ + using Required = Garbage::SimpleConf::Required; + + std::filesystem::path path("TestConfigs/BasicConversions.conf"); + + Garbage::SimpleConf config(path); + + SECTION("Basic stuff that should be correct") + { + REQUIRE(config.Get("an int") == "123456"); + REQUIRE(config.Get("a float") == "3.5"); + REQUIRE(config.Get("a long (8B)") == "121212121212121212"); + REQUIRE(config.Get("a double") == "0.0000128"); + + REQUIRE(config.Get("an int") == 123456); + REQUIRE(config.Get("a float") == 3.5f); + REQUIRE(config.Get("a long (8B)") == 121212121212121212); + REQUIRE(config.Get("a double") == 0.0000128); + } + + SECTION("Stuff that should fail to parse") + { + REQUIRE(config.Get("an incorrect char") == "256"); + REQUIRE(config.Get("unsigned") == "-34"); + REQUIRE(config.Get("signed, but large") == "3000000000"); + REQUIRE(config.Get("mangled float") == "3.f4"); + REQUIRE(config.Get("mangled float 2") == "2.4f"); + + using ConversionError = Garbage::SimpleConfImplementation::ConversionError; + + REQUIRE_THROWS_AS(config.Get("an incorrect char"), ConversionError); + REQUIRE_THROWS_AS(config.Get("unsigned"), ConversionError); + REQUIRE_THROWS_AS(config.Get("signed, but large"), ConversionError); + REQUIRE_THROWS_AS(config.Get("mangled float"), ConversionError); + REQUIRE_THROWS_AS(config.Get("mangled float 2"), ConversionError); + } +} diff --git a/Tests/SimpleConf/EmptyConfigs.cpp b/Tests/SimpleConf/EmptyConfigs.cpp new file mode 100644 index 0000000..e0108f6 --- /dev/null +++ b/Tests/SimpleConf/EmptyConfigs.cpp @@ -0,0 +1,17 @@ +#include +#include + +TEST_CASE("Config files with no key value pairs") +{ + SECTION("Empty file") + { + std::filesystem::path path("TestConfigs/Empty.conf"); + REQUIRE_NOTHROW(Garbage::SimpleConf(path)); + } + + SECTION("File with comments") + { + std::filesystem::path path("TestConfigs/CommentsOnly.conf"); + REQUIRE_NOTHROW(Garbage::SimpleConf(path)); + } +} diff --git a/Tests/SimpleConf/Optionality.cpp b/Tests/SimpleConf/Optionality.cpp new file mode 100644 index 0000000..e65406f --- /dev/null +++ b/Tests/SimpleConf/Optionality.cpp @@ -0,0 +1,25 @@ +#include +#include + +TEST_CASE("Check handling of optional values") +{ + std::filesystem::path path("TestConfigs/Optionality.conf"); + + Garbage::SimpleConf config(path); + + SECTION("GetOptional behaviour") + { + REQUIRE(!config.GetOptional("non-existent key")); + REQUIRE(*config.GetOptional("existing key") == "existing value"); + } + + SECTION("Get behaviour") + { + using Required = Garbage::SimpleConf::Required; + using LookupError = Garbage::SimpleConfImplementation::LookupError; + + REQUIRE(config.Get("non-existent key", "a default") == "a default"); + REQUIRE(config.Get("existing key") == "existing value"); + REQUIRE_THROWS_AS((config.Get("non-existent key")), LookupError); + } +} diff --git a/Tests/SimpleConf/Parsing.cpp b/Tests/SimpleConf/Parsing.cpp new file mode 100644 index 0000000..ec1a6b3 --- /dev/null +++ b/Tests/SimpleConf/Parsing.cpp @@ -0,0 +1,40 @@ +#include +#include +#include + +TEST_CASE("Ensure robust parsing") +{ + std::filesystem::path configs("TestConfigs"); + + using Catch::Matchers::ContainsSubstring; + + SECTION("Multiple assignments") + { + REQUIRE_THROWS_WITH(Garbage::SimpleConf(configs / "Parsing-MultipleEquals.conf"), + ContainsSubstring("[Line 3]")); + } + + SECTION("Multiple assignments") + { + REQUIRE_THROWS_WITH(Garbage::SimpleConf(configs / "Parsing-HashInKey.conf"), + ContainsSubstring("[Line 5]")); + } + + SECTION("No equals") + { + REQUIRE_THROWS_WITH(Garbage::SimpleConf(configs / "Parsing-OnlyKey.conf"), + ContainsSubstring("[Line 7]")); + } + + SECTION("Empty key") + { + REQUIRE_THROWS_WITH(Garbage::SimpleConf(configs / "Parsing-EmptyKey.conf"), + ContainsSubstring("[Line 7]")); + } + + SECTION("Key at end of file") + { + REQUIRE_THROWS_WITH(Garbage::SimpleConf(configs / "Parsing-KeyAtEnd.conf"), + ContainsSubstring("[Line 10]")); + } +} diff --git a/Tests/SimpleConf/TestConfigs/BasicConversions.conf b/Tests/SimpleConf/TestConfigs/BasicConversions.conf new file mode 100644 index 0000000..4fe52a5 --- /dev/null +++ b/Tests/SimpleConf/TestConfigs/BasicConversions.conf @@ -0,0 +1,12 @@ +# First, we wanna test if it can even do the basics +an int = 123456 +a float = 3.5 +a long (8B) = 121212121212121212 +a double = 0.0000128 + +# Now we want to see if it correctly blows up +an incorrect char = 256 +unsigned = -34 +signed, but large = 3000000000 +mangled float = 3.f4 +mangled float 2 = 2.4f diff --git a/Tests/SimpleConf/TestConfigs/Comments.conf b/Tests/SimpleConf/TestConfigs/Comments.conf new file mode 100644 index 0000000..c7ef0c0 --- /dev/null +++ b/Tests/SimpleConf/TestConfigs/Comments.conf @@ -0,0 +1,4 @@ +# Test whether comments behave correctly + +# this shouldn't = be accessible +while this = is accessible # and this comment doesn't interfere diff --git a/Tests/SimpleConf/TestConfigs/CommentsOnly.conf b/Tests/SimpleConf/TestConfigs/CommentsOnly.conf new file mode 100644 index 0000000..5d64d4c --- /dev/null +++ b/Tests/SimpleConf/TestConfigs/CommentsOnly.conf @@ -0,0 +1,3 @@ +# Another example of an empty config +# The config should initialize successfully, +# but there should be no kv-pairs inside it diff --git a/Tests/SimpleConf/TestConfigs/Empty.conf b/Tests/SimpleConf/TestConfigs/Empty.conf new file mode 100644 index 0000000..e69de29 diff --git a/Tests/SimpleConf/TestConfigs/Optionality.conf b/Tests/SimpleConf/TestConfigs/Optionality.conf new file mode 100644 index 0000000..c208851 --- /dev/null +++ b/Tests/SimpleConf/TestConfigs/Optionality.conf @@ -0,0 +1,2 @@ +# This probably doesn't need to exist, but eh +existing key = existing value diff --git a/Tests/SimpleConf/TestConfigs/Parsing-EmptyKey.conf b/Tests/SimpleConf/TestConfigs/Parsing-EmptyKey.conf new file mode 100644 index 0000000..8c4c45a --- /dev/null +++ b/Tests/SimpleConf/TestConfigs/Parsing-EmptyKey.conf @@ -0,0 +1,10 @@ +# A simple config file that + +normal_key=normal_value +another_key=another_value +generic=generic +another_one=another_value + = oh no, an empty key +another_one=... + +# It doesn't really matter what's here, because the parser won't reach it diff --git a/Tests/SimpleConf/TestConfigs/Parsing-HashInKey.conf b/Tests/SimpleConf/TestConfigs/Parsing-HashInKey.conf new file mode 100644 index 0000000..a4588f8 --- /dev/null +++ b/Tests/SimpleConf/TestConfigs/Parsing-HashInKey.conf @@ -0,0 +1,8 @@ +# A simple config file that contains a # character inside the key + +normal_key=normal_value +another_key=another_value +a_very_bas#ic_key=generic_value +another_one=... + +# It doesn't really matter what's here, because the parser won't reach it diff --git a/Tests/SimpleConf/TestConfigs/Parsing-KeyAtEnd.conf b/Tests/SimpleConf/TestConfigs/Parsing-KeyAtEnd.conf new file mode 100644 index 0000000..62ed3c6 --- /dev/null +++ b/Tests/SimpleConf/TestConfigs/Parsing-KeyAtEnd.conf @@ -0,0 +1,10 @@ +# A simple config file that + +normal_key=normal_value +another_key=another_value +generic=generic +another_one=another_value +another_one=... + +# It doesn't really matter what's here, because the parser won't reach it +sneaky_key \ No newline at end of file diff --git a/Tests/SimpleConf/TestConfigs/Parsing-MultipleEquals.conf b/Tests/SimpleConf/TestConfigs/Parsing-MultipleEquals.conf new file mode 100644 index 0000000..cf156e5 --- /dev/null +++ b/Tests/SimpleConf/TestConfigs/Parsing-MultipleEquals.conf @@ -0,0 +1,4 @@ +# A simple config +that appears = correct +but it = has = an invalid line # Should blow up here +another=pair diff --git a/Tests/SimpleConf/TestConfigs/Parsing-OnlyKey.conf b/Tests/SimpleConf/TestConfigs/Parsing-OnlyKey.conf new file mode 100644 index 0000000..617ab76 --- /dev/null +++ b/Tests/SimpleConf/TestConfigs/Parsing-OnlyKey.conf @@ -0,0 +1,10 @@ +# A simple config file that contains only the key on a single line + +normal_key=normal_value +another_key=another_value +generic=generic +another_one=another_value +a_very_basic_key +another_one=... + +# It doesn't really matter what's here, because the parser won't reach it diff --git a/Tests/SimpleConf/WhitespaceBehaviour.cpp b/Tests/SimpleConf/WhitespaceBehaviour.cpp new file mode 100644 index 0000000..a5508bd --- /dev/null +++ b/Tests/SimpleConf/WhitespaceBehaviour.cpp @@ -0,0 +1,53 @@ +#include +#include + +TEST_CASE("Check behavior when whitespace is involved") +{ + SECTION("Simple multi-line config") + { + using Required = Garbage::SimpleConf::Required; + + std::string rawConfig = + " key with whitespace = and value too \n" + "another=one\n" + " and yet = another"; + + Garbage::SimpleConf config(std::move(rawConfig)); + + REQUIRE(config.Get("key with whitespace") == "and value too"); + REQUIRE(config.Get("another") == "one"); + REQUIRE(config.Get("and yet") == "another"); + } + + SECTION("Deal with CRLF correctly") + { + using Required = Garbage::SimpleConf::Required; + + std::string rawConfig = + " simple key=simple value \r\n" + " another = one\r\n" + " and one = more\r\n"; + + Garbage::SimpleConf config(std::move(rawConfig)); + + REQUIRE(config.Get("simple key") == "simple value"); + REQUIRE(config.Get("another") == "one"); + REQUIRE(config.Get("and one") == "more"); + } + + SECTION("Weirder whitespace shenanigans") + { + using Required = Garbage::SimpleConf::Required; + + std::string rawConfig = + "let's+start_simple=with some\tbasics\n" + "\t\tnow we get \tsome=\t \tweirder\tstuff\r\n" + "\t\tinsa+nity\twith\t\t=\t white \t space \t\t"; + + Garbage::SimpleConf config(std::move(rawConfig)); + + REQUIRE(config.Get("let's+start_simple") == "with some\tbasics"); + REQUIRE(config.Get("now we get \tsome") == "weirder\tstuff"); + REQUIRE(config.Get("insa+nity\twith") == "white \t space"); + } +}