]> git.ipfire.org Git - thirdparty/ccache.git/commitdiff
feat: Add support for multi-line configuration values
authorJoel Rosdahl <joel@rosdahl.net>
Sat, 18 Oct 2025 18:55:20 +0000 (20:55 +0200)
committerJoel Rosdahl <joel@rosdahl.net>
Sat, 25 Oct 2025 10:03:24 +0000 (12:03 +0200)
doc/manual.adoc
src/ccache/config.cpp
src/ccache/config.hpp
src/ccache/util/CMakeLists.txt
src/ccache/util/configreader.cpp [new file with mode: 0644]
src/ccache/util/configreader.hpp [new file with mode: 0644]
unittest/CMakeLists.txt
unittest/test_config.cpp
unittest/test_util_configreader.cpp [new file with mode: 0644]

index c75d86efd900d5f959f97123b4ab4d44aa72af73..28500cda418d284e014c947908f115eba7a1424e 100644 (file)
@@ -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 <<config_stats,*stats*>> 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
     <<config_remote_only,*remote_only*>> is true). See
     _<<Remote storage backends>>_ 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.
index e4d9bf268d2b29549fd4eab6dd3e52a082b0ca65..dcf7c17d71b9cc616c7ec4b078cd388445705ff5 100644 (file)
@@ -23,6 +23,7 @@
 #include <ccache/core/exceptions.hpp>
 #include <ccache/core/sloppiness.hpp>
 #include <ccache/util/assertions.hpp>
+#include <ccache/util/configreader.hpp>
 #include <ccache/util/direntry.hpp>
 #include <ccache/util/environment.hpp>
 #include <ccache/util/expected.hpp>
@@ -124,11 +125,11 @@ enum class ConfigKeyType : uint8_t { normal, alias };
 struct ConfigKeyTableEntry
 {
   ConfigItem item;
-  std::optional<std::string> alias = std::nullopt;
+  std::optional<std::string_view> alias = std::nullopt;
 };
 
-const std::unordered_map<std::string, ConfigKeyTableEntry> k_config_key_table =
-  {
+const std::unordered_map<std::string_view, ConfigKeyTableEntry>
+  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<std::string, ConfigKeyTableEntry> k_config_key_table =
     {"umask",                      {ConfigItem::umask}                           },
 };
 
-const std::unordered_map<std::string, std::string> 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<std::string_view, std::string_view>
+  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<void(
-  const std::string& line, const std::string& key, const std::string& value)>;
-
-// 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<std::string, std::string>
 create_cmdline_settings_map(const std::vector<std::string>& 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<std::string>(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<std::string>(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<std::string>& 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;
index f1f09bf091f640dc94f055f5fc5054dff3ef9fd7..83cd77ecef76f777a8b6b930ae74da287ba66929 100644 (file)
@@ -31,6 +31,7 @@
 #include <functional>
 #include <optional>
 #include <string>
+#include <string_view>
 #include <unordered_map>
 #include <vector>
 
@@ -228,7 +229,7 @@ private:
 
   std::unordered_map<std::string /*key*/, std::string /*origin*/> 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<std::string>& env_var_key,
                 bool negate,
index f4a03656ff53e2a81e424e88e7cf0ab1f65b7bf7..7fe6c1fb149cab598cfab8db99a351ba0fc8f9bf 100644 (file)
@@ -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 (file)
index 0000000..71cc041
--- /dev/null
@@ -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 <ccache/util/string.hpp>
+
+#include <cctype>
+
+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<size_t>(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<size_t>(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<std::optional<ConfigReader::RawItem>, 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<std::optional<ConfigReader::Item>, 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 (file)
index 0000000..b814910
--- /dev/null
@@ -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 <tl/expected.hpp>
+
+#include <cstddef>
+#include <functional>
+#include <map>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <vector>
+
+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<std::optional<RawItem>, Error> read_next_raw_item();
+
+  // Read the next configuration item with normalized value. Returns
+  // std::nullopt at EOF.
+  tl::expected<std::optional<Item>, Error> read_next_item();
+
+private:
+  std::string_view m_config;
+  std::vector<std::string_view> m_lines;
+  std::vector<RawItem> m_items;
+  std::optional<Error> m_error;
+  size_t m_current_item = 0;
+};
+
+} // namespace util
index f333b3d91762385ac9dba011bf99d9223e7465c1..524304fb11784a4e7360c2f52cd3307b826dcff6 100644 (file)
@@ -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
index 8e2de0302839a06b66d95a3115d75a5f1e6008fe..572966d971996bb7b95eb1130c5ed6d1338f39f4 100644 (file)
@@ -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<std::string>("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<std::string>("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<std::string>("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 (file)
index 0000000..65b62c0
--- /dev/null
@@ -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 <ccache/util/configreader.hpp>
+
+#include <doctest/doctest.h>
+
+#include <ostream> // 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");
+  }
+}