[M1] add manifest::parse
This commit is contained in:
@@ -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
|
||||
in CMake; `tests/util_error.cpp` covers six format cases via
|
||||
`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.
|
||||
|
||||
@@ -31,9 +31,11 @@ endif()
|
||||
|
||||
# ----- cargoxx library: module units + implementation units -----
|
||||
add_library(cargoxx STATIC)
|
||||
target_include_directories(cargoxx SYSTEM PRIVATE third_party)
|
||||
target_sources(cargoxx
|
||||
PRIVATE
|
||||
src/util/error.cpp
|
||||
src/manifest/parser.cpp
|
||||
PUBLIC
|
||||
FILE_SET CXX_MODULES FILES
|
||||
src/lib.cppm
|
||||
|
||||
@@ -1,3 +1,37 @@
|
||||
export module cargoxx.manifest;
|
||||
|
||||
import std;
|
||||
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
285
src/manifest/parser.cpp
Normal 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
|
||||
@@ -8,3 +8,4 @@ function(cargoxx_add_test name)
|
||||
endfunction()
|
||||
|
||||
cargoxx_add_test(util_error)
|
||||
cargoxx_add_test(manifest_parse)
|
||||
|
||||
245
tests/manifest_parse.cpp
Normal file
245
tests/manifest_parse.cpp
Normal 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
17890
third_party/toml.hpp
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user