From: Joel Rosdahl Date: Sat, 18 Oct 2025 18:55:20 +0000 (+0200) Subject: feat: Add support for multi-line configuration values X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=3e5af6ea87557b29fe0382f3cf0f7db6fda3adeb;p=thirdparty%2Fccache.git feat: Add support for multi-line configuration values --- diff --git a/doc/manual.adoc b/doc/manual.adoc index c75d86ef..28500cda 100644 --- a/doc/manual.adoc +++ b/doc/manual.adoc @@ -406,6 +406,33 @@ whitespace surrounding keys and values. Example: max_size = 10GB ------------------------------------------------------------------------------- +==== Multi-line values + +Values can span multiple lines using indentation-based continuation. Lines that +start with whitespace (spaces or tabs) are treated as continuation lines and are +joined to the previous value with a single space. Comments and blank lines +within a multi-line value are skipped. For example: + +------------------------------------------------------------------------------- +ignore_options = + -Wall + -Wextra + # This is a comment within the value + -pedantic +# This ends the multi-line value +compiler = gcc +------------------------------------------------------------------------------- + +This is equivalent to: + +------------------------------------------------------------------------------- +ignore_options = -Wall -Wextra -pedantic +compiler = gcc +------------------------------------------------------------------------------- + +Multi-line values are particularly useful for options that accept multiple +items. + === Boolean values Some configuration options are boolean values (i.e. truth values). In a @@ -964,19 +991,23 @@ temporary files otherwise. You may also want to set <> to [#config_remote_storage] *remote_storage* (*CCACHE_REMOTE_STORAGE*):: - This option specifies one or several storage backends (separated by space) - to query after checking the local cache (unless + This option specifies one or several storage backends (separated by + whitespace) to query after checking the local cache (unless <> is true). See _<>_ for documentation of syntax and available backends. + Examples: + -* `+file:/shared/nfs/directory+` -* `+file:///shared/nfs/one|read-only file:///shared/nfs/two+` -* `+file:///Z:/example/windows/folder+` -* `+http://example.com/cache+` -* `+redis://example.com+` +---- +remote_storage = file:/shared/nfs/directory+` +remote_storage = + file:///shared/nfs/one|read-only + file:///shared/nfs/two +remote_storage = file:///Z:/example/windows/folder +remote_storage = http://example.com/cache +remote_storage = redis://example.com +---- + NOTE: In previous ccache versions this option was called *secondary_storage* (*CCACHE_SECONDARY_STORAGE*), which can still be used as an alias. diff --git a/src/ccache/config.cpp b/src/ccache/config.cpp index e4d9bf26..dcf7c17d 100644 --- a/src/ccache/config.cpp +++ b/src/ccache/config.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -124,11 +125,11 @@ enum class ConfigKeyType : uint8_t { normal, alias }; struct ConfigKeyTableEntry { ConfigItem item; - std::optional alias = std::nullopt; + std::optional alias = std::nullopt; }; -const std::unordered_map k_config_key_table = - { +const std::unordered_map + k_config_key_table = { {"absolute_paths_in_stderr", {ConfigItem::absolute_paths_in_stderr} }, {"base_dir", {ConfigItem::base_dir} }, {"cache_dir", {ConfigItem::cache_dir} }, @@ -176,53 +177,54 @@ const std::unordered_map k_config_key_table = {"umask", {ConfigItem::umask} }, }; -const std::unordered_map k_env_variable_table = { - {"ABSSTDERR", "absolute_paths_in_stderr" }, - {"BASEDIR", "base_dir" }, - {"CC", "compiler" }, // Alias for CCACHE_COMPILER - {"COMMENTS", "keep_comments_cpp" }, - {"COMPILER", "compiler" }, - {"COMPILERCHECK", "compiler_check" }, - {"COMPILERTYPE", "compiler_type" }, - {"COMPRESS", "compression" }, - {"COMPRESSLEVEL", "compression_level" }, - {"DEBUG", "debug" }, - {"DEBUGDIR", "debug_dir" }, - {"DEBUGLEVEL", "debug_level" }, - {"DEPEND", "depend_mode" }, - {"DIR", "cache_dir" }, - {"DIRECT", "direct_mode" }, - {"DISABLE", "disable" }, - {"EXTENSION", "cpp_extension" }, - {"EXTRAFILES", "extra_files_to_hash" }, - {"FILECLONE", "file_clone" }, - {"HARDLINK", "hard_link" }, - {"HASHDIR", "hash_dir" }, - {"IGNOREHEADERS", "ignore_headers_in_manifest"}, - {"IGNOREOPTIONS", "ignore_options" }, - {"INODECACHE", "inode_cache" }, - {"LOGFILE", "log_file" }, - {"MAXFILES", "max_files" }, - {"MAXSIZE", "max_size" }, - {"MSVC_DEP_PREFIX", "msvc_dep_prefix" }, - {"NAMESPACE", "namespace" }, - {"PATH", "path" }, - {"PCH_EXTSUM", "pch_external_checksum" }, - {"PREFIX", "prefix_command" }, - {"PREFIX_CPP", "prefix_command_cpp" }, - {"READONLY", "read_only" }, - {"READONLY_DIRECT", "read_only_direct" }, - {"RECACHE", "recache" }, - {"REMOTE_ONLY", "remote_only" }, - {"REMOTE_STORAGE", "remote_storage" }, - {"RESHARE", "reshare" }, - {"RESPONSE_FILE_FORMAT", "response_file_format" }, - {"SECONDARY_STORAGE", "remote_storage" }, // Alias for CCACHE_REMOTE_STORAGE - {"SLOPPINESS", "sloppiness" }, - {"STATS", "stats" }, - {"STATSLOG", "stats_log" }, - {"TEMPDIR", "temporary_dir" }, - {"UMASK", "umask" }, +const std::unordered_map + k_env_variable_table = { + {"ABSSTDERR", "absolute_paths_in_stderr" }, + {"BASEDIR", "base_dir" }, + {"CC", "compiler" }, // Alias for CCACHE_COMPILER + {"COMMENTS", "keep_comments_cpp" }, + {"COMPILER", "compiler" }, + {"COMPILERCHECK", "compiler_check" }, + {"COMPILERTYPE", "compiler_type" }, + {"COMPRESS", "compression" }, + {"COMPRESSLEVEL", "compression_level" }, + {"DEBUG", "debug" }, + {"DEBUGDIR", "debug_dir" }, + {"DEBUGLEVEL", "debug_level" }, + {"DEPEND", "depend_mode" }, + {"DIR", "cache_dir" }, + {"DIRECT", "direct_mode" }, + {"DISABLE", "disable" }, + {"EXTENSION", "cpp_extension" }, + {"EXTRAFILES", "extra_files_to_hash" }, + {"FILECLONE", "file_clone" }, + {"HARDLINK", "hard_link" }, + {"HASHDIR", "hash_dir" }, + {"IGNOREHEADERS", "ignore_headers_in_manifest"}, + {"IGNOREOPTIONS", "ignore_options" }, + {"INODECACHE", "inode_cache" }, + {"LOGFILE", "log_file" }, + {"MAXFILES", "max_files" }, + {"MAXSIZE", "max_size" }, + {"MSVC_DEP_PREFIX", "msvc_dep_prefix" }, + {"NAMESPACE", "namespace" }, + {"PATH", "path" }, + {"PCH_EXTSUM", "pch_external_checksum" }, + {"PREFIX", "prefix_command" }, + {"PREFIX_CPP", "prefix_command_cpp" }, + {"READONLY", "read_only" }, + {"READONLY_DIRECT", "read_only_direct" }, + {"RECACHE", "recache" }, + {"REMOTE_ONLY", "remote_only" }, + {"REMOTE_STORAGE", "remote_storage" }, + {"RESHARE", "reshare" }, + {"RESPONSE_FILE_FORMAT", "response_file_format" }, + {"SECONDARY_STORAGE", "remote_storage" }, // Alias for CCACHE_REMOTE_STORAGE + {"SLOPPINESS", "sloppiness" }, + {"STATS", "stats" }, + {"STATSLOG", "stats_log" }, + {"TEMPDIR", "temporary_dir" }, + {"UMASK", "umask" }, }; util::Args::ResponseFileFormat @@ -438,42 +440,6 @@ parse_line(const std::string& line, return true; } -// `line` is the full configuration line excluding newline. `key` will be empty -// for comments and blank lines. `value` does not include newline. -using ConfigLineHandler = std::function; - -// Call `config_line_handler` for each line in `path`. -bool -parse_config_file(const fs::path& path, - const ConfigLineHandler& config_line_handler) -{ - std::ifstream file(util::pstr(path).c_str()); - if (!file) { - return false; - } - - std::string line; - - size_t line_number = 0; - while (std::getline(file, line)) { - ++line_number; - - try { - std::string key; - std::string value; - std::string error_message; - if (!parse_line(line, &key, &value, &error_message)) { - throw core::Error(error_message); - } - config_line_handler(line, key, value); - } catch (const core::Error& e) { - throw core::Error(FMT("{}:{}: {}", path, line_number, e.what())); - } - } - return true; -} - std::unordered_map create_cmdline_settings_map(const std::vector& settings) { @@ -727,12 +693,35 @@ Config::set_system_config_path(const fs::path& path) bool Config::update_from_file(const fs::path& path) { - return parse_config_file( - path, [&](const auto& /*line*/, const auto& key, const auto& value) { - if (!key.empty()) { - set_item(key, value, std::nullopt, false, util::pstr(path)); - } - }); + auto config_content = util::read_file(path); + if (!config_content) { + return false; + } + + util::ConfigReader reader(*config_content); + + while (true) { + auto item_result = reader.read_next_item(); + if (!item_result) { + throw core::Error(FMT("{}:{}: {}", + path, + item_result.error().line_number, + item_result.error().message)); + } + + auto& item = *item_result; + if (!item) { + break; // EOF + } + + try { + set_item(item->key, item->value, std::nullopt, false, util::pstr(path)); + } catch (const core::Error& e) { + throw core::Error(FMT("{}:{}: {}", path, item->line_number, e.what())); + } + } + + return true; } void @@ -960,26 +949,51 @@ Config::set_value_in_file(const std::string& path, FMT("failed to write to {}: ", resolved_path)); } - core::AtomicFile output(resolved_path, core::AtomicFile::Mode::text); - bool found = false; - - if (!parse_config_file( - path, - [&](const auto& c_line, const auto& c_key, const auto& /*c_value*/) { - if (c_key == key) { - output.write(FMT("{} = {}\n", key, value)); - found = true; - } else { - output.write(FMT("{}\n", c_line)); - } - })) { - throw core::Error(FMT("failed to open {}: {}", path, strerror(errno))); + auto config_content = util::read_file(path); + if (!config_content) { + throw core::Error(FMT("failed to read {}: {}", path, strerror(errno))); } - if (!found) { - output.write(FMT("{} = {}\n", key, value)); + std::string result; + result.reserve(config_content->size() + key.size() + 3 + value.size() + 1); + bool key_found = false; + + util::ConfigReader reader(*config_content); + while (true) { + auto item_result = reader.read_next_raw_item(); + if (!item_result) { + throw core::Error(FMT("failed to read {}: {}:{}", + path, + item_result.error().line_number, + item_result.error().message)); + } + + auto& item = *item_result; + if (!item) { + break; // EOF + } + + if (item->key == key) { + // Copy everything before this entry + result.append(*config_content, 0, item->value_start_pos); + result.append(value); + result.append(*config_content, + item->value_start_pos + item->value_length); + key_found = true; + break; + } } + if (!key_found) { + result = std::move(*config_content); + if (!result.empty() && result.back() != '\n') { + result += '\n'; + } + result += FMT("{} = {}\n", key, value); + } + + core::AtomicFile output(resolved_path, core::AtomicFile::Mode::text); + output.write(result); output.commit(); } @@ -1003,7 +1017,7 @@ Config::visit_items(const ItemVisitor& item_visitor) const } void -Config::set_item(const std::string& key, +Config::set_item(const std::string_view& key, const std::string& unexpanded_value, const std::optional& env_var_key, bool negate, @@ -1207,7 +1221,7 @@ Config::set_item(const std::string& key, break; } - const std::string& canonical_key = it->second.alias.value_or(key); + const std::string_view& canonical_key = it->second.alias.value_or(key); const auto& [element, inserted] = m_origins.emplace(canonical_key, origin); if (!inserted) { element->second = origin; diff --git a/src/ccache/config.hpp b/src/ccache/config.hpp index f1f09bf0..83cd77ec 100644 --- a/src/ccache/config.hpp +++ b/src/ccache/config.hpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include @@ -228,7 +229,7 @@ private: std::unordered_map m_origins; - void set_item(const std::string& key, + void set_item(const std::string_view& key, const std::string& unexpanded_value, const std::optional& env_var_key, bool negate, diff --git a/src/ccache/util/CMakeLists.txt b/src/ccache/util/CMakeLists.txt index f4a03656..7fe6c1fb 100644 --- a/src/ccache/util/CMakeLists.txt +++ b/src/ccache/util/CMakeLists.txt @@ -4,6 +4,7 @@ set( assertions.cpp bytes.cpp clang.cpp + configreader.cpp cpu.cpp direntry.cpp environment.cpp diff --git a/src/ccache/util/configreader.cpp b/src/ccache/util/configreader.cpp new file mode 100644 index 00000000..71cc0414 --- /dev/null +++ b/src/ccache/util/configreader.cpp @@ -0,0 +1,163 @@ +// Copyright (C) 2025 Joel Rosdahl and other contributors +// +// See doc/AUTHORS.adoc for a complete list of contributors. +// +// 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, write to the Free Software Foundation, Inc., 51 +// Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +#include "configreader.hpp" + +#include + +#include + +namespace util { + +namespace { + +bool +is_comment_or_blank(std::string_view line) +{ + std::string stripped = util::strip_whitespace(line); + return stripped.empty() || stripped[0] == '#'; +} + +} // namespace + +ConfigReader::ConfigReader(std::string_view config) + : m_config(config), + m_lines(split_into_views(config, "\n", Tokenizer::Mode::include_empty)) +{ + // First pass: find all keys + for (size_t i = 0; i < m_lines.size(); ++i) { + std::string_view line = m_lines[i]; + + if (!is_comment_or_blank(line)) { + // Non-comment, non-blank line found + if (!line.empty() && util::is_space(line[0])) { + // Indented non-comment line without a preceding key + if (m_items.empty()) { + m_error = Error{i + 1, "indented key"}; + return; + } + } else { + // This should be a key=value line + size_t eq_pos = line.find('='); + if (eq_pos == std::string_view::npos) { + // Non-indented line without "=" is an error + m_error = Error{i + 1, "missing equal sign"}; + return; + } + + std::string_view key = line.substr(0, eq_pos); + while (!key.empty() && util::is_space(key.back())) { + key.remove_suffix(1); + } + + size_t line_start = static_cast(line.data() - m_config.data()); + size_t value_start = line_start + eq_pos + 1; + while (value_start < m_config.size() + && util::is_space(m_config[value_start])) { + ++value_start; + } + + m_items.push_back(RawItem{i + 1, key, value_start, 0}); + } + } + } + + // Second pass: compute value lengths for each key + for (size_t i = 0; i < m_items.size(); ++i) { + auto& item = m_items[i]; + size_t line_index = item.line_number - 1; // Convert to 0-based + + // Determine the end line for this item's value + size_t search_end_line = (i + 1 < m_items.size()) + ? (m_items[i + 1].line_number - 1) + : m_lines.size(); + + // Backtrack from search_end_line to skip trailing comments and blank lines + size_t value_end_line = search_end_line; + while (value_end_line > line_index + 1) { + if (!is_comment_or_blank(m_lines[value_end_line - 1])) { + // Found the last content line + break; + } + --value_end_line; + } + + std::string_view last_line = m_lines[value_end_line - 1]; + size_t last_line_start = + static_cast(last_line.data() - m_config.data()); + // If the line ends with CR (as in CRLF files split on '\n'), treat the + // trailing CR as part of the newline and exclude it from the value end. + size_t last_line_size = last_line.size(); + if (last_line_size > 0 && last_line.back() == '\r') { + --last_line_size; + } + size_t value_end = last_line_start + last_line_size; + + item.value_length = value_end - item.value_start_pos; + } +} + +tl::expected, ConfigReader::Error> +ConfigReader::read_next_raw_item() +{ + if (m_error) { + return tl::make_unexpected(*m_error); + } + + if (m_current_item >= m_items.size()) { + return std::nullopt; // EOF + } + + return m_items[m_current_item++]; +} + +tl::expected, ConfigReader::Error> +ConfigReader::read_next_item() +{ + auto raw_item_result = read_next_raw_item(); + if (!raw_item_result) { + return tl::make_unexpected(raw_item_result.error()); + } + + auto& raw_item = *raw_item_result; + if (!raw_item) { + return std::nullopt; + } + + std::string_view raw_value = + m_config.substr(raw_item->value_start_pos, raw_item->value_length); + + // Normalize the value: skip comments/blanks, join continuation lines + std::string normalized_value; + auto value_lines = + split_into_views(raw_value, "\n", Tokenizer::Mode::include_empty); + + for (auto line : value_lines) { + std::string stripped = util::strip_whitespace(line); + if (!stripped.empty() && stripped[0] != '#') { + if (!normalized_value.empty()) { + normalized_value += ' '; + } + normalized_value += stripped; + } + } + + return Item{raw_item->line_number, raw_item->key, normalized_value}; +} + +} // namespace util diff --git a/src/ccache/util/configreader.hpp b/src/ccache/util/configreader.hpp new file mode 100644 index 00000000..b8149103 --- /dev/null +++ b/src/ccache/util/configreader.hpp @@ -0,0 +1,83 @@ +// Copyright (C) 2025 Joel Rosdahl and other contributors +// +// See doc/AUTHORS.adoc for a complete list of contributors. +// +// 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, write to the Free Software Foundation, Inc., 51 +// Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace util { + +// Reader for configuration files with support for indentation-based line +// continuation. +// +// Continuation rules: +// +// 1. Indented lines (start with whitespace) continue the previous value. +// 2. Comments (start with #) and blank lines are skipped during continuation. +// 3. Non-indented non-comment lines start new entries. +// 4. Continuation lines are stripped and joined with single spaces. +class ConfigReader +{ +public: + struct Error + { + size_t line_number; + std::string message; + }; + + struct RawItem + { + size_t line_number; + std::string_view key; + size_t value_start_pos; // Position in m_config where value starts + size_t value_length; + }; + + struct Item + { + size_t line_number; + std::string_view key; + std::string value; // Normalized: comments/blanks removed, lines joined + }; + + explicit ConfigReader(std::string_view config); + + // Read the next configuration item in raw form. Returns std::nullopt at EOF. + tl::expected, Error> read_next_raw_item(); + + // Read the next configuration item with normalized value. Returns + // std::nullopt at EOF. + tl::expected, Error> read_next_item(); + +private: + std::string_view m_config; + std::vector m_lines; + std::vector m_items; + std::optional m_error; + size_t m_current_item = 0; +}; + +} // namespace util diff --git a/unittest/CMakeLists.txt b/unittest/CMakeLists.txt index f333b3d9..524304fb 100644 --- a/unittest/CMakeLists.txt +++ b/unittest/CMakeLists.txt @@ -21,6 +21,7 @@ set( test_util_bitset.cpp test_util_bytes.cpp test_util_clang.cpp + test_util_configreader.cpp test_util_conversion.cpp test_util_direntry.cpp test_util_environment.cpp diff --git a/unittest/test_config.cpp b/unittest/test_config.cpp index 8e2de030..572966d9 100644 --- a/unittest/test_config.cpp +++ b/unittest/test_config.cpp @@ -109,8 +109,8 @@ TEST_CASE("Config::update_from_file") "cache_dir = $USER$/${USER}/.ccache\n" "\n" "\n" - " #A comment\n" - "\t compiler = foo\n" + "#A comment\n" + "compiler = foo\n" "compiler_check = none\n" "compiler_type = nvcc\n" "compression=false\n" @@ -295,6 +295,119 @@ TEST_CASE("Config::update_from_file, error handling") } } +TEST_CASE("Config::update_from_file, multi-line values") +{ + TestContext test_context; + + Config config; + + SUBCASE("basic continuation") + { + REQUIRE(util::write_file("ccache.conf", "path = a\n b\n c")); + CHECK(config.update_from_file("ccache.conf")); + CHECK(config.path() == "a b c"); + } + + SUBCASE("indented continuation with empty value on first line") + { + REQUIRE(util::write_file("ccache.conf", "path =\n b\n c")); + CHECK(config.update_from_file("ccache.conf")); + CHECK(config.path() == "b c"); + } + + SUBCASE("comments are not part of continuation") + { + REQUIRE(util::write_file("ccache.conf", + "path = a\n" + " b\n" + "# comment\n" + "compiler = c")); + CHECK(config.update_from_file("ccache.conf")); + CHECK(config.path() == "a b"); + CHECK(config.compiler() == "c"); + } + + SUBCASE("comments and empty lines don't break continuation") + { + REQUIRE(util::write_file("ccache.conf", + "path = a\n" + " b\n" + "\n" + "# comment\n" + " compiler = c")); + CHECK(config.update_from_file("ccache.conf")); + CHECK(config.path() == "a b compiler = c"); + CHECK(config.compiler() == ""); + } + + SUBCASE("blank lines are not part of continuation") + { + REQUIRE(util::write_file("ccache.conf", + "path = a\n" + " b\n" + "\n" + "compiler = c")); + CHECK(config.update_from_file("ccache.conf")); + CHECK(config.path() == "a b"); + CHECK(config.compiler() == "c"); + } + + SUBCASE("hash after value does not mean comment") + { + REQUIRE(util::write_file("ccache.conf", + "ignore_options =\n" + " -Wall\n" + " zzz = b\n" + " b # not a comment\n" + " c")); + CHECK(config.update_from_file("ccache.conf")); + CHECK(config.ignore_options() == "-Wall zzz = b b # not a comment c"); + } + + SUBCASE("multiple indented multi-line values") + { + REQUIRE(util::write_file("ccache.conf", + "path = /usr/bin\n" + " /usr/local/bin\n" + "compiler = gcc\n" + "ignore_options = -Wall\n" + " -Wextra\n" + " -pedantic")); + CHECK(config.update_from_file("ccache.conf")); + CHECK(config.path() == "/usr/bin /usr/local/bin"); + CHECK(config.compiler() == "gcc"); + CHECK(config.ignore_options() == "-Wall -Wextra -pedantic"); + } + + SUBCASE("both indented and non-indented comments are skipped") + { + REQUIRE(util::write_file("ccache.conf", + "path =\n" + " a\n" + " b # not a comment\n" + "\n" + "# nonindented comment\n" + " # indented comment\n" + " c = d")); + CHECK(config.update_from_file("ccache.conf")); + CHECK(config.path() == "a b # not a comment c = d"); + } + + SUBCASE("tab indentation also works") + { + REQUIRE(util::write_file("ccache.conf", "path = a\n\tb\n\tc")); + CHECK(config.update_from_file("ccache.conf")); + CHECK(config.path() == "a b c"); + } + + SUBCASE("mixed spaces and tabs") + { + REQUIRE(util::write_file("ccache.conf", "path = a\n b\n\tc")); + CHECK(config.update_from_file("ccache.conf")); + CHECK(config.path() == "a b c"); + } +} + TEST_CASE("Config::update_from_environment") { Config config; @@ -411,6 +524,37 @@ TEST_CASE("Config::set_value_in_file") std::string content = *util::read_file("ccache.conf"); CHECK(content == "# c1\npath = vanilla\n#c2\ncompiler = chocolate\n"); } + + SUBCASE("comments in multi-line values are kept") + { + REQUIRE(util::write_file("ccache.conf", + "ignore_options = -Wall\n" + " -Wextra\n" + "# A comment\n" + " -pedantic\n" + "compiler = gcc\n")); + config.set_value_in_file("ccache.conf", "compiler", "clang"); + std::string content = *util::read_file("ccache.conf"); + CHECK(content == "ignore_options = -Wall\n" + " -Wextra\n" + "# A comment\n" + " -pedantic\n" + "compiler = clang\n"); + } + + SUBCASE("possible to replace multi-line value") + { + REQUIRE(util::write_file("ccache.conf", + "ignore_options = -Wall\n" + " -Wextra\n" + "# A comment\n" + " -pedantic\n" + "compiler = gcc\n")); + config.set_value_in_file("ccache.conf", "ignore_options", "-Weverything"); + std::string content = *util::read_file("ccache.conf"); + CHECK(content == "ignore_options = -Weverything\n" + "compiler = gcc\n"); + } } TEST_CASE("Config::get_string_value") diff --git a/unittest/test_util_configreader.cpp b/unittest/test_util_configreader.cpp new file mode 100644 index 00000000..65b62c0e --- /dev/null +++ b/unittest/test_util_configreader.cpp @@ -0,0 +1,290 @@ +// Copyright (C) 2025 Joel Rosdahl and other contributors +// +// See doc/AUTHORS.adoc for a complete list of contributors. +// +// 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, write to the Free Software Foundation, Inc., 51 +// Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +#include + +#include + +#include // https://github.com/doctest/doctest/issues/618 + +using util::ConfigReader; + +TEST_SUITE("util::ConfigReader") +{ + TEST_CASE("missing equal sign") + { + ConfigReader reader("key"); + auto result = reader.read_next_item(); + REQUIRE(!result); + CHECK(result.error().line_number == 1); + CHECK(result.error().message == "missing equal sign"); + } + + TEST_CASE("indented key") + { + ConfigReader reader(" key = value"); + auto result = reader.read_next_item(); + REQUIRE(!result); + CHECK(result.error().line_number == 1); + CHECK(result.error().message == "indented key"); + } + + TEST_CASE("read_next_item: simple key=value") + { + ConfigReader reader("key = value"); + auto result = reader.read_next_item(); + REQUIRE(result); + auto& item = **result; + CHECK(item.line_number == 1); + CHECK(item.key == "key"); + CHECK(item.value == "value"); + auto eof = reader.read_next_item(); + REQUIRE(eof); + CHECK(!*eof); + } + + TEST_CASE("read_next_item: multiple items") + { + ConfigReader reader("key1 = value1 \nkey2=value2\n"); + auto result1 = reader.read_next_item(); + REQUIRE(result1); + auto& item1 = **result1; + CHECK(item1.key == "key1"); + CHECK(item1.value == "value1"); + + auto result2 = reader.read_next_item(); + REQUIRE(result2); + auto& item2 = **result2; + CHECK(item2.key == "key2"); + CHECK(item2.value == "value2"); + + auto eof = reader.read_next_item(); + REQUIRE(eof); + CHECK(!*eof); + } + + TEST_CASE("read_next_item: indented continuation") + { + ConfigReader reader("key = a\n b\n c"); + auto result = reader.read_next_item(); + REQUIRE(result); + auto& item = **result; + CHECK(item.key == "key"); + CHECK(item.value == "a b c"); + } + + TEST_CASE("read_next_item: empty value on first line") + { + ConfigReader reader("key =\n b\n c"); + auto result = reader.read_next_item(); + REQUIRE(result); + auto& item = **result; + CHECK(item.key == "key"); + CHECK(item.value == "b c"); + } + + TEST_CASE("read_next_item: comments are skipped") + { + ConfigReader reader("key = a\n b\n# comment\n c"); + auto result = reader.read_next_item(); + REQUIRE(result); + auto& item = **result; + CHECK(item.value == "a b c"); + } + + TEST_CASE("read_next_item: blank lines are skipped") + { + ConfigReader reader("key = a\n b\n\n c"); + auto result = reader.read_next_item(); + REQUIRE(result); + auto& item = **result; + CHECK(item.value == "a b c"); + } + + TEST_CASE("read_next_item: inline comments preserved") + { + ConfigReader reader("key = a # not a comment"); + auto result = reader.read_next_item(); + REQUIRE(result); + auto& item = **result; + CHECK(item.value == "a # not a comment"); + } + + TEST_CASE("read_next_item: different comments") + { + ConfigReader reader( + "world =\n" + " a\n" + " b # not a comment\n" + "\n" + "# nonindented comment\n" + " # indented comment\n" + " c = d"); + auto result = reader.read_next_item(); + REQUIRE(result); + auto& item = **result; + CHECK(item.key == "world"); + CHECK(item.value == "a b # not a comment c = d"); + } + + TEST_CASE("read_next_item: leading comments are skipped") + { + ConfigReader reader("# comment\nkey = value"); + auto result = reader.read_next_item(); + REQUIRE(result); + auto& item = **result; + CHECK(item.line_number == 2); + CHECK(item.key == "key"); + } + + TEST_CASE("read_next_item: tab indentation") + { + ConfigReader reader("key = a\n\tb\n\tc"); + auto result = reader.read_next_item(); + REQUIRE(result); + auto& item = **result; + CHECK(item.value == "a b c"); + } + + TEST_CASE("read_next_item: empty config") + { + ConfigReader reader(""); + auto result = reader.read_next_item(); + REQUIRE(result); + CHECK(!*result); + } + + TEST_CASE("read_next_item: only comments") + { + ConfigReader reader("# comment1\n# comment2"); + auto result = reader.read_next_item(); + REQUIRE(result); + CHECK(!*result); + } + + TEST_CASE("read_next_item: CRLF line endings") + { + // Use CRLF line endings as might appear on Windows files + ConfigReader reader("key = a\r\n b\r\n# comment\r\n c\r\nother = x\r\n"); + auto result1 = reader.read_next_item(); + REQUIRE(result1); + auto& item1 = **result1; + CHECK(item1.key == "key"); + CHECK(item1.value == "a b c"); + + auto result2 = reader.read_next_item(); + REQUIRE(result2); + auto& item2 = **result2; + CHECK(item2.key == "other"); + CHECK(item2.value == "x"); + } + + TEST_CASE("read_next_raw_item: simple key=value") + { + ConfigReader reader("key = value"); + auto result = reader.read_next_raw_item(); + REQUIRE(result); + auto& item = **result; + CHECK(item.key == "key"); + CHECK(item.value_start_pos == 6); // After "key = " + CHECK(item.value_length == 5); // Length of "value" + } + + TEST_CASE("read_next_raw_item: preserves embedded comments and blank lines") + { + std::string config = "key = a\n b\n\n# comment\n \n c\nother = x"; + ConfigReader reader(config); + + auto result1 = reader.read_next_raw_item(); + REQUIRE(result1); + auto& item1 = **result1; + CHECK(item1.key == "key"); + // Raw value should include "a\n b\n\n# comment\n \n c" (no trailing + // newline) + std::string raw_value = + config.substr(item1.value_start_pos, item1.value_length); + CHECK(raw_value == "a\n b\n\n# comment\n \n c"); + + auto result2 = reader.read_next_raw_item(); + REQUIRE(result2); + auto& item2 = **result2; + CHECK(item2.key == "other"); + } + + TEST_CASE("read_next_raw_item: multiple items") + { + std::string config = "key1 = value1\nkey2 = value2"; + ConfigReader reader(config); + + auto result1 = reader.read_next_raw_item(); + REQUIRE(result1); + auto& item1 = **result1; + CHECK(item1.key == "key1"); + std::string value1 = + config.substr(item1.value_start_pos, item1.value_length); + CHECK(value1 == "value1"); + + auto result2 = reader.read_next_raw_item(); + REQUIRE(result2); + auto& item2 = **result2; + CHECK(item2.key == "key2"); + std::string value2 = + config.substr(item2.value_start_pos, item2.value_length); + CHECK(value2 == "value2"); + } + + TEST_CASE("read_next_raw_item: EOF handling") + { + ConfigReader reader("key = value"); + auto result = reader.read_next_raw_item(); + REQUIRE(result); + REQUIRE(*result); + auto eof = reader.read_next_raw_item(); + REQUIRE(eof); + CHECK(!*eof); + } + + TEST_CASE("read_next_raw_item: CRLF preserves raw blocks") + { + std::string config = + "key = a\r\n" + " b\r\n" + "\r\n" + "# comment\r\n" + " c\r\n" + "other = x\r\n"; + + ConfigReader reader(config); + auto res = reader.read_next_raw_item(); + REQUIRE(res); + auto& raw_item = **res; + CHECK(raw_item.key == "key"); + + // Extract raw substring and verify it preserves embedded blank/comment + // lines (without the trailing newline) + std::string raw_value = + config.substr(raw_item.value_start_pos, raw_item.value_length); + + CHECK(raw_value == "a\r\n b\r\n\r\n# comment\r\n c"); + + auto res2 = reader.read_next_raw_item(); + REQUIRE(res2); + auto& raw_item2 = **res2; + CHECK(raw_item2.key == "other"); + } +}