file.cpp
filelock.cpp
filesystem.cpp
+ json.cpp
lockfile.cpp
logging.cpp
longlivedlockfilemanager.cpp
--- /dev/null
+// 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 "json.hpp"
+
+#include <ccache/util/format.hpp>
+#include <ccache/util/string.hpp>
+
+namespace {
+
+struct ParseState
+{
+ std::string_view doc;
+ size_t pos;
+};
+
+void
+skip_whitespace(ParseState& state)
+{
+ while (state.pos < state.doc.size() && util::is_space(state.doc[state.pos])) {
+ ++state.pos;
+ }
+}
+
+tl::expected<std::string, std::string>
+parse_string(ParseState& state)
+{
+ if (state.pos >= state.doc.size() || state.doc[state.pos] != '"') {
+ return tl::unexpected("Expected string");
+ }
+ ++state.pos; // Skip opening '"'
+
+ std::string result;
+ while (state.pos < state.doc.size()) {
+ char ch = state.doc[state.pos];
+
+ if (ch == '"') {
+ ++state.pos; // Skip closing '"'
+ return result;
+ }
+
+ if (ch == '\\') {
+ ++state.pos;
+ if (state.pos >= state.doc.size()) {
+ return tl::unexpected("Unexpected end of string");
+ }
+
+ char escaped = state.doc[state.pos];
+ switch (escaped) {
+ case '"':
+ case '\\':
+ case '/':
+ result += escaped;
+ break;
+ case 'b':
+ result += '\b';
+ break;
+ case 'f':
+ result += '\f';
+ break;
+ case 'n':
+ result += '\n';
+ break;
+ case 'r':
+ result += '\r';
+ break;
+ case 't':
+ result += '\t';
+ break;
+ case 'u':
+ return tl::unexpected("\\uXXXX escape sequences are not supported");
+ default:
+ return tl::unexpected(FMT("Unknown escape sequence: \\{}", escaped));
+ }
+ ++state.pos;
+ } else {
+ result += ch;
+ ++state.pos;
+ }
+ }
+
+ return tl::unexpected("Unterminated string");
+}
+
+void
+skip_primitive(ParseState& state)
+{
+ // Skip numbers, true, false, null
+ while (state.pos < state.doc.size()) {
+ char ch = state.doc[state.pos];
+ if (util::is_space(ch) || ch == ',' || ch == '}' || ch == ']') {
+ break;
+ }
+ ++state.pos;
+ }
+}
+
+tl::expected<void, std::string>
+skip_array(ParseState& state)
+{
+ if (state.pos >= state.doc.size() || state.doc[state.pos] != '[') {
+ return tl::unexpected("Expected array");
+ }
+ ++state.pos; // Skip '['
+
+ int depth = 1;
+ while (state.pos < state.doc.size() && depth > 0) {
+ char ch = state.doc[state.pos];
+ if (ch == '"') {
+ auto str_result = parse_string(state); // Parse and discard
+ if (!str_result) {
+ return tl::unexpected(str_result.error());
+ }
+ } else if (ch == '[') {
+ ++depth;
+ ++state.pos;
+ } else if (ch == ']') {
+ --depth;
+ ++state.pos;
+ } else {
+ ++state.pos;
+ }
+ }
+
+ if (depth != 0) {
+ return tl::unexpected("Unterminated array");
+ }
+ return {};
+}
+
+tl::expected<void, std::string>
+skip_object(ParseState& state)
+{
+ if (state.pos >= state.doc.size() || state.doc[state.pos] != '{') {
+ return tl::unexpected("Expected object");
+ }
+ ++state.pos; // Skip '{'
+
+ int depth = 1;
+ while (state.pos < state.doc.size() && depth > 0) {
+ char ch = state.doc[state.pos];
+ if (ch == '"') {
+ auto str_result = parse_string(state); // Parse and discard
+ if (!str_result) {
+ return tl::unexpected(str_result.error());
+ }
+ } else if (ch == '{') {
+ ++depth;
+ ++state.pos;
+ } else if (ch == '}') {
+ --depth;
+ ++state.pos;
+ } else {
+ ++state.pos;
+ }
+ }
+
+ if (depth != 0) {
+ return tl::unexpected("Unterminated object");
+ }
+ return {};
+}
+
+tl::expected<void, std::string>
+skip_value(ParseState& state)
+{
+ if (state.pos >= state.doc.size()) {
+ return tl::unexpected("Unexpected end of document");
+ }
+
+ char ch = state.doc[state.pos];
+
+ if (ch == '"') {
+ auto str_result = parse_string(state); // Parse and discard
+ if (!str_result) {
+ return tl::unexpected(str_result.error());
+ }
+ } else if (ch == '{') {
+ auto obj_result = skip_object(state);
+ if (!obj_result) {
+ return tl::unexpected(obj_result.error());
+ }
+ } else if (ch == '[') {
+ auto arr_result = skip_array(state);
+ if (!arr_result) {
+ return tl::unexpected(arr_result.error());
+ }
+ } else if (ch == 't' || ch == 'f' || ch == 'n' || ch == '-'
+ || util::is_digit(ch)) {
+ skip_primitive(state);
+ } else {
+ return tl::unexpected(FMT("Unexpected character: '{}'", ch));
+ }
+ return {};
+}
+
+tl::expected<void, std::string>
+navigate_to_key(ParseState& state, std::string_view key)
+{
+ if (state.pos >= state.doc.size() || state.doc[state.pos] != '{') {
+ return tl::unexpected("Expected object");
+ }
+ ++state.pos; // Skip '{'
+
+ while (true) {
+ skip_whitespace(state);
+
+ if (state.pos >= state.doc.size()) {
+ return tl::unexpected(FMT("Key '{}' not found", key));
+ }
+
+ if (state.doc[state.pos] == '}') {
+ return tl::unexpected(FMT("Key '{}' not found", key));
+ }
+
+ if (state.doc[state.pos] != '"') {
+ return tl::unexpected("Expected string key");
+ }
+ auto current_key_result = parse_string(state);
+ if (!current_key_result) {
+ return tl::unexpected(current_key_result.error());
+ }
+
+ skip_whitespace(state);
+ if (state.pos >= state.doc.size() || state.doc[state.pos] != ':') {
+ return tl::unexpected("Expected ':' after key");
+ }
+ ++state.pos; // Skip ':'
+
+ skip_whitespace(state);
+
+ if (*current_key_result == key) {
+ return {}; // Found the key, state.pos is now at the value
+ }
+
+ auto skip_result = skip_value(state);
+ if (!skip_result) {
+ return tl::unexpected(skip_result.error());
+ }
+
+ skip_whitespace(state);
+ if (state.pos < state.doc.size() && state.doc[state.pos] == ',') {
+ ++state.pos; // Skip comma
+ }
+ }
+}
+
+tl::expected<std::vector<std::string>, std::string>
+parse_string_array(ParseState& state)
+{
+ if (state.pos >= state.doc.size() || state.doc[state.pos] != '[') {
+ return tl::unexpected("Expected array");
+ }
+ ++state.pos; // Skip '['
+
+ std::vector<std::string> result;
+
+ while (true) {
+ skip_whitespace(state);
+
+ if (state.pos >= state.doc.size()) {
+ return tl::unexpected("Unterminated array");
+ }
+
+ if (state.doc[state.pos] == ']') {
+ ++state.pos; // Skip ']'
+ return result;
+ }
+
+ if (state.doc[state.pos] != '"') {
+ return tl::unexpected("Expected string in array");
+ }
+
+ auto str_result = parse_string(state);
+ if (!str_result) {
+ return tl::unexpected(str_result.error());
+ }
+ result.push_back(*str_result);
+
+ skip_whitespace(state);
+
+ if (state.pos >= state.doc.size()) {
+ return tl::unexpected("Unterminated array");
+ }
+
+ if (state.doc[state.pos] == ',') {
+ ++state.pos; // Skip comma
+ } else if (state.doc[state.pos] != ']') {
+ return tl::unexpected("Expected ',' or ']' in array");
+ }
+ }
+}
+
+} // namespace
+
+namespace util {
+
+SimpleJsonParser::SimpleJsonParser(std::string_view document)
+ : m_document(document)
+{
+}
+
+tl::expected<std::vector<std::string>, std::string>
+SimpleJsonParser::get_string_array(std::string_view filter) const
+{
+ if (filter.empty() || filter[0] != '.') {
+ return tl::unexpected("Invalid filter: must start with '.'");
+ }
+
+ // Parse filter path, e.g. ".Data.Includes" -> ["Data", "Includes"].
+ auto path = split_into_views(filter.substr(1), ".");
+ if (path.empty()) {
+ return tl::unexpected("Empty filter path");
+ }
+
+ ParseState state{m_document, 0};
+ skip_whitespace(state);
+
+ if (state.pos >= state.doc.size() || state.doc[state.pos] != '{') {
+ return tl::unexpected("Expected object at root");
+ }
+
+ // Navigate through nested objects.
+ for (size_t i = 0; i < path.size() - 1; ++i) {
+ auto nav_result = navigate_to_key(state, path[i]);
+ if (!nav_result) {
+ return tl::unexpected(nav_result.error());
+ }
+ skip_whitespace(state);
+ if (state.pos >= state.doc.size() || state.doc[state.pos] != '{') {
+ return tl::unexpected(FMT("Expected object for key '{}'", path[i]));
+ }
+ }
+
+ // Navigate to the final key which should contain an array.
+ auto nav_result = navigate_to_key(state, path.back());
+ if (!nav_result) {
+ return tl::unexpected(nav_result.error());
+ }
+ skip_whitespace(state);
+
+ if (state.pos >= state.doc.size() || state.doc[state.pos] != '[') {
+ return tl::unexpected(FMT("Expected array for key '{}'", path.back()));
+ }
+
+ return parse_string_array(state);
+}
+
+} // namespace util
--- /dev/null
+// 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 <string>
+#include <string_view>
+#include <vector>
+
+namespace util {
+
+// Simple JSON parser that is tailored for parsing MSVC's /sourceDependencies
+// files.
+//
+// Does not support \uXXXX escapes and lots of other things.
+class SimpleJsonParser
+{
+public:
+ explicit SimpleJsonParser(std::string_view document);
+
+ // Extract array of strings from the document. `filter` is a jq-like filter
+ // (e.g. ".Data.Includes") that locates the string array to extract. The
+ // filter syntax currently only supports nested objects.
+ tl::expected<std::vector<std::string>, std::string>
+ get_string_array(std::string_view filter) const;
+
+private:
+ std::string_view m_document;
+};
+
+} // namespace util
test_util_exec.cpp
test_util_expected.cpp
test_util_file.cpp
+ test_util_json.cpp
test_util_lockfile.cpp
test_util_path.cpp
test_util_string.cpp
--- /dev/null
+// 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 "testutil.hpp"
+
+#include <ccache/context.hpp>
+#include <ccache/util/json.hpp>
+
+#include <doctest/doctest.h>
+
+TEST_SUITE_BEGIN("json");
+
+TEST_CASE("SimpleJsonParser")
+{
+ SUBCASE("Parse MSVC /sourceDependencies file")
+ {
+ std::string json = R"({
+ "Version": "1.1",
+ "Data": {
+ "Source": "C:\\path\\to\\source.cpp",
+ "ProvidedModule": "",
+ "Includes": [
+ "C:\\path\\to\\header\"with\"quotes.h",
+ "C:\\path\\to\\header\\with\\backslashes.h",
+ "C:\\日本語\\header1.h"
+ ]
+ }
+})";
+
+ util::SimpleJsonParser parser(json);
+ auto includes = parser.get_string_array(".Data.Includes");
+ REQUIRE(includes);
+ REQUIRE(includes->size() == 3);
+ CHECK((*includes)[0] == "C:\\path\\to\\header\"with\"quotes.h");
+ CHECK((*includes)[1] == "C:\\path\\to\\header\\with\\backslashes.h");
+ CHECK((*includes)[2] == "C:\\日本語\\header1.h");
+ }
+
+ SUBCASE("Empty array")
+ {
+ std::string json = R"({
+ "Data": {
+ "Includes": []
+ }
+})";
+
+ util::SimpleJsonParser parser(json);
+ auto includes = parser.get_string_array(".Data.Includes");
+ REQUIRE(includes);
+ CHECK(includes->empty());
+ }
+
+ SUBCASE("Single element array")
+ {
+ std::string json = R"({
+ "Data": {
+ "Includes": ["single.h"]
+ }
+})";
+
+ util::SimpleJsonParser parser(json);
+ auto includes = parser.get_string_array(".Data.Includes");
+ REQUIRE(includes);
+ REQUIRE(includes->size() == 1);
+ CHECK((*includes)[0] == "single.h");
+ }
+
+ SUBCASE("Array with whitespace variations")
+ {
+ std::string json = R"({
+"Data":{"Includes":["a.h" , "b.h","c.h"]}
+})";
+
+ util::SimpleJsonParser parser(json);
+ auto includes = parser.get_string_array(".Data.Includes");
+ REQUIRE(includes);
+ REQUIRE(includes->size() == 3);
+ CHECK((*includes)[0] == "a.h");
+ CHECK((*includes)[1] == "b.h");
+ CHECK((*includes)[2] == "c.h");
+ }
+
+ SUBCASE("Escape sequences")
+ {
+ std::string json = R"({
+ "Data": {
+ "Includes": [
+ "path\\with\\backslashes",
+ "string\"with\"quotes",
+ "line1\nline2",
+ "tab\tseparated",
+ "carriage\rreturn",
+ "form\ffeed",
+ "back\bspace",
+ "forward/slash"
+ ]
+ }
+})";
+
+ util::SimpleJsonParser parser(json);
+ auto includes = parser.get_string_array(".Data.Includes");
+ REQUIRE(includes);
+ REQUIRE(includes->size() == 8);
+ CHECK((*includes)[0] == "path\\with\\backslashes");
+ CHECK((*includes)[1] == "string\"with\"quotes");
+ CHECK((*includes)[2] == "line1\nline2");
+ CHECK((*includes)[3] == "tab\tseparated");
+ CHECK((*includes)[4] == "carriage\rreturn");
+ CHECK((*includes)[5] == "form\ffeed");
+ CHECK((*includes)[6] == "back\bspace");
+ CHECK((*includes)[7] == "forward/slash");
+ }
+
+ SUBCASE("UTF-8 characters")
+ {
+ std::string json = R"({
+ "Data": {
+ "Includes": [
+ "日本語.h",
+ "中文.cpp",
+ "한글.hpp",
+ "emoji😀.c",
+ "Ελληνικά.h"
+ ]
+ }
+})";
+
+ util::SimpleJsonParser parser(json);
+ auto includes = parser.get_string_array(".Data.Includes");
+ REQUIRE(includes);
+ REQUIRE(includes->size() == 5);
+ CHECK((*includes)[0] == "日本語.h");
+ CHECK((*includes)[1] == "中文.cpp");
+ CHECK((*includes)[2] == "한글.hpp");
+ CHECK((*includes)[3] == "emoji😀.c");
+ CHECK((*includes)[4] == "Ελληνικά.h");
+ }
+
+ SUBCASE("Nested objects")
+ {
+ std::string json = R"({
+ "Level1": {
+ "Level2": {
+ "Level3": {
+ "Files": ["deep.h"]
+ }
+ }
+ }
+})";
+
+ util::SimpleJsonParser parser(json);
+ auto files = parser.get_string_array(".Level1.Level2.Level3.Files");
+ REQUIRE(files);
+ REQUIRE(files->size() == 1);
+ CHECK((*files)[0] == "deep.h");
+ }
+
+ SUBCASE("Object with multiple keys")
+ {
+ std::string json = R"({
+ "Version": "1.0",
+ "Data": {
+ "Source": "main.cpp",
+ "Includes": ["header.h"],
+ "Flags": ["-O2", "-Wall"]
+ }
+})";
+
+ util::SimpleJsonParser parser(json);
+ auto includes = parser.get_string_array(".Data.Includes");
+ REQUIRE(includes);
+ REQUIRE(includes->size() == 1);
+ CHECK((*includes)[0] == "header.h");
+
+ util::SimpleJsonParser parser2(json);
+ auto flags = parser2.get_string_array(".Data.Flags");
+ REQUIRE(flags);
+ REQUIRE(flags->size() == 2);
+ CHECK((*flags)[0] == "-O2");
+ CHECK((*flags)[1] == "-Wall");
+ }
+
+ SUBCASE("Skip non-target values")
+ {
+ std::string json = R"({
+ "Other": {
+ "NestedArray": [1, 2, 3],
+ "NestedObject": {"key": "value"}
+ },
+ "Data": {
+ "Includes": ["target.h"]
+ }
+})";
+
+ util::SimpleJsonParser parser(json);
+ auto includes = parser.get_string_array(".Data.Includes");
+ REQUIRE(includes);
+ REQUIRE(includes->size() == 1);
+ CHECK((*includes)[0] == "target.h");
+ }
+
+ SUBCASE("Empty strings in array")
+ {
+ std::string json = R"({
+ "Data": {
+ "Includes": ["", "file.h", ""]
+ }
+})";
+
+ util::SimpleJsonParser parser(json);
+ auto includes = parser.get_string_array(".Data.Includes");
+ REQUIRE(includes);
+ REQUIRE(includes->size() == 3);
+ CHECK((*includes)[0] == "");
+ CHECK((*includes)[1] == "file.h");
+ CHECK((*includes)[2] == "");
+ }
+
+ SUBCASE("Error: Invalid filter (no leading dot)")
+ {
+ std::string json = R"({"Data": {"Includes": []}})";
+ util::SimpleJsonParser parser(json);
+ auto result = parser.get_string_array("Data.Includes");
+ CHECK(!result);
+ CHECK(result.error() == "Invalid filter: must start with '.'");
+ }
+
+ SUBCASE("Error: Invalid filter (empty)")
+ {
+ std::string json = R"({"Data": {"Includes": []}})";
+ util::SimpleJsonParser parser(json);
+ auto result = parser.get_string_array("");
+ CHECK(!result);
+ CHECK(result.error() == "Invalid filter: must start with '.'");
+ }
+
+ SUBCASE("Error: Key not found")
+ {
+ std::string json = R"({"Data": {"Other": []}})";
+ util::SimpleJsonParser parser(json);
+ auto result = parser.get_string_array(".Data.Includes");
+ CHECK(!result);
+ CHECK(result.error().find("not found") != std::string::npos);
+ }
+
+ SUBCASE("Error: Not an array")
+ {
+ std::string json = R"({"Data": {"Includes": "not-an-array"}})";
+ util::SimpleJsonParser parser(json);
+ auto result = parser.get_string_array(".Data.Includes");
+ CHECK(!result);
+ CHECK(result.error().find("Expected array") != std::string::npos);
+ }
+
+ SUBCASE("Error: Not an object")
+ {
+ std::string json = R"({"Data": "not-an-object"})";
+ util::SimpleJsonParser parser(json);
+ auto result = parser.get_string_array(".Data.Includes");
+ CHECK(!result);
+ CHECK(result.error().find("Expected object") != std::string::npos);
+ }
+
+ SUBCASE("Error: Unterminated string")
+ {
+ std::string json = R"({"Data": {"Includes": ["unterminated]}})";
+ util::SimpleJsonParser parser(json);
+ auto result = parser.get_string_array(".Data.Includes");
+ CHECK(!result);
+ CHECK(result.error() == "Unterminated string");
+ }
+
+ SUBCASE("Error: Unterminated array")
+ {
+ std::string json = R"({"Data": {"Includes": ["file.h")";
+ util::SimpleJsonParser parser(json);
+ auto result = parser.get_string_array(".Data.Includes");
+ CHECK(!result);
+ CHECK(result.error() == "Unterminated array");
+ }
+
+ SUBCASE("Error: Invalid escape sequence")
+ {
+ std::string json = R"({"Data": {"Includes": ["invalid\xescape"]}})";
+ util::SimpleJsonParser parser(json);
+ auto result = parser.get_string_array(".Data.Includes");
+ CHECK(!result);
+ CHECK(result.error().find("Unknown escape sequence") != std::string::npos);
+ }
+
+ SUBCASE("Error: \\uXXXX escape sequence not supported")
+ {
+ std::string json = R"({"Data": {"Includes": ["unicode\u0041char"]}})";
+ util::SimpleJsonParser parser(json);
+ auto result = parser.get_string_array(".Data.Includes");
+ CHECK(!result);
+ CHECK(result.error() == "\\uXXXX escape sequences are not supported");
+ }
+
+ SUBCASE("Error: \\uXXXX in nested object")
+ {
+ std::string json =
+ R"({"Data": {"Key": "value\u1234", "Includes": ["file.h"]}})";
+ util::SimpleJsonParser parser(json);
+ auto result = parser.get_string_array(".Data.Includes");
+ CHECK(!result);
+ CHECK(result.error() == "\\uXXXX escape sequences are not supported");
+ }
+
+ SUBCASE("Error: Root is not an object")
+ {
+ std::string json = R"(["array", "at", "root"])";
+ util::SimpleJsonParser parser(json);
+ auto result = parser.get_string_array(".Data.Includes");
+ CHECK(!result);
+ CHECK(result.error() == "Expected object at root");
+ }
+
+ SUBCASE("Error: Non-string element in array")
+ {
+ std::string json = R"({"Data": {"Includes": ["file.h", 123]}})";
+ util::SimpleJsonParser parser(json);
+ auto result = parser.get_string_array(".Data.Includes");
+ CHECK(!result);
+ CHECK(result.error() == "Expected string in array");
+ }
+}
+
+TEST_SUITE_END();