diff --git a/Dockerfile b/Dockerfile index 4bca8b659..93912e40e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ COPY .git . COPY Makefile . COPY include ./include/ COPY lib ./lib/ +COPY rustify ./rustify/ COPY src ./src/ RUN make BUILD=release install diff --git a/Makefile b/Makefile index 4bf6dee03..dc8525a45 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ CUSTOM_CXXFLAGS := $(shell grep -m1 cxxflags cabin.toml | sed 's/cxxflags = \[// # Git dependency versions TOML11_VER := $(shell grep -m1 toml11 cabin.toml | sed 's/.*tag = \(.*\)}/\1/' | tr -d '"') -RESULT_VER := $(shell grep -m1 cpp-result cabin.toml | sed 's/.*tag = \(.*\)}/\1/' | tr -d '"') +RESULT_VER := $(shell grep -m1 cpp-result rustify/cabin.toml | sed 's/.*tag = \(.*\)}/\1/' | tr -d '"') GIT_DEPS := $(O)/DEPS/toml11 $(O)/DEPS/mitama-cpp-result @@ -44,7 +44,7 @@ DEFINES := -DCABIN_CABIN_PKG_VERSION='"$(VERSION)"' \ -DCABIN_CABIN_COMMIT_HASH='"$(COMMIT_HASH)"' \ -DCABIN_CABIN_COMMIT_SHORT_HASH='"$(COMMIT_SHORT_HASH)"' \ -DCABIN_CABIN_COMMIT_DATE='"$(COMMIT_DATE)"' -INCLUDES := -Iinclude -Isrc -isystem $(O)/DEPS/toml11/include \ +INCLUDES := -Iinclude -Isrc -Irustify/include -isystem $(O)/DEPS/toml11/include \ -isystem $(O)/DEPS/mitama-cpp-result/include CXXFLAGS := -std=c++$(EDITION) -fdiagnostics-color $(CUSTOM_CXXFLAGS) \ diff --git a/cabin.toml b/cabin.toml index a7020f432..75d664bf5 100644 --- a/cabin.toml +++ b/cabin.toml @@ -12,13 +12,13 @@ version = "0.13.0" [dependencies] toml11 = {git = "https://github.com/ToruNiina/toml11.git", tag = "v4.4.0"} -mitama-cpp-result = {git = "https://github.com/loliGothicK/mitama-cpp-result.git", tag = "v11.0.0"} fmt = {version = ">=9 && <13", system = true} spdlog = {version = ">=1.8 && <2", system = true} libcurl = {version = ">=7.79.1 && <9", system = true} libgit2 = {version = ">=1.7 && <1.10", system = true} nlohmann_json = {version = "3.10.5", system = true} tbb = {version = ">=2021.5.0 && <2023.0.0", system = true} +rustify = {path = "rustify"} [dev-dependencies] boost-ut = {git = "https://github.com/boost-ext/ut.git", tag = "v2.3.1"} diff --git a/lib/Manifest.cc b/lib/Manifest.cc index 9fc9fce6f..482718593 100644 --- a/lib/Manifest.cc +++ b/lib/Manifest.cc @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +29,174 @@ static const std::unordered_set ALLOWED_CHARS = { '-', '_', '/', '.', '+' // allowed in the dependency name }; +template +struct Overloaded : Ts... { + using Ts::operator()...; +}; +template +Overloaded(Ts...) -> Overloaded; + +enum class DepKind : std::uint8_t { Git, Path, System }; + +struct DepKey { + DepKind kind; + std::string detail; + + bool operator==(const DepKey& other) const = default; +}; + +static fs::path resolveIncludeDir(const fs::path& installDir) { + fs::path includeDir = installDir / "include"; + if (fs::exists(includeDir) && fs::is_directory(includeDir) + && !fs::is_empty(includeDir)) { + return includeDir; + } + return installDir; +} + +static fs::path canonicalizePathDep(const fs::path& baseDir, + const std::string& relPath) { + std::error_code ec; + fs::path depRoot = fs::weakly_canonical(baseDir / relPath, ec); + if (ec) { + depRoot = fs::absolute(baseDir / relPath); + } + return depRoot; +} + +static Result +installDependencies(const Manifest& manifest, bool includeDevDeps, + std::unordered_map& seenDeps, + std::unordered_set& visited, + std::vector& installed); + +static Result +installPathDependency(const Manifest& manifest, const PathDependency& pathDep, + bool includeDevDeps, + std::unordered_map& seenDeps, + std::unordered_set& visited, + std::vector& installed) { + const fs::path basePath = manifest.path.parent_path(); + const fs::path depRoot = canonicalizePathDep(basePath, pathDep.path); + + Ensure(fs::exists(depRoot) && fs::is_directory(depRoot), + "{} can't be accessible as directory", depRoot.string()); + if (!visited.insert(depRoot).second) { + return Ok(); + } + + CompilerOpts pathOpts; + pathOpts.cFlags.includeDirs.emplace_back(resolveIncludeDir(depRoot), + /*isSystem=*/false); + + const fs::path depManifestPath = depRoot / Manifest::FILE_NAME; + Ensure(fs::exists(depManifestPath), "missing `{}` in path dependency {}", + Manifest::FILE_NAME, depRoot.string()); + const Manifest depManifest = Try(Manifest::tryParse(depManifestPath, false)); + + std::vector nestedDeps; + Try(installDependencies(depManifest, includeDevDeps, seenDeps, visited, + nestedDeps)); + for (const CompilerOpts& opts : nestedDeps) { + pathOpts.merge(opts); + } + + installed.emplace_back(std::move(pathOpts)); + return Ok(); +} + +static DepKey makeDepKey(const Manifest& manifest, const Dependency& dep) { + const fs::path basePath = manifest.path.parent_path(); + return std::visit( + Overloaded{ + [&](const GitDependency& gitDep) -> DepKey { + std::string detail = gitDep.url; + if (gitDep.target.has_value()) { + detail.push_back('#'); + detail.append(gitDep.target.value()); + } + return DepKey{ .kind = DepKind::Git, .detail = std::move(detail) }; + }, + [&](const SystemDependency& sysDep) -> DepKey { + return DepKey{ .kind = DepKind::System, + .detail = sysDep.versionReq.toString() }; + }, + [&](const PathDependency& pathDep) -> DepKey { + const fs::path canon = canonicalizePathDep(basePath, pathDep.path); + return DepKey{ .kind = DepKind::Path, + .detail = canon.generic_string() }; + }, + }, + dep); +} + +static const std::string& depName(const Dependency& dep) { + return std::visit( + Overloaded{ + [](const GitDependency& gitDep) -> const std::string& { + return gitDep.name; + }, + [](const SystemDependency& sysDep) -> const std::string& { + return sysDep.name; + }, + [](const PathDependency& pathDep) -> const std::string& { + return pathDep.name; + }, + }, + dep); +} + +static Result rememberDep(const Manifest& manifest, const Dependency& dep, + std::unordered_map& seen) { + const DepKey key = makeDepKey(manifest, dep); + const std::string& name = depName(dep); + const auto [it, inserted] = seen.emplace(name, key); + if (inserted) { + return Ok(); + } + if (it->second == key) { + return Ok(); + } + Bail("dependency `{}` conflicts across manifests", name); +} + +static Result +installDependencies(const Manifest& manifest, const bool includeDevDeps, + std::unordered_map& seenDeps, + std::unordered_set& visited, + std::vector& installed) { + const auto installOne = [&](const Dependency& dep) -> Result { + Try(rememberDep(manifest, dep, seenDeps)); + return std::visit(Overloaded{ + [&](const GitDependency& gitDep) -> Result { + installed.emplace_back(Try(gitDep.install())); + return Ok(); + }, + [&](const SystemDependency& sysDep) -> Result { + installed.emplace_back(Try(sysDep.install())); + return Ok(); + }, + [&](const PathDependency& pathDep) -> Result { + return installPathDependency( + manifest, pathDep, includeDevDeps, seenDeps, + visited, installed); + }, + }, + dep); + }; + + for (const auto& dep : manifest.dependencies) { + Try(installOne(dep)); + } + if (includeDevDeps && manifest.path == Manifest::tryParse().unwrap().path) { + for (const auto& dep : manifest.devDependencies) { + Try(installOne(dep)); + } + } + + return Ok(); +} + Result Edition::tryFromString(std::string str) noexcept { if (str == "98") { return Ok(Edition(Edition::Cpp98, std::move(str))); @@ -432,20 +601,10 @@ Result Manifest::findPath(fs::path candidateDir) noexcept { Result> Manifest::installDeps(const bool includeDevDeps) const { + std::unordered_map seenDeps; + std::unordered_set visited; std::vector installed; - const auto install = [&](const auto& arg) -> Result { - installed.emplace_back(Try(arg.install())); - return Ok(); - }; - - for (const auto& dep : dependencies) { - Try(std::visit(install, dep)); - } - if (includeDevDeps) { - for (const auto& dep : devDependencies) { - Try(std::visit(install, dep)); - } - } + Try(installDependencies(*this, includeDevDeps, seenDeps, visited, installed)); return Ok(installed); } diff --git a/rustify/cabin.toml b/rustify/cabin.toml new file mode 100644 index 000000000..436093dfe --- /dev/null +++ b/rustify/cabin.toml @@ -0,0 +1,8 @@ +[package] +name = "rustify" +version = "0.1.0" +edition = "23" + +[dependencies] +fmt = {version = ">=9 && <13", system = true} +mitama-cpp-result = {git = "https://github.com/loliGothicK/mitama-cpp-result.git", tag = "v11.0.0"} diff --git a/include/Rustify/Result.hpp b/rustify/include/Rustify/Result.hpp similarity index 100% rename from include/Rustify/Result.hpp rename to rustify/include/Rustify/Result.hpp diff --git a/include/Rustify/Tests.hpp b/rustify/include/Rustify/Tests.hpp similarity index 100% rename from include/Rustify/Tests.hpp rename to rustify/include/Rustify/Tests.hpp diff --git a/src/Cmd/Fmt.cc b/src/Cmd/Fmt.cc index 448451c1f..0a7331052 100644 --- a/src/Cmd/Fmt.cc +++ b/src/Cmd/Fmt.cc @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -76,6 +77,12 @@ collectFormatTargets(const fs::path& manifestDir, if (entry->is_directory()) { const std::string path = fs::relative(entry->path(), manifestDir).string(); + if (entry->path() != manifestDir + && fs::exists(entry->path() / Manifest::FILE_NAME)) { + spdlog::debug("Ignore nested project: {}", path); + entry.disable_recursion_pending(); + continue; + } if ((hasGitRepo && repo.isIgnored(path)) || isExcluded(path)) { spdlog::debug("Ignore: {}", path); entry.disable_recursion_pending(); diff --git a/src/Cmd/Lint.cc b/src/Cmd/Lint.cc index 5da9d0ca2..49487716d 100644 --- a/src/Cmd/Lint.cc +++ b/src/Cmd/Lint.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -34,6 +35,29 @@ struct LintArgs { std::vector excludes; }; +static std::vector +collectNestedProjectExcludes(const fs::path& manifestDir) { + std::vector excludes; + for (auto entry = fs::recursive_directory_iterator(manifestDir); + entry != fs::recursive_directory_iterator(); ++entry) { + if (!entry->is_directory()) { + continue; + } + if (entry->path() == manifestDir) { + continue; + } + if (fs::exists(entry->path() / Manifest::FILE_NAME)) { + std::error_code ec; + const fs::path rel = fs::relative(entry->path(), manifestDir, ec); + if (!ec) { + excludes.emplace_back("--exclude=" + rel.generic_string()); + } + entry.disable_recursion_pending(); + } + } + return excludes; +} + static Result lint(const std::string_view name, const std::vector& cpplintArgs, bool useVcsIgnoreFiles) { @@ -100,6 +124,11 @@ static Result lintMain(const CliArgsView args) { const auto manifest = Try(Manifest::tryParse()); std::vector cpplintArgs = std::move(lintArgs.excludes); + const fs::path projectDir = manifest.path.parent_path(); + const std::vector nestedExcludes = + collectNestedProjectExcludes(projectDir); + cpplintArgs.insert(cpplintArgs.end(), nestedExcludes.begin(), + nestedExcludes.end()); if (fs::exists("CPPLINT.cfg")) { spdlog::debug("Using CPPLINT.cfg for lint ..."); return lint(manifest.package.name, cpplintArgs, useVcsIgnoreFiles); diff --git a/tests/deps_path.cc b/tests/deps_path.cc new file mode 100644 index 000000000..751f7d5f5 --- /dev/null +++ b/tests/deps_path.cc @@ -0,0 +1,306 @@ +#include "helpers.hpp" + +#include +#include +#include + +int main() { + using boost::ut::expect; + using boost::ut::operator""_test; + namespace fs = std::filesystem; + + "path dependency installs transitive deps"_test = [] { + const tests::TempDir tmp; + + const fs::path innerRoot = tmp.path / "inner"; + fs::create_directories(innerRoot / "include" / "inner"); + tests::writeFile(innerRoot / "cabin.toml", + R"([package] +name = "inner" +version = "0.1.0" +edition = "23" +)"); + tests::writeFile(innerRoot / "include" / "inner" / "inner.hpp", + R"(#pragma once + +inline int inner_value() { return 5; } +)"); + + const fs::path depRoot = tmp.path / "dep"; + fs::create_directories(depRoot / "include" / "dep"); + tests::writeFile(depRoot / "cabin.toml", + R"([package] +name = "dep" +version = "0.1.0" +edition = "23" + +[dependencies] +inner = {path = "../inner"} +)"); + tests::writeFile(depRoot / "include" / "dep" / "dep.hpp", + R"(#pragma once + +#include "inner/inner.hpp" + +inline int dep_value() { return inner_value(); } +)"); + + const fs::path appRoot = tmp.path / "app"; + fs::create_directories(appRoot / "src"); + tests::writeFile(appRoot / "cabin.toml", + R"([package] +name = "app" +version = "0.1.0" +edition = "23" + +[dependencies] +dep = {path = "../dep"} +)"); + tests::writeFile(appRoot / "src" / "main.cc", + R"(#include "dep/dep.hpp" + +int main() { + return dep_value() == 5 ? 0 : 1; +} +)"); + + const auto result = tests::runCabin({ "build" }, appRoot).unwrap(); + + expect(result.status.success()) << result.status.toString(); + }; + + "path dependency can depend on another path dependency"_test = [] { + const tests::TempDir tmp; + + const fs::path utilRoot = tmp.path / "util"; + fs::create_directories(utilRoot / "include" / "util"); + tests::writeFile(utilRoot / "cabin.toml", + R"([package] +name = "util" +version = "0.1.0" +edition = "23" +)"); + tests::writeFile(utilRoot / "include" / "util" / "util.hpp", + R"(#pragma once + +inline int util_value() { return 42; } +)"); + + const fs::path depRoot = tmp.path / "dep"; + fs::create_directories(depRoot / "include" / "dep"); + tests::writeFile(depRoot / "cabin.toml", + R"([package] +name = "dep" +version = "0.1.0" +edition = "23" + +[dependencies] +util = {path = "../util"} +)"); + tests::writeFile(depRoot / "include" / "dep" / "dep.hpp", + R"(#pragma once + +#include "util/util.hpp" + +inline int dep_value() { return util_value(); } +)"); + + const fs::path appRoot = tmp.path / "app"; + fs::create_directories(appRoot / "src"); + tests::writeFile(appRoot / "cabin.toml", + R"([package] +name = "app" +version = "0.1.0" +edition = "23" + +[dependencies] +dep = {path = "../dep"} +)"); + tests::writeFile(appRoot / "src" / "main.cc", + R"(#include "dep/dep.hpp" + +int main() { + return dep_value() == 42 ? 0 : 1; +} +)"); + + const auto result = tests::runCabin({ "build" }, appRoot).unwrap(); + expect(result.status.success()) << result.status.toString(); + }; + + "path dependency uses root when include/ is absent"_test = [] { + const tests::TempDir tmp; + + const fs::path depRoot = tmp.path / "dep"; + fs::create_directories(depRoot); + tests::writeFile(depRoot / "cabin.toml", + R"([package] +name = "dep" +version = "0.1.0" +edition = "23" +)"); + tests::writeFile(depRoot / "dep.hpp", + R"(#pragma once + +inline int dep_value() { return 7; } +)"); + + const fs::path appRoot = tmp.path / "app"; + fs::create_directories(appRoot / "src"); + tests::writeFile(appRoot / "cabin.toml", + R"([package] +name = "app" +version = "0.1.0" +edition = "23" + +[dependencies] +dep = {path = "../dep"} +)"); + tests::writeFile(appRoot / "src" / "main.cc", + R"(#include "dep.hpp" + +int main() { return dep_value() == 7 ? 0 : 1; } +)"); + + const auto result = tests::runCabin({ "build" }, appRoot).unwrap(); + expect(result.status.success()) << result.status.toString(); + }; + + "root and dep agree on shared dep"_test = [] { + const tests::TempDir tmp; + + const fs::path sharedRoot = tmp.path / "shared"; + fs::create_directories(sharedRoot / "include" / "shared"); + tests::writeFile(sharedRoot / "cabin.toml", + R"([package] +name = "shared" +version = "0.1.0" +edition = "23" +)"); + tests::writeFile(sharedRoot / "include" / "shared" / "shared.hpp", + R"(#pragma once + +inline int shared_value() { return 11; } +)"); + + const fs::path depRoot = tmp.path / "dep"; + fs::create_directories(depRoot / "include" / "dep"); + tests::writeFile(depRoot / "cabin.toml", + R"([package] +name = "dep" +version = "0.1.0" +edition = "23" + +[dependencies] +fmt = {path = "../shared"} +)"); + tests::writeFile(depRoot / "include" / "dep" / "dep.hpp", + R"(#pragma once + +#include "shared/shared.hpp" + +inline int dep_value() { return shared_value(); } +)"); + + const fs::path appRoot = tmp.path / "app"; + fs::create_directories(appRoot / "src"); + tests::writeFile(appRoot / "cabin.toml", + R"([package] +name = "app" +version = "0.1.0" +edition = "23" + +[dependencies] +dep = {path = "../dep"} +fmt = {path = "../shared"} +)"); + tests::writeFile(appRoot / "src" / "main.cc", + R"(#include "dep/dep.hpp" +#include "shared/shared.hpp" + +int main() { + return dep_value() == shared_value() ? 0 : 1; +} +)"); + + const auto result = tests::runCabin({ "build" }, appRoot).unwrap(); + expect(result.status.success()) << result.status.toString(); + }; + + "root and dep conflict on shared dep"_test = [] { + const tests::TempDir tmp; + + const fs::path sharedRoot = tmp.path / "shared"; + fs::create_directories(sharedRoot / "include" / "shared"); + tests::writeFile(sharedRoot / "cabin.toml", + R"([package] +name = "shared" +version = "0.1.0" +edition = "23" +)"); + tests::writeFile(sharedRoot / "include" / "shared" / "shared.hpp", + R"(#pragma once + +inline int shared_value() { return 11; } +)"); + + const fs::path otherRoot = tmp.path / "other"; + fs::create_directories(otherRoot / "include" / "other"); + tests::writeFile(otherRoot / "cabin.toml", + R"([package] +name = "other" +version = "0.1.0" +edition = "23" +)"); + tests::writeFile(otherRoot / "include" / "other" / "other.hpp", + R"(#pragma once + +inline int other_value() { return 22; } +)"); + + const fs::path depRoot = tmp.path / "dep"; + fs::create_directories(depRoot / "include" / "dep"); + tests::writeFile(depRoot / "cabin.toml", + R"([package] +name = "dep" +version = "0.1.0" +edition = "23" + +[dependencies] +fmt = {path = "../other"} +)"); + tests::writeFile(depRoot / "include" / "dep" / "dep.hpp", + R"(#pragma once + +#include "other/other.hpp" + +inline int dep_value() { return other_value(); } +)"); + + const fs::path appRoot = tmp.path / "app"; + fs::create_directories(appRoot / "src"); + tests::writeFile(appRoot / "cabin.toml", + R"([package] +name = "app" +version = "0.1.0" +edition = "23" + +[dependencies] +dep = {path = "../dep"} +fmt = {path = "../shared"} +)"); + tests::writeFile(appRoot / "src" / "main.cc", + R"(#include "dep/dep.hpp" +#include "shared/shared.hpp" + +int main() { + return dep_value() == shared_value() ? 0 : 1; +} +)"); + + const auto result = tests::runCabin({ "build" }, appRoot).unwrap(); + expect(!result.status.success()); + const auto err = tests::sanitizeOutput(result.err); + expect(err.contains("dependency `fmt` conflicts across manifests")); + }; +} diff --git a/tests/new.cc b/tests/new.cc index 0643bcc47..25bb59a6d 100644 --- a/tests/new.cc +++ b/tests/new.cc @@ -57,6 +57,25 @@ int main() { expect(sanitizedErr == expectedErr); }; + "cabin new hyphenated library"_test = [] { + const tests::TempDir tmp; + const auto result = + tests::runCabin({ "new", "--lib", "my-lib" }, tmp.path).unwrap(); + + expect(result.status.success()) << result.status.toString(); + + const auto project = tmp.path / "my-lib"; + const auto header = project / "include" / "my-lib" / "my-lib.hpp"; + const auto impl = project / "lib" / "my-lib.cc"; + expect(tests::fs::is_regular_file(header)); + expect(tests::fs::is_regular_file(impl)); + + const auto headerContent = tests::readFile(header); + expect(headerContent.contains("namespace my_lib")); + const auto implContent = tests::readFile(impl); + expect(implContent.contains("namespace my_lib")); + }; + "cabin new requires name"_test = [] { const tests::TempDir tmp; const auto result = tests::runCabin({ "new" }, tmp.path).unwrap();