[M1] add manifest::parse

This commit is contained in:
2026-05-08 00:23:46 +00:00
parent fba725e192
commit 95a8890623
7 changed files with 18463 additions and 0 deletions

View File

@@ -19,3 +19,9 @@ All notable changes to cargoxx will be documented in this file.
- `Catch2 v3` wired through `flake.nix` (libc++-built override) and registered - `Catch2 v3` wired through `flake.nix` (libc++-built override) and registered
in CMake; `tests/util_error.cpp` covers six format cases via in CMake; `tests/util_error.cpp` covers six format cases via
`catch_discover_tests`. `catch_discover_tests`.
- `cargoxx.manifest` public types (`Package`, `Dependency`, `BuildSettings`,
`Edition`, `Manifest`) and `parse(path)` returning `Result<Manifest>`,
backed by toml++ vendored as `third_party/toml.hpp`. Unknown keys in
`[package]` / `[build]` and unknown top-level keys are rejected; reserved
fields (`description`, `repository`, `[dev-dependencies]`, `[features]`,
`[workspace]`) are accepted. `tests/manifest_parse.cpp` covers 17 cases.

View File

@@ -31,9 +31,11 @@ endif()
# ----- cargoxx library: module units + implementation units ----- # ----- cargoxx library: module units + implementation units -----
add_library(cargoxx STATIC) add_library(cargoxx STATIC)
target_include_directories(cargoxx SYSTEM PRIVATE third_party)
target_sources(cargoxx target_sources(cargoxx
PRIVATE PRIVATE
src/util/error.cpp src/util/error.cpp
src/manifest/parser.cpp
PUBLIC PUBLIC
FILE_SET CXX_MODULES FILES FILE_SET CXX_MODULES FILES
src/lib.cppm src/lib.cppm

View File

@@ -1,3 +1,37 @@
export module cargoxx.manifest; export module cargoxx.manifest;
import std;
import cargoxx.util; import cargoxx.util;
export namespace cargoxx::manifest {
struct Dependency {
std::string name;
std::string version_spec;
std::vector<std::string> components;
};
struct BuildSettings {
bool warnings_as_errors = false;
std::vector<std::string> sanitizers;
};
enum class Edition { Cpp20, Cpp23, Cpp26 };
struct Package {
std::string name;
std::string version;
Edition edition = Edition::Cpp23;
std::vector<std::string> authors;
std::optional<std::string> license;
};
struct Manifest {
Package package;
std::vector<Dependency> dependencies;
BuildSettings build;
};
auto parse(const std::filesystem::path& path) -> util::Result<Manifest>;
} // namespace cargoxx::manifest

285
src/manifest/parser.cpp Normal file
View File

@@ -0,0 +1,285 @@
module;
#include <toml.hpp>
module cargoxx.manifest;
import std;
import cargoxx.util;
namespace cargoxx::manifest {
namespace {
using util::Error;
using util::ErrorCode;
auto err(ErrorCode code, std::string msg, std::filesystem::path path,
std::optional<std::pair<int, int>> line_col = std::nullopt) -> Error {
return Error{code, std::move(msg), "", std::move(path), line_col};
}
auto is_valid_name(std::string_view s) -> bool {
if (s.empty()) {
return false;
}
if (std::isdigit(static_cast<unsigned char>(s[0]))) {
return false;
}
for (char c : s) {
bool ok = std::isalnum(static_cast<unsigned char>(c)) || c == '_' || c == '-';
if (!ok) {
return false;
}
}
return true;
}
auto parse_edition(std::string_view s) -> std::optional<Edition> {
if (s == "cpp20") {
return Edition::Cpp20;
}
if (s == "cpp23") {
return Edition::Cpp23;
}
if (s == "cpp26") {
return Edition::Cpp26;
}
return std::nullopt;
}
auto source_pos(const toml::node& n) -> std::pair<int, int> {
auto src = n.source();
return {static_cast<int>(src.begin.line), static_cast<int>(src.begin.column)};
}
auto extract_string_array(const toml::array& arr, std::string_view field,
const std::filesystem::path& path)
-> util::Result<std::vector<std::string>> {
std::vector<std::string> out;
out.reserve(arr.size());
for (const auto& el : arr) {
if (auto s = el.value<std::string>()) {
out.push_back(*s);
} else {
return std::unexpected(err(
ErrorCode::ManifestInvalidField,
std::format("'{}' must be an array of strings", field),
path, source_pos(el)));
}
}
return out;
}
constexpr std::array PACKAGE_KNOWN_KEYS = {
"name", "version", "edition", "authors", "license", "description", "repository",
};
constexpr std::array BUILD_KNOWN_KEYS = {
"warnings_as_errors", "sanitizers",
};
constexpr std::array TOPLEVEL_KNOWN_KEYS = {
"package", "dependencies", "build",
// reserved (accepted, ignored for v0.1)
"dev-dependencies", "features", "workspace",
};
auto parse_package(const toml::table& tbl, const std::filesystem::path& path)
-> util::Result<Package> {
Package pkg;
for (const auto& [key, value] : tbl) {
std::string k{key.str()};
if (std::ranges::find(PACKAGE_KNOWN_KEYS, k) == PACKAGE_KNOWN_KEYS.end()) {
return std::unexpected(err(ErrorCode::ManifestUnknownField,
std::format("unknown key '[package].{}'", k), path,
source_pos(value)));
}
}
if (auto name = tbl["name"].value<std::string>()) {
if (!is_valid_name(*name)) {
return std::unexpected(err(ErrorCode::LayoutInvalidName,
std::format("invalid package name '{}'", *name), path,
source_pos(*tbl.get("name"))));
}
pkg.name = *name;
} else {
return std::unexpected(
err(ErrorCode::ManifestInvalidField, "[package].name is required", path));
}
if (auto version = tbl["version"].value<std::string>()) {
pkg.version = *version;
} else {
return std::unexpected(
err(ErrorCode::ManifestInvalidField, "[package].version is required", path));
}
if (auto edition = tbl["edition"].value<std::string>()) {
auto parsed = parse_edition(*edition);
if (!parsed) {
return std::unexpected(err(
ErrorCode::ManifestInvalidField,
std::format("invalid edition '{}': expected cpp20, cpp23, or cpp26", *edition),
path, source_pos(*tbl.get("edition"))));
}
pkg.edition = *parsed;
}
if (const auto* authors = tbl["authors"].as_array()) {
auto r = extract_string_array(*authors, "[package].authors", path);
if (!r) {
return std::unexpected(r.error());
}
pkg.authors = std::move(*r);
}
if (auto license = tbl["license"].value<std::string>()) {
pkg.license = *license;
}
return pkg;
}
auto parse_dependency(std::string name, const toml::node& value,
const std::filesystem::path& path) -> util::Result<Dependency> {
Dependency dep;
dep.name = std::move(name);
if (auto v = value.value<std::string>()) {
dep.version_spec = *v;
return dep;
}
if (const auto* tbl = value.as_table()) {
if (auto v = (*tbl)["version"].value<std::string>()) {
dep.version_spec = *v;
} else {
return std::unexpected(err(
ErrorCode::ManifestInvalidField,
std::format("dependency '{}' table must have a 'version' string", dep.name),
path, source_pos(value)));
}
if (const auto* comps = (*tbl)["components"].as_array()) {
auto r = extract_string_array(
*comps, std::format("dependencies.{}.components", dep.name), path);
if (!r) {
return std::unexpected(r.error());
}
dep.components = std::move(*r);
}
return dep;
}
return std::unexpected(err(
ErrorCode::ManifestInvalidField,
std::format("dependency '{}' must be a version string or a table", dep.name),
path, source_pos(value)));
}
auto parse_dependencies(const toml::table& tbl, const std::filesystem::path& path)
-> util::Result<std::vector<Dependency>> {
std::vector<Dependency> out;
out.reserve(tbl.size());
for (const auto& [key, value] : tbl) {
auto d = parse_dependency(std::string{key.str()}, value, path);
if (!d) {
return std::unexpected(d.error());
}
out.push_back(std::move(*d));
}
return out;
}
auto parse_build(const toml::table& tbl, const std::filesystem::path& path)
-> util::Result<BuildSettings> {
BuildSettings b;
for (const auto& [key, value] : tbl) {
std::string k{key.str()};
if (std::ranges::find(BUILD_KNOWN_KEYS, k) == BUILD_KNOWN_KEYS.end()) {
return std::unexpected(err(ErrorCode::ManifestUnknownField,
std::format("unknown key '[build].{}'", k), path,
source_pos(value)));
}
}
if (auto v = tbl["warnings_as_errors"].value<bool>()) {
b.warnings_as_errors = *v;
}
if (const auto* sans = tbl["sanitizers"].as_array()) {
auto r = extract_string_array(*sans, "[build].sanitizers", path);
if (!r) {
return std::unexpected(r.error());
}
b.sanitizers = std::move(*r);
}
return b;
}
} // namespace
auto parse(const std::filesystem::path& path) -> util::Result<Manifest> {
std::error_code ec;
if (!std::filesystem::exists(path, ec) || ec) {
return std::unexpected(err(ErrorCode::ManifestNotFound,
std::format("manifest not found: {}", path.string()), path));
}
toml::table root;
try {
root = toml::parse_file(path.string());
} catch (const toml::parse_error& e) {
auto src = e.source();
return std::unexpected(err(ErrorCode::ManifestParseError,
std::format("toml parse error: {}", e.description()), path,
std::pair{static_cast<int>(src.begin.line),
static_cast<int>(src.begin.column)}));
}
for (const auto& [key, value] : root) {
std::string k{key.str()};
if (std::ranges::find(TOPLEVEL_KNOWN_KEYS, k) == TOPLEVEL_KNOWN_KEYS.end()) {
return std::unexpected(err(ErrorCode::ManifestUnknownField,
std::format("unknown top-level key '{}'", k), path,
source_pos(value)));
}
}
Manifest m;
const auto* pkg_tbl = root["package"].as_table();
if (!pkg_tbl) {
return std::unexpected(
err(ErrorCode::ManifestInvalidField, "[package] table is required", path));
}
auto pkg = parse_package(*pkg_tbl, path);
if (!pkg) {
return std::unexpected(pkg.error());
}
m.package = std::move(*pkg);
if (const auto* deps_tbl = root["dependencies"].as_table()) {
auto deps = parse_dependencies(*deps_tbl, path);
if (!deps) {
return std::unexpected(deps.error());
}
m.dependencies = std::move(*deps);
}
if (const auto* build_tbl = root["build"].as_table()) {
auto build = parse_build(*build_tbl, path);
if (!build) {
return std::unexpected(build.error());
}
m.build = std::move(*build);
}
return m;
}
} // namespace cargoxx::manifest

View File

@@ -8,3 +8,4 @@ function(cargoxx_add_test name)
endfunction() endfunction()
cargoxx_add_test(util_error) cargoxx_add_test(util_error)
cargoxx_add_test(manifest_parse)

245
tests/manifest_parse.cpp Normal file
View File

@@ -0,0 +1,245 @@
#include <catch2/catch_test_macros.hpp>
import cargoxx.manifest;
import cargoxx.util;
import std;
using cargoxx::manifest::Edition;
using cargoxx::manifest::parse;
using cargoxx::util::ErrorCode;
namespace {
auto write_manifest(std::string_view body) -> std::filesystem::path {
auto dir = std::filesystem::temp_directory_path() /
std::format("cargoxx-manifest-test-{}", std::random_device{}());
std::filesystem::create_directories(dir);
auto path = dir / "Cargoxx.toml";
std::ofstream{path} << body;
return path;
}
} // namespace
TEST_CASE("parse rejects missing file", "[manifest][parse]") {
auto r = parse("/nonexistent/Cargoxx.toml");
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestNotFound);
}
TEST_CASE("parse rejects malformed toml", "[manifest][parse]") {
auto p = write_manifest("[package\nname = ");
auto r = parse(p);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestParseError);
}
TEST_CASE("parse accepts a minimal manifest", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "foo"
version = "0.1.0"
)");
auto r = parse(p);
REQUIRE(r.has_value());
REQUIRE(r->package.name == "foo");
REQUIRE(r->package.version == "0.1.0");
REQUIRE(r->package.edition == Edition::Cpp23);
REQUIRE(r->dependencies.empty());
REQUIRE_FALSE(r->build.warnings_as_errors);
}
TEST_CASE("parse handles all edition values", "[manifest][parse]") {
for (auto [s, e] : std::vector<std::pair<std::string, Edition>>{
{"cpp20", Edition::Cpp20}, {"cpp23", Edition::Cpp23}, {"cpp26", Edition::Cpp26}}) {
auto p = write_manifest(std::format(R"(
[package]
name = "foo"
version = "0.1.0"
edition = "{}"
)",
s));
auto r = parse(p);
REQUIRE(r.has_value());
REQUIRE(r->package.edition == e);
}
}
TEST_CASE("parse rejects unknown edition", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "foo"
version = "0.1.0"
edition = "cpp99"
)");
auto r = parse(p);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestInvalidField);
}
TEST_CASE("parse extracts authors and license", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "foo"
version = "0.1.0"
authors = ["Ada", "Grace"]
license = "MIT"
)");
auto r = parse(p);
REQUIRE(r.has_value());
REQUIRE(r->package.authors == std::vector<std::string>{"Ada", "Grace"});
REQUIRE(r->package.license == "MIT");
}
TEST_CASE("parse accepts string-form dependency", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "foo"
version = "0.1.0"
[dependencies]
fmt = "10.2"
)");
auto r = parse(p);
REQUIRE(r.has_value());
REQUIRE(r->dependencies.size() == 1);
REQUIRE(r->dependencies[0].name == "fmt");
REQUIRE(r->dependencies[0].version_spec == "10.2");
REQUIRE(r->dependencies[0].components.empty());
}
TEST_CASE("parse accepts table-form dependency with components", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "foo"
version = "0.1.0"
[dependencies]
boost = { version = "1.84", components = ["filesystem", "system"] }
)");
auto r = parse(p);
REQUIRE(r.has_value());
REQUIRE(r->dependencies.size() == 1);
REQUIRE(r->dependencies[0].name == "boost");
REQUIRE(r->dependencies[0].version_spec == "1.84");
REQUIRE(r->dependencies[0].components == std::vector<std::string>{"filesystem", "system"});
}
TEST_CASE("parse rejects dependency value of wrong type", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "foo"
version = "0.1.0"
[dependencies]
weird = 42
)");
auto r = parse(p);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestInvalidField);
}
TEST_CASE("parse extracts build settings", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "foo"
version = "0.1.0"
[build]
warnings_as_errors = true
sanitizers = ["address", "undefined"]
)");
auto r = parse(p);
REQUIRE(r.has_value());
REQUIRE(r->build.warnings_as_errors);
REQUIRE(r->build.sanitizers == std::vector<std::string>{"address", "undefined"});
}
TEST_CASE("parse rejects unknown [package] key", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "foo"
version = "0.1.0"
flavor = "spicy"
)");
auto r = parse(p);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestUnknownField);
}
TEST_CASE("parse rejects unknown [build] key", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "foo"
version = "0.1.0"
[build]
optimize = "max"
)");
auto r = parse(p);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestUnknownField);
}
TEST_CASE("parse accepts reserved [package] fields", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "foo"
version = "0.1.0"
description = "demo"
repository = "https://example.com/foo"
)");
auto r = parse(p);
REQUIRE(r.has_value());
}
TEST_CASE("parse accepts reserved top-level tables", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "foo"
version = "0.1.0"
[dev-dependencies]
gtest = "1.14"
[features]
default = []
[workspace]
members = ["a"]
)");
auto r = parse(p);
REQUIRE(r.has_value());
}
TEST_CASE("parse rejects invalid name with spaces", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "foo bar"
version = "0.1.0"
)");
auto r = parse(p);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::LayoutInvalidName);
}
TEST_CASE("parse rejects name starting with digit", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "1foo"
version = "0.1.0"
)");
auto r = parse(p);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::LayoutInvalidName);
}
TEST_CASE("parse rejects missing [package].name", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
version = "0.1.0"
)");
auto r = parse(p);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestInvalidField);
}

17890
third_party/toml.hpp vendored Normal file

File diff suppressed because it is too large Load Diff