Add project files

This commit is contained in:
alejo committed 2026-10-24 09:53:48 -03:00
commit f2fe6b8855
114 files changed
+20525

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# The `breeze` command: registers every command with CLIForge and hands the work to
# breeze_core. Nothing here decides anything; main.cpp and Commands.cpp are only the command line.
add_exe_module(breeze_exe OUTPUT_NAME "breeze" DEPENDS cliforge breeze_core)
+872
View File
@@ -0,0 +1,872 @@
#include "Commands.hpp"
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <set>
#include "breeze/Build.hpp"
#include "breeze/Cli.hpp"
#include "breeze/Config.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Modules.hpp"
#include "breeze/Presets.hpp"
#include "breeze/Process.hpp"
#include "breeze/Project.hpp"
#include "breeze/Sdks.hpp"
#include "breeze/Text.hpp"
#include "breeze/Tools.hpp"
namespace breeze::cli
{
int& exitCode()
{
static int s_code = 0;
return s_code;
}
namespace
{
using Str = std::optional<std::string>;
using Strs = std::vector<std::string>;
// ---------------------------------------------------------------- project
void showOrSet(Setting which, const Str& set)
{
Project project = requireProject();
if (set)
{
std::string before = readSetting(project, which);
writeSetting(project, which, *set);
std::cout << before << " -> " << *set << "\n";
}
else
{
std::cout << readSetting(project, which) << "\n";
}
}
// ---------------------------------------------------------------- config
void configValue(const char* key, const Str& set)
{
GlobalConfig cfg = GlobalConfig::load();
if (set)
{
if (*set == "none")
cfg.erase(key);
else
cfg.set(key, *set);
cfg.save();
std::cout << key << " = " << (*set == "none" ? "<not set>" : *set) << "\n";
return;
}
std::optional<std::string> value = cfg.effective(key);
std::cout << (value ? *value : "<not set>") << "\n";
}
// ---------------------------------------------------------------- defaults
void defaultsBuild(const char* key, const Str& set)
{
Project project = requireProject();
Defaults defaults = Defaults::load(project);
if (!set)
{
std::optional<std::string> v = defaults.build(key);
std::cout << (v ? *v : "<not set>") << "\n";
return;
}
if (*set == "none")
{
defaults.clearBuild(key);
}
else
{
std::string value = *set;
std::string k = key;
if (k == "mode")
value = buildTypeFor(value) == "RelWithDebInfo" ? "ReleaseDebugSymbols"
: buildTypeFor(value);
if (k == "preset")
{
std::optional<std::string> canonical;
for (const Preset& p : listPresets(project))
{
if (text::normalized(p.name) == text::normalized(value))
canonical = p.name;
}
if (!canonical)
throw Error("'" + value +
"' is not a preset of this project for this machine "
"(see `cmake --list-presets`)");
value = *canonical;
}
defaults.setBuild(key, value);
}
defaults.save(project);
std::cout << "default build " << key << " = "
<< defaults.build(key).value_or("<not set>") << "\n";
}
void defaultsRun(const Str& set)
{
Project project = requireProject();
Defaults defaults = Defaults::load(project);
if (!set)
{
std::optional<std::string> v = defaults.run();
std::cout << (v ? *v : "<not set>") << "\n";
return;
}
if (*set == "none")
{
defaults.clearRun();
}
else
{
std::vector<Module> modules = scanModules(project);
const Module* m = findModule(modules, *set);
if (m == nullptr || (m->kind != ModuleKind::Exec && m->kind != ModuleKind::Test &&
m->kind != ModuleKind::Example))
throw Error("'" + *set + "' is not an executable module of this project");
defaults.setRun(m->id);
}
defaults.save(project);
std::cout << "default run = "
<< (*set == "none" ? "<not set>" : defaults.run().value_or("")) << "\n";
}
// ---------------------------------------------------------------- generation
PresetQuery queryOf(const Str& preset, const Str& platform, const Str& arch,
const Str& compiler)
{
PresetQuery q;
q.preset = preset;
q.platform = platform;
q.arch = arch;
q.compiler = compiler;
return q;
}
void clean(bool noBuild, bool docs)
{
Project project = requireProject();
std::vector<std::string> removed = breeze::clean(project, noBuild, docs);
if (removed.empty())
std::cout << "nothing to clean\n";
for (const std::string& r : removed)
std::cout << "removed " << r << "\n";
}
BuildRequest requestOf(const Str& platform, const Str& arch, const Str& compiler,
const Str& mode, const std::optional<std::int32_t>& jobs,
const Str& preset, const Strs& cargs, const Strs& bargs,
bool noConfigure, bool noBuild)
{
BuildRequest r;
r.preset = queryOf(preset, platform, arch, compiler);
r.mode = mode;
if (jobs)
{
if (*jobs < 1)
throw Error("--jobs must be at least 1");
r.jobs = *jobs;
}
r.configureArgs = cargs;
r.buildArgs = bargs;
r.noConfigure = noConfigure;
r.noBuild = noBuild;
return r;
}
void build(const Str& platform, const Str& arch, const Str& compiler, const Str& mode,
const std::optional<std::int32_t>& jobs, const Str& preset, const Strs& cargs,
const Strs& bargs, bool noConfigure)
{
exitCode() = breeze::build(requireProject(),
requestOf(platform, arch, compiler, mode, jobs, preset,
cargs, bargs, noConfigure, false));
}
void install(const Str& platform, const Str& arch, const Str& compiler, const Str& mode,
const std::optional<std::int32_t>& jobs, const Str& preset, const Strs& cargs,
const Strs& bargs, bool noConfigure, bool noBuild)
{
exitCode() = breeze::install(requireProject(),
requestOf(platform, arch, compiler, mode, jobs, preset,
cargs, bargs, noConfigure, noBuild));
}
void docs()
{
exitCode() = breeze::docs(requireProject());
}
void runOne(const Str& id, const Str& preset, const Str& platform, const Str& arch,
const Str& compiler, const Str& mode, const Strs& args)
{
(void)mode; // the SDK keeps one build directory per preset, so the mode is part of what
// was built
exitCode() =
breeze::run(requireProject(), id, queryOf(preset, platform, arch, compiler), args);
}
void runDefault(const Str& preset, const Str& platform, const Str& arch,
const Str& compiler, const Str& mode, const Strs& args)
{
runOne(std::nullopt, preset, platform, arch, compiler, mode, args);
}
void runById(const std::string& id, const Str& preset, const Str& platform, const Str& arch,
const Str& compiler, const Str& mode, const Strs& args)
{
runOne(id, preset, platform, arch, compiler, mode, args);
}
void setup(bool required, bool all)
{
exitCode() = breeze::setup(requireProject(), required, all);
}
// ---------------------------------------------------------------- modules
void add(ModuleKind kind, const std::string& name, const Str& id, const Strs& deps,
LibType type)
{
Project project = requireProject();
NewModule request;
request.kind = kind;
request.name = name;
request.id = id;
request.deps = deps;
request.libType = type;
Module m = addModule(project, request);
std::cout << "added " << kindName(m.kind) << " module '" << m.id << "' in "
<< fs::generic(std::filesystem::relative(m.dir, project.root)) << "\n";
}
void addExec(const std::string& name, const Str& id, const Strs& deps)
{
add(ModuleKind::Exec, name, id, deps, LibType::Unspecified);
}
void addLib(const std::string& name, const Str& id, const Strs& deps, bool isStatic,
bool isShared)
{
if (isStatic == isShared)
throw Error("a library is either --static or --shared; say which (not both)");
add(ModuleKind::Lib, name, id, deps, isShared ? LibType::Shared : LibType::Static);
}
void addObj(const std::string& name, const Str& id, const Strs& deps)
{
add(ModuleKind::Obj, name, id, deps, LibType::Unspecified);
}
void addTest(const std::string& name, const Str& id, const Strs& deps)
{
add(ModuleKind::Test, name, id, deps, LibType::Unspecified);
}
void addExample(const std::string& name, const Str& id, const Strs& deps)
{
add(ModuleKind::Example, name, id, deps, LibType::Unspecified);
}
void deleteIt(const std::string& id, bool yes)
{
Project project = requireProject();
std::vector<Module> modules = scanModules(project);
const Module* m = findModule(modules, id);
if (m == nullptr)
throw Error("there is no module '" + id + "' (see `breeze ls`)");
std::string where = fs::generic(std::filesystem::relative(m->dir, project.root));
if (!yes)
{
if (!proc::stdinIsTerminal())
throw Error("deleting '" + m->id + "' removes " + where +
"; pass --yes to do it without asking");
std::cout << "Delete module '" << m->id << "' and everything in " << where
<< "? [y/N] " << std::flush;
std::string answer;
std::getline(std::cin, answer);
if (text::lower(text::trim(answer)) != "y" &&
text::lower(text::trim(answer)) != "yes")
{
std::cout << "not deleted\n";
return;
}
}
deleteModule(project, id);
std::cout << "deleted " << where << "\n";
}
void rename(const std::string& id, const Str& name, const Str& newId)
{
Project project = requireProject();
RenameResult r = renameModule(project, id, name, newId);
if (r.oldDir != r.newDir)
std::cout << "directory " << r.oldDir << " -> " << r.newDir << "\n";
if (r.oldId != r.newId)
std::cout << "id " << r.oldId << " -> " << r.newId << "\n";
for (const std::string& d : r.updatedDependents)
std::cout << "updated DEPENDS in " << d << "\n";
}
void list(const Str& filter, bool exec, bool obj, bool lib, bool test, bool example,
bool isShared, bool isStatic)
{
Project project = requireProject();
std::vector<Module> modules = scanModules(project);
bool anyKind = exec || obj || lib || test || example;
std::size_t idWidth = 2;
std::size_t dirWidth = 3;
std::size_t kindWidth = 4;
std::vector<const Module*> shown;
for (const Module& m : modules)
{
bool kindOk = !anyKind || (m.kind == ModuleKind::Exec && exec) ||
(m.kind == ModuleKind::Obj && obj) ||
(m.kind == ModuleKind::Lib && lib) ||
(m.kind == ModuleKind::Test && test) ||
(m.kind == ModuleKind::Example && example);
if (!kindOk)
continue;
if (isShared && !(m.kind == ModuleKind::Lib && m.libType == LibType::Shared))
continue;
if (isStatic && !(m.kind == ModuleKind::Lib && m.libType == LibType::Static))
continue;
if (filter && text::lower(m.id).find(text::lower(*filter)) == std::string::npos &&
text::lower(m.dirName).find(text::lower(*filter)) == std::string::npos)
continue;
shown.push_back(&m);
kindWidth = std::max(
kindWidth,
kindName(m.kind).size() +
(m.kind == ModuleKind::Lib && m.libType != LibType::Unspecified ? 7 : 0));
idWidth = std::max(idWidth, m.id.size());
dirWidth = std::max(
dirWidth, fs::generic(std::filesystem::relative(m.dir, project.root)).size());
}
if (shown.empty())
{
std::cout << "no modules\n";
return;
}
std::cout << std::left << std::setw(static_cast<int>(idWidth) + 2) << "ID"
<< std::setw(static_cast<int>(kindWidth) + 2) << "KIND"
<< std::setw(static_cast<int>(dirWidth) + 2) << "DIRECTORY" << "DEPENDS\n";
for (const Module* m : shown)
{
std::string kind = kindName(m->kind);
if (m->kind == ModuleKind::Lib && m->libType != LibType::Unspecified)
kind += m->libType == LibType::Shared ? "/shared" : "/static";
std::string dir = fs::generic(std::filesystem::relative(m->dir, project.root));
std::cout << std::left << std::setw(static_cast<int>(idWidth) + 2) << m->id
<< std::setw(static_cast<int>(kindWidth) + 2) << kind;
if (m->deps.empty())
std::cout << dir << "\n";
else
std::cout << std::setw(static_cast<int>(dirWidth) + 2) << dir
<< text::join(m->deps, " ") << "\n";
}
}
void printTree(const std::vector<Module>& modules, const std::string& id, bool dependencies,
int depth, std::set<std::string>& onPath)
{
const Module* m = findModule(modules, id);
std::vector<std::string> next;
if (m != nullptr)
{
next = dependencies ? m->deps : dependentsOf(modules, *m, false);
}
for (const std::string& n : next)
{
bool known = findModule(modules, n) != nullptr;
std::cout << std::string(static_cast<std::size_t>(depth) * 2, ' ') << n
<< (known ? "" : " (external)") << "\n";
if (known && onPath.insert(n).second)
{
printTree(modules, n, dependencies, depth + 1, onPath);
onPath.erase(n);
}
}
}
void deps(bool of, const std::string& module, bool recursive)
{
Project project = requireProject();
std::vector<Module> modules = scanModules(project);
const Module* m = findModule(modules, module);
if (m == nullptr)
throw Error("there is no module '" + module + "' (see `breeze ls`)");
std::vector<std::string> direct =
of ? dependenciesOf(modules, *m, false) : dependentsOf(modules, *m, false);
if (direct.empty())
{
std::cout << (of ? m->id + " depends on nothing\n"
: "nothing depends on " + m->id + "\n");
return;
}
if (!recursive)
{
for (const std::string& d : direct)
std::cout << d << (findModule(modules, d) != nullptr ? "" : " (external)")
<< "\n";
return;
}
std::set<std::string> onPath{m->id};
printTree(modules, m->id, of, 0, onPath);
}
void sdkVersion()
{
Project project = requireProject();
std::cout << project.sdk.describe() << "\n";
}
void sdkUpdate()
{
sdk::update(requireProject());
}
void sdkUpgrade()
{
sdk::upgrade(requireProject());
}
void sdkMigrate(const std::string& version)
{
sdk::migrate(requireProject(), version);
}
// ---------------------------------------------------------------- cli
void cliVersion()
{
std::cout << self::version() << "\n";
}
void cliPick(const std::string& version)
{
self::pick(version);
}
void cliInstall(const std::string& version)
{
self::install(version);
}
void cliUninstall(const std::string& version)
{
self::uninstall(version);
}
void cliList()
{
self::list();
}
// ---------------------------------------------------------------- tools
void toolInstall(const std::string& repo)
{
tools::install(requireProject(), repo);
}
void toolUninstall(const std::string& id)
{
tools::uninstall(requireProject(), id);
}
void toolUpdate(const std::string& id)
{
tools::update(requireProject(), id);
}
void toolUpgrade(const std::string& id)
{
tools::upgrade(requireProject(), id);
}
void toolMigrate(const std::string& id, const std::string& version)
{
tools::migrate(requireProject(), id, version);
}
void toolList()
{
tools::list(requireProject());
}
// Only here so that `breeze --help` shows it: main() hands `tool <id> ...` to the tool
// before CLIForge sees it.
void toolRun(const std::string& /*id*/, const Strs& /*arguments*/)
{
}
void projectNew(const std::string& name, const Str& id, const Str& sdkVersion)
{
sdk::createProject(name, id, sdkVersion);
}
}
void registerCommands(cliforge::Engine& cli)
{
using std::int32_t;
// ---- cli
cli.command()
.keyword("cli")
.keyword("version")
.describe("Shows this CLI's version")
.action(&cliVersion);
cli.command()
.keyword("cli")
.keyword("pick")
.parameter<std::string>("version", "An installed CLI version")
.describe("Picks the CLI version that runs from now on")
.action(&cliPick);
cli.command()
.keyword("cli")
.keyword("install")
.parameter<std::string>("version", "The CLI version to install")
.describe(
"Installs a CLI version (this program's own, or a release from the CLI source)")
.action(&cliInstall);
cli.command()
.keyword("cli")
.keyword("uninstall")
.parameter<std::string>("version", "An installed CLI version")
.describe("Uninstalls a CLI version")
.action(&cliUninstall);
cli.command()
.keyword("cli")
.keyword("ls")
.describe("Lists the installed CLI versions")
.action(&cliList);
// ---- project
cli.command()
.keyword("project")
.keyword("new")
.parameter<std::string>("name", "The new project's name (and directory)")
.option<std::string>("id", '\0', "The project's id (default: the name in lower case)")
.option<std::string>("sdk", '\0', "The SDK version to start from (default: the latest)")
.describe("Creates a new project")
.action(&projectNew);
cli.command()
.keyword("project")
.keyword("version")
.option<std::string>("set", '\0', "Sets the project's version (major.minor[.patch])")
.describe("Shows or sets the project's version")
.action([](const Str& set) { showOrSet(Setting::Version, set); });
cli.command()
.keyword("project")
.keyword("name")
.option<std::string>("set", '\0', "Sets the project's name")
.describe("Shows or sets the project's name (the CMake project() name)")
.action([](const Str& set) { showOrSet(Setting::Name, set); });
cli.command()
.keyword("project")
.keyword("id")
.option<std::string>("set", '\0', "Sets the project's id")
.describe("Shows or sets the project's id (lower-case; the install component)")
.action([](const Str& set) { showOrSet(Setting::Id, set); });
// ---- setup
cli.command()
.keyword("setup")
.flag("required", '\0', "Install the required tools without asking")
.flag("all", '\0', "Also install the optional tools without asking")
.describe(
"Checks the tools the project builds with and offers to install what is missing")
.action(&setup);
// ---- config
cli.command()
.keyword("config")
.keyword("editor")
.option<std::string>("set", '\0', "Sets the editor ('none' clears it)")
.describe("Shows or sets the editor the CLI opens files with")
.action([](const Str& set) { configValue(config::Editor, set); });
cli.command()
.keyword("config")
.keyword("sdk-source")
.option<std::string>("set", '\0',
"Sets the SDK source: a git repository (URL or path), or a "
"directory of versions ('none' clears it)")
.describe("Shows or sets where SDK versions come from")
.action([](const Str& set) { configValue(config::SdkSource, set); });
cli.command()
.keyword("config")
.keyword("cli-source")
.option<std::string>(
"set", '\0',
"Sets the CLI source: a URL or directory of releases ('none' clears it)")
.describe("Shows or sets where CLI versions come from")
.action([](const Str& set) { configValue(config::CliSource, set); });
// ---- generation
cli.command()
.keyword("clean")
.flag("no-build", 'b', "Do not clean build artifacts")
.flag("docs", 'd', "Clean documentation artifacts")
.describe("Cleans build artifacts")
.action(&clean);
cli.command()
.keyword("build")
.option<std::string>("platform", 'P', "The desired platform (Windows, macOS, Linux)")
.option<std::string>("arch", 'A', "The desired architecture (x64, x86, arm32, arm64)")
.option<std::string>(
"compiler", 'C',
"The desired compiler (MSVC, Clang, ClangCl, GCC, MinGW32, MinGW64, ...)")
.option<std::string>("mode", 'M',
"The configuration (Release, Debug, ReleaseDebugSymbols)")
.option<int32_t>("jobs", 'j', "The job count for the compilation")
.option<std::string>("preset", 'p', "The CMake preset to use")
.option<std::string>("cargs", '\0', "CMake configuration arguments", true)
.option<std::string>("bargs", '\0', "CMake build arguments", true)
.flag("no-configure", '\0', "Do not perform CMake configure")
.describe("Configures and builds the project")
.action(&build);
cli.command()
.keyword("install")
.option<std::string>("platform", 'P', "The desired platform (Windows, macOS, Linux)")
.option<std::string>("arch", 'A', "The desired architecture (x64, x86, arm32, arm64)")
.option<std::string>(
"compiler", 'C',
"The desired compiler (MSVC, Clang, ClangCl, GCC, MinGW32, MinGW64, ...)")
.option<std::string>("mode", 'M',
"The configuration (Release, Debug, ReleaseDebugSymbols)")
.option<int32_t>("jobs", 'j', "The job count for the compilation")
.option<std::string>("preset", 'p', "The CMake preset to use")
.option<std::string>("cargs", '\0', "CMake configuration arguments", true)
.option<std::string>("bargs", '\0', "CMake build arguments", true)
.flag("no-configure", '\0', "Do not perform CMake configure")
.flag("no-build", '\0', "Do not perform CMake build")
.describe("Configures, builds and installs the project")
.action(&install);
cli.command().keyword("docs").describe("Builds the project documentation").action(&docs);
// ---- defaults
for (const char* key : {"platform", "arch", "compiler", "preset", "mode"})
{
cli.command()
.keyword("defaults")
.keyword("build")
.keyword(key)
.option<std::string>("set", '\0',
"Sets this default to the given value ('none' clears it)")
.describe(std::string("Gets or sets the project default build ") + key)
.action([key](const Str& set) { defaultsBuild(key, set); });
}
cli.command()
.keyword("defaults")
.keyword("run")
.option<std::string>("set", '\0',
"Sets this default to the given value ('none' clears it)")
.describe(
"Gets or sets the default executable to run if no `id` is given to `breeze run`")
.action(&defaultsRun);
// ---- run (the id is optional: two commands)
cli.command()
.keyword("run")
.option<std::string>("preset", 'p', "The preset to run")
.option<std::string>("platform", 'P', "The desired platform (Windows, macOS, Linux)")
.option<std::string>("arch", 'A', "The desired architecture (x64, x86, arm32, arm64)")
.option<std::string>(
"compiler", 'C',
"The desired compiler (MSVC, Clang, ClangCl, GCC, MinGW32, MinGW64, ...)")
.option<std::string>("mode", 'M',
"The configuration (Release, Debug, ReleaseDebugSymbols)")
.option<std::string>("args", 'x', "The arguments to pass to the executable", true)
.describe("Runs the default executable (see `breeze defaults run`)")
.action(&runDefault);
cli.command()
.keyword("run")
.parameter<std::string>("id", "The executable module to run")
.option<std::string>("preset", 'p', "The preset to run")
.option<std::string>("platform", 'P', "The desired platform (Windows, macOS, Linux)")
.option<std::string>("arch", 'A', "The desired architecture (x64, x86, arm32, arm64)")
.option<std::string>(
"compiler", 'C',
"The desired compiler (MSVC, Clang, ClangCl, GCC, MinGW32, MinGW64, ...)")
.option<std::string>("mode", 'M',
"The configuration (Release, Debug, ReleaseDebugSymbols)")
.option<std::string>("args", 'x', "The arguments to pass to the executable", true)
.describe("Runs an executable (primary executable, test or example)")
.action(&runById);
// ---- modules
cli.command()
.keyword("add")
.keyword("exec")
.parameter<std::string>("name", "The module's name (its directory)")
.option<std::string>("id", '\0', "The CMake target id (default: <name>_exe)")
.option<std::string>("deps", '\0', "Modules or targets it depends on", true)
.describe("Adds an executable module")
.action(&addExec);
cli.command()
.keyword("add")
.keyword("lib")
.parameter<std::string>("name", "The module's name (its directory)")
.option<std::string>("id", '\0', "The CMake target id (default: <name>_lib)")
.option<std::string>("deps", '\0', "Modules or targets it depends on", true)
.flag("static", '\0', "A static library")
.flag("shared", '\0', "A shared library")
.describe("Adds a library module")
.action(&addLib);
cli.command()
.keyword("add")
.keyword("obj")
.parameter<std::string>("name", "The module's name (its directory)")
.option<std::string>("id", '\0', "The CMake target id (default: <name>_obj)")
.option<std::string>("deps", '\0', "Modules or targets it depends on", true)
.describe("Adds an objects module: compiled to object files that a library or "
"executable takes in")
.action(&addObj);
cli.command()
.keyword("add")
.keyword("test")
.parameter<std::string>("name", "The module's name (its directory)")
.option<std::string>("id", '\0', "The CMake target id (default: <name>_test)")
.option<std::string>("deps", '\0', "Modules or targets it depends on", true)
.describe("Adds a test module")
.action(&addTest);
cli.command()
.keyword("add")
.keyword("example")
.parameter<std::string>("name", "The module's name (its directory)")
.option<std::string>("id", '\0', "The CMake target id (default: <name>_example)")
.option<std::string>("deps", '\0', "Modules or targets it depends on", true)
.describe("Adds an example module")
.action(&addExample);
cli.command()
.keyword("delete")
.parameter<std::string>("id", "The module's id (or directory name)")
.flag("yes", 'y', "Delete without asking")
.describe("Deletes a module")
.action(&deleteIt);
cli.command()
.keyword("rename")
.parameter<std::string>("id", "The module's id (or directory name)")
.option<std::string>("name", '\0', "The new name (directory)")
.option<std::string>("id", '\0', "The new id")
.describe(
"Renames a module's directory and/or id, updating the modules that depend on it")
.action(&rename);
cli.command()
.keyword("ls")
.option<std::string>("filter", '\0',
"Only modules whose id or directory contains this text")
.flag("exec", '\0', "Executable modules")
.flag("obj", '\0', "Objects modules")
.flag("lib", '\0', "Library modules")
.flag("test", '\0', "Test modules")
.flag("example", '\0', "Example modules")
.flag("shared", '\0', "Only shared libraries")
.flag("static", '\0', "Only static libraries")
.describe("Lists the project's modules")
.action(&list);
// ---- deps
cli.command()
.keyword("deps")
.keyword("of")
.parameter<std::string>("module", "The module's id (or directory name)")
.flag("recursive", '\0', "Follow dependencies of dependencies")
.describe("Lists what a module depends on")
.action([](const std::string& module, bool recursive)
{ deps(true, module, recursive); });
cli.command()
.keyword("deps")
.keyword("on")
.parameter<std::string>("module", "The module's id (or directory name)")
.flag("recursive", '\0', "Follow dependents of dependents")
.describe("Lists the modules that depend on a module")
.action([](const std::string& module, bool recursive)
{ deps(false, module, recursive); });
// ---- tools
cli.command()
.keyword("tool")
.keyword("install")
.parameter<std::string>("repo", "The tool's git repository (URL or path)")
.describe("Installs a tool into the project")
.action(&toolInstall);
cli.command()
.keyword("tool")
.keyword("uninstall")
.parameter<std::string>("id", "The tool's id")
.describe("Uninstalls a tool from the project")
.action(&toolUninstall);
cli.command()
.keyword("tool")
.keyword("update")
.parameter<std::string>("id", "The tool's id")
.describe("Looks for newer versions of a tool")
.action(&toolUpdate);
cli.command()
.keyword("tool")
.keyword("upgrade")
.parameter<std::string>("id", "The tool's id")
.describe("Moves a tool to the newest version of its major version")
.action(&toolUpgrade);
cli.command()
.keyword("tool")
.keyword("migrate")
.parameter<std::string>("id", "The tool's id")
.parameter<std::string>("version", "The version to move to")
.describe("Moves a tool to a given version")
.action(&toolMigrate);
cli.command()
.keyword("tool")
.keyword("ls")
.describe("Lists the project's tools")
.action(&toolList);
cli.command()
.keyword("tool")
.parameter<std::string>("id", "An installed tool")
.parameter<std::string>("arguments", "What the tool is asked to do", true)
.describe("Runs a command a tool adds: breeze tool <id> <arguments...>")
.action(&toolRun);
// ---- sdk
cli.command()
.keyword("sdk")
.keyword("version")
.describe("Shows the project's SDK version")
.action(&sdkVersion);
cli.command()
.keyword("sdk")
.keyword("update")
.describe("Looks for newer SDK versions")
.action(&sdkUpdate);
cli.command()
.keyword("sdk")
.keyword("upgrade")
.describe("Moves the project to the newest SDK version of its major version")
.action(&sdkUpgrade);
cli.command()
.keyword("sdk")
.keyword("migrate")
.parameter<std::string>("version", "The SDK version to move to")
.describe("Moves the project to a given SDK version (across majors, and from the "
"legacy layout)")
.action(&sdkMigrate);
}
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <CLIForge/CLIForge.hpp>
namespace breeze::cli
{
/// Registers every command of the spec on the engine.
void registerCommands(cliforge::Engine& engine);
/// The exit code the last command that ran a program asked for. Actions can't return one
/// (CLIForge ignores what they return), so they leave it here and main() ends with it.
int& exitCode();
}
+56
View File
@@ -0,0 +1,56 @@
#include <iostream>
#include <string>
#include <vector>
#include <CLIForge/CLIForge.hpp>
#include "Commands.hpp"
#include "breeze/Arguments.hpp"
#include "breeze/Cli.hpp"
#include "breeze/Error.hpp"
#include "breeze/Project.hpp"
#include "breeze/Tools.hpp"
int main(int argc, char** argv)
{
try
{
std::vector<std::string> given(argv + 1, argv + argc);
// Another CLI version may be the picked one: it takes over (never for `cli ...`).
breeze::self::forwardToPicked(given);
// `breeze tool <id> ...` is the tool's own command line, which CLIForge knows nothing
// about.
if (given.size() >= 2 && given[0] == "tool" && given[1].rfind('-', 0) != 0 &&
!breeze::tools::isReservedName(given[1]))
{
breeze::Project project = breeze::requireProject();
std::optional<int> rc = breeze::tools::run(
project, given[1], std::vector<std::string>(given.begin() + 2, given.end()));
if (!rc)
throw breeze::Error("there is no tool '" + given[1] +
"' installed in this project (see `breeze tool ls`)");
return *rc;
}
cliforge::Engine engine("breeze");
breeze::cli::registerCommands(engine);
std::vector<std::string> args = breeze::cli::normalizeArguments(given);
std::vector<char*> forwarded{argv[0]};
for (std::string& a : args)
forwarded.push_back(a.data());
int rc = engine.run(static_cast<int>(forwarded.size()), forwarded.data());
return rc != 0 ? rc : breeze::cli::exitCode();
}
catch (const breeze::Error& e)
{
std::cerr << "breeze: " << e.what() << "\n";
return e.exitCode();
}
catch (const std::exception& e)
{
std::cerr << "breeze: unexpected error: " << e.what() << "\n";
return 70;
}
}
+20
View File
@@ -0,0 +1,20 @@
# Everything the breeze CLI does that is not reading a command line: finding
# and reading projects and their SDK, running the SDK's scripts, editing modules,
# managing CLI versions, SDKs and tools. source/Breeze registers the commands
# (with CLIForge) and calls into this; source/tests/CoreTests tests it.
#
# nlohmann/json: an installed copy (3.11 or newer) if there is one, else the
# release tarball, pinned by hash.
add_dependency(nlohmann_json
URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz
URL_HASH SHA256=d6c65aca6b1ed68e7a182f4757257b107ae403032760ed6ef121c9d55e81757d
FIND_PACKAGE_ARGS 3.11 NAMES nlohmann_json)
add_lib_module(breeze_core TYPE STATIC OUTPUT_NAME "breeze_core")
# PUBLIC: Config.hpp and Project.hpp have nlohmann::json members, so whoever includes them needs
# its headers (DEPENDS would link it PRIVATE).
target_link_libraries(breeze_core PUBLIC nlohmann_json::nlohmann_json)
# The version of the program, from the project's own (project/Configuration.cmake).
target_compile_definitions(breeze_core PRIVATE BREEZE_CLI_VERSION="${PROJECT_VERSION}")
@@ -0,0 +1,15 @@
#pragma once
#include <string>
#include <vector>
namespace breeze::cli
{
/// CLIForge reads every token that starts with '-' as an option, so `--cargs -DBUILD_TESTS=ON`
/// would try to find an option called -D. The values of the pass-through options (--cargs,
/// --bargs and run's --args/-x) are rewritten to the `--cargs=-DBUILD_TESTS=ON` form CLIForge
/// does accept, each taking tokens up to the next option of the command.
std::vector<std::string> normalizeArguments(const std::vector<std::string>& args);
}
@@ -0,0 +1,55 @@
#pragma once
#include <filesystem>
#include <map>
#include <optional>
#include <string>
#include <vector>
#include "breeze/Presets.hpp"
#include "breeze/Project.hpp"
/// Building, installing, running and cleaning a project -- by driving the SDK's own scripts, so the
/// environment they set up (the pinned Ninja, the MSVC environment on Windows) is always the one
/// used. Where a request needs something the scripts don't offer (extra `cmake --build` arguments,
/// skipping the configure), the CLI runs `cmake --build` itself against the build the scripts made.
namespace breeze
{
struct BuildRequest
{
PresetQuery preset;
/// Release, Debug, ReleaseDebugSymbols or MinSizeRel (any case).
std::optional<std::string> mode;
std::optional<int> jobs;
std::vector<std::string> configureArgs;
std::vector<std::string> buildArgs;
bool noConfigure = false;
bool noBuild = false;
};
/// The CMake build type for a mode name; throws Error for an unknown one.
std::string buildTypeFor(const std::string& mode);
/// Values of the project's settings (BUILD_OUTPUT_DIR, DOCS_OUTPUT_DIR, ...) as the SDK
/// computes them. Falls back to the SDK's defaults for a legacy layout that can't be asked.
std::map<std::string, std::string> readConfigVariables(const Project& project,
const std::vector<std::string>& names);
/// The build directory for the preset (or the one built last), as the SDK defines it.
std::filesystem::path buildDirFor(const Project& project,
const std::optional<std::string>& preset);
/// Each returns the exit code of the command that did the work.
int build(const Project& project, const BuildRequest& request);
int install(const Project& project, const BuildRequest& request);
int docs(const Project& project);
int run(const Project& project, const std::optional<std::string>& id, const PresetQuery& where,
const std::vector<std::string>& programArgs);
int setup(const Project& project, bool required, bool all);
/// Removes the build directories (unless keepBuild) and the documentation (if alsoDocs).
/// Returns the paths removed, relative to the project.
std::vector<std::string> clean(const Project& project, bool keepBuild, bool alsoDocs);
}
@@ -0,0 +1,49 @@
#pragma once
#include <cstddef>
#include <functional>
#include <optional>
#include <string>
#include <vector>
/// Just enough of CMake's syntax to find and edit a command call in a CMakeLists.txt or a settings
/// file without disturbing anything else in it: comments, layout and line endings stay as they
/// were.
namespace breeze::cmake
{
/// One argument of a call. begin/end are byte offsets of the argument as written (quotes
/// included); text is its value without the quotes.
struct Token
{
std::size_t begin = 0;
std::size_t end = 0;
std::string text;
bool quoted = false;
};
/// A command call: name(args...). begin/end span the whole call, "name(" through ")".
struct Call
{
std::string name;
std::size_t begin = 0;
std::size_t end = 0;
std::vector<Token> args;
};
/// Every call in `text`, in order, whose name `wanted` accepts. Comments, quoted strings and
/// the arguments of other calls are never mistaken for calls.
std::vector<Call> findCalls(const std::string& text,
const std::function<bool(const std::string&)>& wanted);
/// The first set(<variable> ...) call.
std::optional<Call> findSet(const std::string& text, const std::string& variable);
/// `value` as a quoted CMake argument.
std::string quote(const std::string& value);
/// Replaces the token's text in `text` (offsets of later tokens are stale afterwards).
void replaceToken(std::string& text, const Token& token, const std::string& replacement);
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
#include "breeze/Version.hpp"
/// The CLI managing itself: which versions of it are installed, which one is picked, and running
/// the picked one. Versions live in <breeze home>/cli/<version>/; `cli/current` names the picked
/// one, and <breeze home>/bin/breeze is the copy to put on PATH. Whatever `breeze` the shell finds
/// hands the command to the picked version, so installing or picking never means changing PATH
/// again.
namespace breeze::self
{
/// The version of this program.
std::string version();
/// Installed versions, oldest first.
std::vector<Version> installed();
/// The version `cli pick` chose, if any.
std::optional<Version> picked();
/// Installs a version: this very program if that is its version, else a release of the
/// configured CLI source. The first version installed is picked.
void install(const std::string& version);
void pick(const std::string& version);
/// Removes a version; un-picks it if it was the picked one.
void uninstall(const std::string& version);
/// Prints the installed versions, marking the picked one and the running one.
void list();
/// If another version is picked, installed and runnable, runs it with `args` and does not
/// return. `cli ...` commands always run in the version that was started.
void forwardToPicked(const std::vector<std::string>& args);
}
@@ -0,0 +1,46 @@
#pragma once
#include <filesystem>
#include <optional>
#include <string>
#include <nlohmann/json.hpp>
/// The CLI's own, global state: where it lives and what the user configured.
namespace breeze
{
/// $BREEZE_HOME, or ~/.breeze. Holds config.json, the installed CLI versions (cli/), the SDK
/// snapshots (sdk/) and cached indexes.
std::filesystem::path breezeHome();
/// The keys of config.json. Each can also come from the environment (BREEZE_EDITOR,
/// BREEZE_SDK_SOURCE, BREEZE_CLI_SOURCE), which wins.
namespace config
{
inline constexpr const char* Editor = "editor";
inline constexpr const char* SdkSource = "sdkSource";
inline constexpr const char* CliSource = "cliSource";
}
class GlobalConfig
{
public:
/// Reads config.json (an absent file is an empty configuration).
static GlobalConfig load();
/// The environment's value for the key if set, else the file's.
[[nodiscard]] std::optional<std::string> effective(const std::string& key) const;
/// The file's value only.
[[nodiscard]] std::optional<std::string> stored(const std::string& key) const;
void set(const std::string& key, const std::string& value);
void erase(const std::string& key);
void save() const;
private:
nlohmann::json m_data = nlohmann::json::object();
};
}
@@ -0,0 +1,24 @@
#pragma once
#include <stdexcept>
#include <string>
namespace breeze
{
/// Every failure the CLI reports to the user: a message and the process exit code to end with.
class Error : public std::runtime_error
{
public:
explicit Error(const std::string& message, int exitCode = 1)
: std::runtime_error(message), m_exitCode(exitCode)
{
}
[[nodiscard]] int exitCode() const noexcept { return m_exitCode; }
private:
int m_exitCode;
};
}
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
/// File system and host helpers. Paths are std::filesystem::path throughout; text files are read
/// and written as bytes, so the line endings a file has are the ones it keeps.
namespace breeze::fs
{
using std::filesystem::path;
/// Throws Error when the file can't be read.
std::string readText(const path& file);
std::optional<std::string> tryReadText(const path& file);
/// Creates the parent directories, then writes the file (replacing it).
void writeText(const path& file, const std::string& text);
/// Copies `from` into `to` (merging into what is there, overwriting files). Entries whose name
/// is in `skipNames` are left out at every level.
void copyTree(const path& from, const path& to, const std::vector<std::string>& skipNames = {});
/// Removes the file or directory tree if it exists. Throws Error on failure.
void removeTree(const path& p);
/// A new, empty directory under the system's temp directory.
path makeTempDir(const std::string& prefix);
/// The user's home directory (HOME, or USERPROFILE on Windows).
path homeDir();
/// Where the running program's executable is.
path executablePath();
/// "linux", "macos" or "windows".
std::string hostPlatform();
/// "x64", "arm64", "arm32" or "x86".
std::string hostArch();
/// chmod +x on POSIX; nothing on Windows.
void makeExecutable(const path& file);
/// True for a regular file that can be run.
bool isExecutable(const path& file);
/// The path with forward slashes, for messages that read the same on every OS.
std::string generic(const path& p);
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <filesystem>
#include <string>
#include <vector>
#include "breeze/Version.hpp"
/// The little of git the CLI needs: listing the version tags of a repository and exporting one
/// tag's files. Repositories are URLs or local paths; git itself must be installed.
namespace breeze::git
{
struct Tag
{
/// The tag as written in the repository, e.g. "v1.2.0".
std::string name;
Version version;
};
/// Is `source` something git can clone (a URL, or a local repository) rather than a plain
/// directory?
bool isRepository(const std::string& source);
/// The tags of the repository that are versions, in no particular order. Throws Error when the
/// repository can't be reached.
std::vector<Tag> versionTags(const std::string& repository);
/// Exports the files of `tag` (the default branch's tip when empty) into `destination`, without
/// the repository's own history.
void exportTree(const std::string& repository, const std::string& tag,
const std::filesystem::path& destination);
}
@@ -0,0 +1,97 @@
#pragma once
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
#include "breeze/Project.hpp"
/// A project's modules: the directories under source/ (and source/tests/, source/examples/) that
/// each register one CMake target with one of the SDK's add_*_module macros. They are read from the
/// CMakeLists.txt files themselves, so what the CLI shows is what CMake will do, and the files it
/// writes are the ones a person would have written.
namespace breeze
{
enum class ModuleKind
{
Exec,
Lib,
Obj,
Test,
Example
};
enum class LibType
{
Unspecified,
Static,
Shared
};
/// "exec", "lib", "obj", "test" or "example".
std::string kindName(ModuleKind kind);
struct Module
{
/// The CMake target: what DEPENDS, `breeze run` and `breeze deps` call it.
std::string id;
/// The directory's name.
std::string dirName;
std::filesystem::path dir;
ModuleKind kind = ModuleKind::Exec;
LibType libType = LibType::Unspecified;
std::string outputName;
std::vector<std::string> deps;
/// The macro that registered it, e.g. add_lib_module.
std::string macro;
};
/// Every module, main ones first (by directory name), then tests, then examples.
std::vector<Module> scanModules(const Project& project);
/// By id, else by directory name. Null when there is no such module.
const Module* findModule(const std::vector<Module>& modules, const std::string& idOrDir);
struct NewModule
{
ModuleKind kind = ModuleKind::Exec;
std::string name;
std::optional<std::string> id;
std::vector<std::string> deps;
LibType libType = LibType::Unspecified;
};
/// Creates the module's directory, CMakeLists.txt and starter files. Throws Error on anything
/// it would have to guess about or overwrite.
Module addModule(const Project& project, const NewModule& request);
/// Removes the module's directory. Refuses while another module still depends on it.
void deleteModule(const Project& project, const std::string& idOrDir);
struct RenameResult
{
std::string oldId;
std::string newId;
std::string oldDir;
std::string newDir;
/// Modules whose CMakeLists.txt had a DEPENDS entry updated.
std::vector<std::string> updatedDependents;
};
/// Renames the directory and/or the id, and rewrites DEPENDS entries elsewhere to the new id.
RenameResult renameModule(const Project& project, const std::string& idOrDir,
const std::optional<std::string>& newName,
const std::optional<std::string>& newId);
/// What `module` depends on, without repeats, in the order met (nearest first). Entries that
/// are not modules of the project (fmt::fmt, pthread) are included as they were written.
std::vector<std::string> dependenciesOf(const std::vector<Module>& modules,
const Module& module, bool recursive);
/// The modules that depend on `module`, directly or (recursive) through others.
std::vector<std::string> dependentsOf(const std::vector<Module>& modules, const Module& module,
bool recursive);
}
@@ -0,0 +1,45 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
#include "breeze/Project.hpp"
/// The project's CMake presets, which are what a platform, an architecture and a compiler mean:
/// every SDK names its configure presets "<platform>-<arch>-<compiler>" (linux-x64-gcc,
/// windows-x64-clang-cl).
namespace breeze
{
struct Preset
{
std::string name;
std::string description;
std::string platform; // linux, macos, windows
std::string arch; // x64, x86, arm32, arm64
std::string compiler; // the rest of the name: gcc, clang, msvc, clang-cl, mingw64, ...
};
/// The configure presets usable on this machine (what `cmake --list-presets` shows).
std::vector<Preset> listPresets(const Project& project);
/// What was asked for; every field is optional and compared ignoring case and punctuation
/// ("ClangCl" finds clang-cl).
struct PresetQuery
{
std::optional<std::string> preset;
std::optional<std::string> platform;
std::optional<std::string> arch;
std::optional<std::string> compiler;
};
/// The preset a command should use: the named preset; else one made from the platform,
/// architecture and compiler given, the project's defaults and then this machine for whatever
/// is missing; else the project's default preset; else none (the SDK's own default). Throws an
/// Error that lists the choices when what was asked for doesn't exist. `note` gets a line to
/// show the user when the choice involved a guess.
std::optional<std::string> resolvePreset(const Project& project, const Defaults& defaults,
const PresetQuery& query, std::string* note = nullptr);
}
@@ -0,0 +1,56 @@
#pragma once
#include <filesystem>
#include <optional>
#include <string>
#include <utility>
#include <vector>
/// Running other programs. Arguments are passed as a vector, never through a shell, so nothing
/// needs quoting by the caller.
namespace breeze::proc
{
struct Options
{
/// Working directory; empty keeps the current one.
std::filesystem::path cwd;
/// Variables set (or replaced) for the child only.
std::vector<std::pair<std::string, std::string>> env;
/// Collect the child's standard output into Result::output instead of showing it.
bool capture = false;
/// With capture, standard error is collected too (merged into the same string).
bool mergeStderr = false;
/// Throw the child's output away.
bool silent = false;
};
struct Result
{
int exitCode = 0;
std::string output;
};
/// Runs argv[0] with the rest as arguments (looked up on PATH when it has no directory part)
/// and waits. A program that can't be started is exit code 127. Throws Error only for failures
/// of this function itself (nothing to run, the system refusing to create a process).
Result run(const std::vector<std::string>& argv, const Options& options = {});
/// Runs a .bat/.cmd script on Windows through cmd.exe, or the script itself elsewhere.
Result runScript(const std::filesystem::path& script, const std::vector<std::string>& args,
const Options& options = {});
/// Replaces this process with argv (POSIX), or runs it and exits with its status (Windows).
/// Does not return. Throws Error when it can't be started.
[[noreturn]] void execReplace(const std::vector<std::string>& argv);
/// The first match for `program` on PATH, if any.
std::optional<std::filesystem::path> findInPath(const std::string& program);
/// Sets an environment variable for this process and the programs it starts from now on.
void setEnvironment(const std::string& name, const std::string& value);
bool stdinIsTerminal();
bool stdoutIsTerminal();
}
@@ -0,0 +1,109 @@
#pragma once
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include "breeze/Version.hpp"
/// Finding a breeze project, telling which SDK it has, and reading and writing the project's own
/// settings.
///
/// The CLI is global and projects carry their SDK (the template's breeze/ folder), so each project
/// may be on a different SDK version -- or on the layout before breeze/ existed, with scripts/ and
/// cmake/ at the root. Everything here asks the project's Sdk where things are instead of assuming.
namespace breeze
{
enum class SdkLayout
{
/// breeze/ (the SDK) and project/ (the project's settings).
Breeze,
/// The flat layout that came before: scripts/, cmake/ and Configuration.cmake at the root.
Legacy
};
struct Sdk
{
SdkLayout layout = SdkLayout::Breeze;
/// breeze/VERSION. Empty for the legacy layout and for a breeze/ that predates the file.
std::optional<Version> version;
/// <root>/breeze, or <root> in the legacy layout.
std::filesystem::path dir;
std::filesystem::path scriptsDir;
std::filesystem::path cmakeDir;
/// project/Configuration.cmake, or Configuration.cmake in the legacy layout.
std::filesystem::path settingsFile;
/// Does this SDK have add_objects_module?
bool supportsObjects = false;
/// "1.0.0", "unversioned (breeze/ layout)" or "legacy (flat layout, unversioned)".
[[nodiscard]] std::string describe() const;
};
struct Project
{
std::filesystem::path root;
Sdk sdk;
/// <root>/.breeze: what the CLI keeps in the project (defaults, installed tools).
[[nodiscard]] std::filesystem::path metaDir() const;
[[nodiscard]] std::filesystem::path defaultsFile() const;
[[nodiscard]] std::filesystem::path toolsDir() const;
/// The SDK's script `name` for this OS: scripts/<name>.sh, or .bat on Windows.
[[nodiscard]] std::filesystem::path script(const std::string& name) const;
};
/// Does `root` hold a breeze project, and with which SDK?
std::optional<Sdk> detectSdk(const std::filesystem::path& root);
/// The nearest project at or above `start`.
std::optional<Project> findProject(const std::filesystem::path& start);
/// findProject, or an Error explaining that the command needs a project.
Project requireProject(const std::filesystem::path& start = std::filesystem::current_path());
enum class Setting
{
Name, // CMake's project() name
Version, // the project's own version, major.minor[.patch[.tweak]]
Id // the machine-friendly identifier (PROJECT_CODE_NAME)
};
std::string readSetting(const Project& project, Setting which);
/// Validates the value, then changes just that value in the file, leaving everything else as it
/// is.
void writeSetting(const Project& project, Setting which, const std::string& value);
/// The CLI's per-project defaults, in .breeze/defaults.json (meant to be committed).
class Defaults
{
public:
static Defaults load(const Project& project);
/// key: platform, arch, compiler, preset or mode.
[[nodiscard]] std::optional<std::string> build(const std::string& key) const;
void setBuild(const std::string& key, const std::string& value);
void clearBuild(const std::string& key);
/// The module id `breeze run` runs when it is given none.
[[nodiscard]] std::optional<std::string> run() const;
void setRun(const std::string& id);
void clearRun();
void save(const Project& project) const;
private:
nlohmann::json m_data = nlohmann::json::object();
};
}
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
#include "breeze/Project.hpp"
#include "breeze/Version.hpp"
/// SDK versions, where they come from and what moving a project between them means.
///
/// An SDK version is a snapshot of the whole template: breeze/ (the SDK proper, replaced as a whole
/// on a change), plus the files the project owns and starts from (project/Configuration.cmake, the
/// root CMakeLists.txt, CMakePresets.json, the dot files). Versions come from a *source*, either a
/// git repository whose tags are versions (v1.2.0, 1.2.0) or a directory with one subdirectory per
/// version.
namespace breeze::sdk
{
class Source
{
public:
struct Entry
{
Version version;
/// The tag, or the directory name, it was found under.
std::string ref;
};
/// The configured source (`breeze config sdk-source`, or BREEZE_SDK_SOURCE); throws Error
/// when there is none.
static Source configured();
explicit Source(std::string location);
[[nodiscard]] const std::string& location() const { return m_location; }
/// All versions, oldest first.
[[nodiscard]] std::vector<Entry> versions() const;
/// The files of one version, fetched into the CLI's cache on first use.
[[nodiscard]] std::filesystem::path snapshot(const Entry& entry) const;
/// "latest" (or nothing), or a version number: the matching entry. Throws Error, listing
/// what exists, when there is no such version.
[[nodiscard]] Entry resolve(const std::optional<std::string>& wanted) const;
private:
std::string m_location;
bool m_isRepository = false;
};
/// Creates ./<name> from an SDK version: a project with that SDK, the settings set to the new
/// project's name and id, no demo modules, and one executable module named after the project.
void createProject(const std::string& name, const std::optional<std::string>& id,
const std::optional<std::string>& sdkVersion);
/// Reports which SDK versions exist and whether the project is behind.
void update(const Project& project);
/// Moves the project to the newest version with the same major as its own.
void upgrade(const Project& project);
/// Moves the project to exactly this version, whichever way and across majors, and a legacy
/// (flat) project to the breeze/ layout.
void migrate(const Project& project, const std::string& version);
}
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <string>
#include <string_view>
#include <vector>
/// Small string helpers; nothing here knows about breeze.
namespace breeze::text
{
std::string trim(std::string_view s);
std::vector<std::string> split(std::string_view s, char delimiter);
std::vector<std::string> splitLines(std::string_view s);
std::string join(const std::vector<std::string>& parts, std::string_view separator);
std::string lower(std::string_view s);
bool startsWith(std::string_view s, std::string_view prefix);
bool endsWith(std::string_view s, std::string_view suffix);
std::string replaceAll(std::string s, std::string_view from, std::string_view to);
/// "CoreFarewell" -> "core_farewell", "HTTPServer" -> "http_server", "my-app" -> "my_app".
std::string snakeCase(std::string_view s);
/// Lower-case letters and digits only: "Clang-CL" -> "clangcl". For comparing names loosely.
std::string normalized(std::string_view s);
/// A module or project name: a letter, then letters, digits, '_' (and '-' when allowDash).
bool isName(std::string_view s, bool allowDash = false);
/// A CMake target id as this project writes them: letters, digits, '_', '.', '+', '-' and "::".
bool isTargetId(std::string_view s);
/// "x" for a version-less message, with a '<none>' stand-in for an empty value.
std::string orNone(std::string_view s);
}
@@ -0,0 +1,57 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
#include "breeze/Project.hpp"
/// Tools: extensions a project installs from a repository. A tool is a git repository (or, while it
/// is being written, a directory) with a `breeze-tool.json` at its root:
///
/// { "id": "resembed", "version": "1.0.0", "description": "...", "entry": "bin/resembed.sh" }
///
/// `entry` may instead be { "posix": "...", "windows": "..." }. Installed, a tool is copied into
/// <project>/.breeze/tools/<id>/ and recorded in .breeze/tools.json (meant to be committed). From
/// then on `breeze tool <id> <arguments...>` runs its entry with those arguments, in the project's
/// directory, with the project described in environment variables (BREEZE_PROJECT_ROOT,
/// BREEZE_SDK_DIR, BREEZE_SDK_VERSION, BREEZE_TOOL_ID, BREEZE_TOOL_DIR, BREEZE_CLI, BREEZE_EDITOR).
/// What commands a tool offers, and what they do, is entirely up to the tool.
namespace breeze::tools
{
struct Tool
{
std::string id;
std::string version;
std::string repo;
std::string description;
std::filesystem::path dir;
};
/// The words after `breeze tool` that belong to the CLI itself and can't be tool ids.
bool isReservedName(const std::string& word);
std::vector<Tool> installed(const Project& project);
/// Installs the newest version of the tool in `repo`.
void install(const Project& project, const std::string& repo);
void uninstall(const Project& project, const std::string& id);
/// Reports whether newer versions of the tool exist.
void update(const Project& project, const std::string& id);
/// Moves the tool to its newest version with the same major version.
void upgrade(const Project& project, const std::string& id);
/// Moves the tool to exactly this version.
void migrate(const Project& project, const std::string& id, const std::string& version);
void list(const Project& project);
/// Runs the tool's entry with `args`; the entry's exit code, or nullopt when no such tool is
/// installed.
std::optional<int> run(const Project& project, const std::string& id,
const std::vector<std::string>& args);
}
@@ -0,0 +1,35 @@
#pragma once
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace breeze
{
/// A semantic version: major.minor.patch with an optional pre-release tag.
/// A pre-release sorts before the release it leads up to (1.0.0-rc1 < 1.0.0).
struct Version
{
int major = 0;
int minor = 0;
int patch = 0;
std::string pre;
[[nodiscard]] std::string toString() const;
};
/// Accepts "1.2.3", "v1.2.3", "1.2.3-rc1", "1.2" (patch 0) and "1" (minor and patch 0);
/// anything else, including build metadata after '+', is dropped or rejected.
std::optional<Version> parseVersion(std::string_view text);
int compare(const Version& a, const Version& b);
bool operator<(const Version& a, const Version& b);
bool operator==(const Version& a, const Version& b);
/// The greatest of `versions`; with `sameMajorAs`, only of those with that major version.
std::optional<Version> greatest(const std::vector<Version>& versions,
std::optional<int> sameMajorAs = std::nullopt);
}
+48
View File
@@ -0,0 +1,48 @@
#include "breeze/Arguments.hpp"
#include <set>
namespace breeze::cli
{
std::vector<std::string> normalizeArguments(const std::vector<std::string>& args)
{
if (args.empty())
return args;
const std::string& command = args.front();
if (command != "build" && command != "install" && command != "run")
return args;
// The options of these commands that take other programs' arguments.
static const std::set<std::string> Passthrough = {"--cargs", "--bargs", "--args", "-x"};
// Every option these commands have: where a pass-through list ends.
static const std::set<std::string> Known = {
"--platform", "-P", "--arch", "-A", "--compiler", "-C",
"--mode", "-M", "--jobs", "-j", "--preset", "-p",
"--cargs", "--bargs", "--args", "-x", "--no-configure", "--no-build",
"--help", "-h"};
std::vector<std::string> out;
for (std::size_t i = 0; i < args.size(); ++i)
{
const std::string& token = args[i];
if (i == 0 || Passthrough.count(token) == 0)
{
out.push_back(token);
continue;
}
// `--cargs=...` written by the user is already in the right form and not in
// `Passthrough`.
std::string longName = token == "-x" ? "--args" : token;
while (i + 1 < args.size() && Known.count(args[i + 1]) == 0)
{
out.push_back(longName + "=" + args[i + 1]);
++i;
}
if (out.empty() || out.back().rfind(longName + "=", 0) != 0)
out.push_back(token); // no values: let CLIForge report that
}
return out;
}
}
+321
View File
@@ -0,0 +1,321 @@
#include "breeze/Build.hpp"
#include <iostream>
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Modules.hpp"
#include "breeze/Process.hpp"
#include "breeze/Text.hpp"
namespace breeze
{
namespace sfs = std::filesystem;
std::string buildTypeFor(const std::string& mode)
{
std::string m = text::normalized(mode);
if (m == "release")
return "Release";
if (m == "debug")
return "Debug";
if (m == "releasedebugsymbols" || m == "relwithdebinfo")
return "RelWithDebInfo";
if (m == "minsizerel")
return "MinSizeRel";
throw Error("'" + mode +
"' is not a build mode: use Release, Debug, ReleaseDebugSymbols or MinSizeRel");
}
namespace
{
bool hasFile(const sfs::path& p)
{
std::error_code ec;
return sfs::is_regular_file(p, ec);
}
proc::Options inProject(const Project& project)
{
proc::Options o;
o.cwd = project.root;
return o;
}
sfs::path requireScript(const Project& project, const std::string& name)
{
sfs::path script = project.script(name);
if (!hasFile(script))
throw Error("this project's SDK (" + project.sdk.describe() + ") has no '" +
fs::generic(sfs::relative(script, project.root)) + "'");
return script;
}
std::optional<std::string> modeOf(const BuildRequest& request, const Defaults& defaults)
{
std::optional<std::string> mode = request.mode ? request.mode : defaults.build("mode");
if (!mode)
return std::nullopt;
return buildTypeFor(*mode);
}
std::optional<std::string> presetOf(const Project& project, const Defaults& defaults,
const PresetQuery& query)
{
std::string note;
std::optional<std::string> preset = resolvePreset(project, defaults, query, &note);
if (!note.empty())
std::cerr << "breeze: " << note << "\n";
return preset;
}
bool isUnder(const sfs::path& root, const sfs::path& p)
{
std::error_code ec;
sfs::path r = sfs::weakly_canonical(root, ec);
sfs::path c = sfs::weakly_canonical(p, ec);
auto rel = c.lexically_relative(r);
return !rel.empty() && *rel.begin() != "..";
}
}
std::map<std::string, std::string> readConfigVariables(const Project& project,
const std::vector<std::string>& names)
{
std::map<std::string, std::string> values;
sfs::path printConfig = project.sdk.cmakeDir / "PrintConfig.cmake";
if (hasFile(printConfig))
{
proc::Options o = inProject(project);
o.capture = true;
proc::Result r = proc::run(
{"cmake", "-DPRINT_VARS=" + text::join(names, ";"), "-P", printConfig.string()}, o);
if (r.exitCode == 0)
{
for (const std::string& line : text::splitLines(r.output))
{
if (!text::startsWith(line, "-- "))
continue;
std::size_t eq = line.find('=');
if (eq == std::string::npos)
continue;
values[line.substr(3, eq - 3)] = line.substr(eq + 1);
}
}
}
// Anything the SDK couldn't tell: the layout every SDK so far has used.
for (const std::string& n : names)
{
if (values.count(n) != 0 && !values[n].empty())
continue;
if (n == "BUILD_OUTPUT_DIR")
values[n] = fs::generic(project.root / "out" / "build");
else if (n == "DOCS_OUTPUT_DIR")
values[n] = fs::generic(project.root / "out" / "docs");
}
return values;
}
sfs::path buildDirFor(const Project& project, const std::optional<std::string>& preset)
{
sfs::path buildDirScript = project.sdk.cmakeDir / "BuildDir.cmake";
if (hasFile(buildDirScript))
{
proc::Options o = inProject(project);
o.capture = true;
std::vector<std::string> argv{"cmake"};
if (preset)
argv.push_back("-DPMN_NAME=" + *preset);
argv.push_back("-P");
argv.push_back(buildDirScript.string());
proc::Result r = proc::run(argv, o);
for (const std::string& line : text::splitLines(r.output))
{
if (text::startsWith(line, "-- BUILD_DIR="))
return sfs::path(line.substr(13));
if (text::startsWith(line, "-- ERROR="))
throw Error(line.substr(9));
}
throw Error("could not tell which build directory to use (the SDK's BuildDir.cmake "
"said nothing)");
}
std::map<std::string, std::string> vars =
readConfigVariables(project, {"BUILD_OUTPUT_DIR"});
return sfs::path(vars["BUILD_OUTPUT_DIR"]) / (preset ? *preset : "default");
}
int build(const Project& project, const BuildRequest& request)
{
Defaults defaults = Defaults::load(project);
std::optional<std::string> preset = presetOf(project, defaults, request.preset);
std::optional<std::string> type = modeOf(request, defaults);
proc::Options options = inProject(project);
if (request.jobs)
options.env.emplace_back("CMAKE_BUILD_PARALLEL_LEVEL", std::to_string(*request.jobs));
if (request.buildArgs.empty() && !request.noConfigure)
{
// The ordinary path: the SDK's script configures and builds.
std::vector<std::string> args;
if (preset)
{
args.push_back("--preset");
args.push_back(*preset);
}
if (type)
args.push_back(*type);
args.insert(args.end(), request.configureArgs.begin(), request.configureArgs.end());
return proc::runScript(requireScript(project, "build"), args, options).exitCode;
}
// Extra build arguments, or no configure: build the configured directory directly.
sfs::path dir = buildDirFor(project, preset);
if (!hasFile(dir / "CMakeCache.txt"))
throw Error("'" + fs::generic(dir) +
"' is not configured yet. Run `breeze build` once without " +
(request.noConfigure ? "--no-configure" : "--bargs") + " first");
if (request.noConfigure && !request.configureArgs.empty())
std::cerr << "breeze: --cargs are ignored with --no-configure\n";
std::vector<std::string> argv{"cmake", "--build", dir.string()};
if (type)
{
argv.push_back("--config");
argv.push_back(*type);
}
if (request.jobs)
{
argv.push_back("-j");
argv.push_back(std::to_string(*request.jobs));
}
argv.insert(argv.end(), request.buildArgs.begin(), request.buildArgs.end());
return proc::run(argv, options).exitCode;
}
int install(const Project& project, const BuildRequest& request)
{
if (!request.noBuild)
{
int rc = build(project, request);
if (rc != 0)
return rc;
}
Defaults defaults = Defaults::load(project);
std::optional<std::string> preset = presetOf(project, defaults, request.preset);
std::optional<std::string> type = modeOf(request, defaults);
std::vector<std::string> args;
if (preset)
{
args.push_back("--preset");
args.push_back(*preset);
}
if (type)
args.push_back(*type);
return proc::runScript(requireScript(project, "install"), args, inProject(project))
.exitCode;
}
int docs(const Project& project)
{
Defaults defaults = Defaults::load(project);
std::vector<std::string> args;
if (std::optional<std::string> preset = defaults.build("preset"))
{
args.push_back("--preset");
args.push_back(*preset);
}
int rc = proc::runScript(requireScript(project, "docs"), args, inProject(project)).exitCode;
if (rc != 0 && rc != 127)
std::cerr << "breeze: the documentation needs a build first: run `breeze build`\n";
return rc;
}
int run(const Project& project, const std::optional<std::string>& id, const PresetQuery& where,
const std::vector<std::string>& programArgs)
{
Defaults defaults = Defaults::load(project);
std::string target;
if (id)
{
target = *id;
// A directory name works too, when the module is there.
std::vector<Module> modules = scanModules(project);
if (const Module* m = findModule(modules, *id))
target = m->id;
}
else if (std::optional<std::string> d = defaults.run())
{
target = *d;
}
else
{
std::vector<Module> modules = scanModules(project);
std::vector<std::string> execs;
for (const Module& m : modules)
{
if (m.kind == ModuleKind::Exec)
execs.push_back(m.id);
}
if (execs.size() != 1)
throw Error(std::string(execs.empty() ? "this project has no executable module"
: "which executable? This project has " +
text::join(execs, ", ")) +
". Give an id, or set a default with `breeze defaults run --set <id>`");
target = execs.front();
}
std::optional<std::string> preset = presetOf(project, defaults, where);
std::vector<std::string> args;
if (preset)
{
args.push_back("--preset");
args.push_back(*preset);
}
args.push_back(target);
if (!programArgs.empty())
{
args.push_back("--");
args.insert(args.end(), programArgs.begin(), programArgs.end());
}
return proc::runScript(requireScript(project, "run"), args, inProject(project)).exitCode;
}
int setup(const Project& project, bool required, bool all)
{
std::vector<std::string> args;
if (all)
args.push_back("--all");
else if (required)
args.push_back("--yes");
return proc::runScript(requireScript(project, "setup"), args, inProject(project)).exitCode;
}
std::vector<std::string> clean(const Project& project, bool keepBuild, bool alsoDocs)
{
std::vector<std::string> names;
if (!keepBuild)
names.push_back("BUILD_OUTPUT_DIR");
if (alsoDocs)
names.push_back("DOCS_OUTPUT_DIR");
std::vector<std::string> removed;
if (names.empty())
return removed;
std::map<std::string, std::string> vars = readConfigVariables(project, names);
for (const std::string& n : names)
{
sfs::path dir = vars[n];
if (!isUnder(project.root, dir))
throw Error("refusing to remove '" + fs::generic(dir) +
"': it is outside the project");
std::error_code ec;
if (!sfs::exists(dir, ec))
continue;
fs::removeTree(dir);
removed.push_back(fs::generic(sfs::relative(dir, project.root)));
}
return removed;
}
}
+210
View File
@@ -0,0 +1,210 @@
#include "breeze/CMakeText.hpp"
#include <cctype>
#include "breeze/Text.hpp"
namespace breeze::cmake
{
namespace
{
bool isIdentStart(char c)
{
return std::isalpha(static_cast<unsigned char>(c)) || c == '_';
}
bool isIdentChar(char c)
{
return std::isalnum(static_cast<unsigned char>(c)) || c == '_';
}
// Skips whitespace and comments from pos; returns the new position.
std::size_t skipBlank(const std::string& t, std::size_t pos)
{
while (pos < t.size())
{
char c = t[pos];
if (std::isspace(static_cast<unsigned char>(c)))
{
++pos;
}
else if (c == '#')
{
// bracket comment: #[[ ... ]] or #[=[ ... ]=]
std::size_t eq = pos + 1;
if (eq < t.size() && t[eq] == '[')
{
std::size_t level = 0;
std::size_t p = eq + 1;
while (p < t.size() && t[p] == '=')
{
++level;
++p;
}
if (p < t.size() && t[p] == '[')
{
std::string close = "]" + std::string(level, '=') + "]";
std::size_t endPos = t.find(close, p + 1);
pos = endPos == std::string::npos ? t.size() : endPos + close.size();
continue;
}
}
while (pos < t.size() && t[pos] != '\n')
++pos;
}
else
{
break;
}
}
return pos;
}
// Parses the arguments of a call whose '(' is at `open`; returns the position just after
// the matching ')'.
std::size_t parseArgs(const std::string& t, std::size_t open, std::vector<Token>& args)
{
std::size_t pos = open + 1;
int depth = 1;
while (pos < t.size())
{
pos = skipBlank(t, pos);
if (pos >= t.size())
break;
char c = t[pos];
if (c == ')')
{
if (--depth == 0)
return pos + 1;
++pos;
continue;
}
if (c == '(')
{
++depth;
++pos;
continue;
}
Token tok;
tok.begin = pos;
if (c == '"')
{
tok.quoted = true;
++pos;
while (pos < t.size() && t[pos] != '"')
{
if (t[pos] == '\\' && pos + 1 < t.size())
++pos;
tok.text += t[pos++];
}
if (pos < t.size())
++pos; // closing quote
}
else if (c == '[' && pos + 1 < t.size() && (t[pos + 1] == '[' || t[pos + 1] == '='))
{
std::size_t p = pos + 1;
std::size_t level = 0;
while (p < t.size() && t[p] == '=')
{
++level;
++p;
}
if (p < t.size() && t[p] == '[')
{
std::string close = "]" + std::string(level, '=') + "]";
std::size_t endPos = t.find(close, p + 1);
std::size_t contentEnd = endPos == std::string::npos ? t.size() : endPos;
tok.quoted = true;
tok.text = t.substr(p + 1, contentEnd - p - 1);
pos = endPos == std::string::npos ? t.size() : endPos + close.size();
}
else
{
tok.text = "[";
++pos;
}
}
else
{
while (pos < t.size() && !std::isspace(static_cast<unsigned char>(t[pos])) &&
t[pos] != ')' && t[pos] != '(' && t[pos] != '#' && t[pos] != '"')
tok.text += t[pos++];
if (tok.text.empty())
{
++pos; // a stray character: step over it rather than loop
continue;
}
}
tok.end = pos;
args.push_back(std::move(tok));
}
return t.size();
}
}
std::vector<Call> findCalls(const std::string& text,
const std::function<bool(const std::string&)>& wanted)
{
std::vector<Call> calls;
std::size_t pos = 0;
while (pos < text.size())
{
pos = skipBlank(text, pos);
if (pos >= text.size())
break;
if (!isIdentStart(text[pos]))
{
++pos;
continue;
}
std::size_t nameBegin = pos;
while (pos < text.size() && isIdentChar(text[pos]))
++pos;
std::string name = text.substr(nameBegin, pos - nameBegin);
std::size_t open = pos;
while (open < text.size() && (text[open] == ' ' || text[open] == '\t'))
++open;
if (open >= text.size() || text[open] != '(')
continue;
Call call;
call.name = std::move(name);
call.begin = nameBegin;
call.end = parseArgs(text, open, call.args);
pos = call.end;
if (wanted(call.name))
calls.push_back(std::move(call));
}
return calls;
}
std::optional<Call> findSet(const std::string& text, const std::string& variable)
{
for (Call& call :
findCalls(text, [](const std::string& n) { return text::lower(n) == "set"; }))
{
if (!call.args.empty() && call.args[0].text == variable)
return std::move(call);
}
return std::nullopt;
}
std::string quote(const std::string& value)
{
std::string out = "\"";
for (char c : value)
{
if (c == '"' || c == '\\')
out += '\\';
out += c;
}
out += '"';
return out;
}
void replaceToken(std::string& text, const Token& token, const std::string& replacement)
{
text.replace(token.begin, token.end - token.begin, replacement);
}
}
+290
View File
@@ -0,0 +1,290 @@
#include "breeze/Cli.hpp"
#include <algorithm>
#include <iostream>
#include "breeze/Config.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Process.hpp"
#include "breeze/Text.hpp"
#ifndef BREEZE_CLI_VERSION
#define BREEZE_CLI_VERSION "0.0.0"
#endif
namespace breeze::self
{
namespace sfs = std::filesystem;
std::string version()
{
return BREEZE_CLI_VERSION;
}
namespace
{
sfs::path root()
{
return breezeHome() / "cli";
}
std::string exeName()
{
return fs::hostPlatform() == "windows" ? "breeze.exe" : "breeze";
}
sfs::path binaryOf(const Version& v)
{
return root() / v.toString() / exeName();
}
sfs::path shim()
{
return breezeHome() / "bin" / exeName();
}
Version parseOrThrow(const std::string& text)
{
std::optional<Version> v = parseVersion(text);
if (!v)
throw Error("'" + text + "' is not a version (use 1.2.3)");
return *v;
}
bool isUrl(const std::string& s)
{
return text::startsWith(s, "http://") || text::startsWith(s, "https://");
}
// <source>/<version>/breeze-<platform>-<arch>[.exe], a URL or a directory.
void obtain(const std::string& source, const Version& v, const sfs::path& destination)
{
std::string file = "breeze-" + fs::hostPlatform() + "-" + fs::hostArch() +
(fs::hostPlatform() == "windows" ? ".exe" : "");
std::string base = source;
while (!base.empty() && base.back() == '/')
base.pop_back();
std::string where = base + "/" + v.toString() + "/" + file;
std::error_code ec;
sfs::create_directories(destination.parent_path(), ec);
if (isUrl(source))
{
if (!proc::findInPath("curl"))
throw Error("curl is needed to download CLI versions, and it is not installed");
proc::Result r = proc::run({"curl", "--fail", "--silent", "--show-error",
"--location", "--output", destination.string(), where});
if (r.exitCode != 0)
throw Error("cannot download '" + where + "'");
return;
}
if (text::startsWith(where, "file://"))
where = where.substr(7);
if (!sfs::is_regular_file(where, ec))
throw Error("there is no '" + where + "': CLI " + v.toString() +
" is not in the CLI source '" + source + "' for " + fs::hostPlatform() +
"-" + fs::hostArch());
sfs::copy_file(where, destination, sfs::copy_options::overwrite_existing, ec);
if (ec)
throw Error("cannot copy '" + where + "': " + ec.message());
}
}
std::vector<Version> installed()
{
std::vector<Version> out;
std::error_code ec;
if (!sfs::is_directory(root(), ec))
return out;
for (const sfs::directory_entry& e : sfs::directory_iterator(root(), ec))
{
if (!e.is_directory())
continue;
std::optional<Version> v = parseVersion(e.path().filename().string());
if (v && sfs::exists(binaryOf(*v)))
out.push_back(*v);
}
std::sort(out.begin(), out.end());
return out;
}
std::optional<Version> picked()
{
std::optional<std::string> text = fs::tryReadText(root() / "current");
if (!text)
return std::nullopt;
return parseVersion(text::trim(*text));
}
void install(const std::string& versionText)
{
Version v = parseOrThrow(versionText);
sfs::path binary = binaryOf(v);
bool already = sfs::exists(binary);
if (!already)
{
// Nothing is left behind when it fails.
struct Cleanup
{
explicit Cleanup(sfs::path d) : dir(std::move(d)) {}
Cleanup(const Cleanup&) = delete;
Cleanup& operator=(const Cleanup&) = delete;
Cleanup(Cleanup&&) = delete;
Cleanup& operator=(Cleanup&&) = delete;
sfs::path dir;
bool armed = true;
~Cleanup()
{
if (armed)
{
std::error_code ec;
sfs::remove_all(dir, ec);
}
}
} cleanup{binary.parent_path()};
if (std::optional<Version> own = parseVersion(version()); own && *own == v)
{
// This program is the version asked for: install a copy of it.
std::error_code ec;
sfs::create_directories(binary.parent_path(), ec);
sfs::copy_file(fs::executablePath(), binary, sfs::copy_options::overwrite_existing,
ec);
if (ec)
throw Error("cannot copy this program to '" + fs::generic(binary) +
"': " + ec.message());
}
else
{
GlobalConfig cfg = GlobalConfig::load();
std::optional<std::string> source = cfg.effective(config::CliSource);
if (!source)
throw Error("CLI " + v.toString() + " is not this program (" + version() +
") and no CLI source "
"is configured. Say where releases come from with:\n"
" breeze config cli-source --set <URL or directory>");
obtain(*source, v, binary);
}
fs::makeExecutable(binary);
// It has to be the version it says it is, and run at all.
proc::Options o;
o.capture = true;
proc::Result r = proc::run({binary.string(), "cli", "version"}, o);
if (r.exitCode != 0 || text::trim(r.output) != v.toString())
{
throw Error("what was installed for " + v.toString() +
" does not run, or says it is another version "
"(it printed '" +
text::trim(r.output) + "'); nothing was installed");
}
cleanup.armed = false;
}
// The copy to put on PATH, and the first version installed is the one in use.
if (!sfs::exists(shim()))
{
std::error_code ec;
sfs::create_directories(shim().parent_path(), ec);
sfs::copy_file(binary, shim(), sfs::copy_options::overwrite_existing, ec);
}
bool first = !picked().has_value();
if (first)
fs::writeText(root() / "current", v.toString() + "\n");
std::cout << (already ? "already installed: " : "installed ") << v.toString()
<< (first ? " (picked)" : "") << "\n";
const char* pathEnv = std::getenv("PATH");
std::string binDir = fs::generic(shim().parent_path());
if (pathEnv == nullptr ||
std::string(pathEnv).find(shim().parent_path().string()) == std::string::npos)
std::cout << "to use it from anywhere, put " << binDir << " on your PATH\n";
}
void pick(const std::string& versionText)
{
Version v = parseOrThrow(versionText);
if (!sfs::exists(binaryOf(v)))
{
std::vector<std::string> names;
for (const Version& i : installed())
names.push_back(i.toString());
throw Error("CLI " + v.toString() + " is not installed (`breeze cli install " +
v.toString() + "`). " +
(names.empty() ? "Nothing is installed."
: "Installed: " + text::join(names, ", ")));
}
std::optional<Version> before = picked();
fs::writeText(root() / "current", v.toString() + "\n");
std::cout << "picked " << v.toString();
if (before && !(*before == v))
std::cout << " (was " << before->toString() << ")";
std::cout << "\n";
}
void uninstall(const std::string& versionText)
{
Version v = parseOrThrow(versionText);
sfs::path dir = root() / v.toString();
if (!sfs::exists(dir))
throw Error("CLI " + v.toString() + " is not installed");
bool wasPicked = picked() && *picked() == v;
fs::removeTree(dir);
if (wasPicked)
{
std::error_code ec;
sfs::remove(root() / "current", ec);
}
std::cout << "uninstalled " << v.toString()
<< (wasPicked ? " (it was the picked version: none is picked now)" : "") << "\n";
}
void list()
{
std::vector<Version> all = installed();
std::optional<Version> p = picked();
std::optional<Version> own = parseVersion(version());
if (all.empty())
{
std::cout << "no versions installed (this program is " << version()
<< ": `breeze cli install " << version() << "`)\n";
return;
}
for (const Version& v : all)
{
std::cout << (p && *p == v ? "* " : " ") << v.toString();
if (own && *own == v)
std::cout << " (this program)";
std::cout << "\n";
}
}
void forwardToPicked(const std::vector<std::string>& args)
{
if (std::getenv("BREEZE_NO_FORWARD") != nullptr)
return;
if (args.empty() || args.front() == "cli")
return;
std::optional<Version> p = picked();
std::optional<Version> own = parseVersion(version());
if (!p || (own && *own == *p))
return;
sfs::path binary = binaryOf(*p);
if (!fs::isExecutable(binary))
{
std::cerr << "breeze: the picked version " << p->toString()
<< " is not installed any more; using " << version()
<< " (`breeze cli install " << p->toString()
<< "` or `breeze cli pick <version>`)\n";
return;
}
proc::setEnvironment("BREEZE_NO_FORWARD", "1");
std::vector<std::string> argv{binary.string()};
argv.insert(argv.end(), args.begin(), args.end());
proc::execReplace(argv);
}
}
+83
View File
@@ -0,0 +1,83 @@
#include "breeze/Config.hpp"
#include <cctype>
#include <cstdlib>
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
namespace breeze
{
std::filesystem::path breezeHome()
{
if (const char* p = std::getenv("BREEZE_HOME"); p != nullptr && *p != '\0')
return std::filesystem::path(p);
return fs::homeDir() / ".breeze";
}
namespace
{
std::filesystem::path configFile()
{
return breezeHome() / "config.json";
}
std::string envName(const std::string& key)
{
std::string name = "BREEZE_";
for (char c : key)
{
if (std::isupper(static_cast<unsigned char>(c)) && name.back() != '_')
name += '_';
name += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
return name;
}
}
GlobalConfig GlobalConfig::load()
{
GlobalConfig c;
if (std::optional<std::string> text = fs::tryReadText(configFile()))
{
nlohmann::json parsed = nlohmann::json::parse(*text, nullptr, false);
if (parsed.is_discarded() || !parsed.is_object())
throw Error("'" + fs::generic(configFile()) +
"' is not valid JSON; fix or delete it");
c.m_data = std::move(parsed);
}
return c;
}
std::optional<std::string> GlobalConfig::stored(const std::string& key) const
{
auto it = m_data.find(key);
if (it == m_data.end() || !it->is_string())
return std::nullopt;
return it->get<std::string>();
}
std::optional<std::string> GlobalConfig::effective(const std::string& key) const
{
if (const char* p = std::getenv(envName(key).c_str()); p != nullptr && *p != '\0')
return std::string(p);
return stored(key);
}
void GlobalConfig::set(const std::string& key, const std::string& value)
{
m_data[key] = value;
}
void GlobalConfig::erase(const std::string& key)
{
m_data.erase(key);
}
void GlobalConfig::save() const
{
fs::writeText(configFile(), m_data.dump(2) + "\n");
}
}
+211
View File
@@ -0,0 +1,211 @@
#include "breeze/Fs.hpp"
#include <cstdlib>
#include <algorithm>
#include <fstream>
#include <random>
#include <sstream>
#include <system_error>
#include "breeze/Error.hpp"
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#elif defined(__APPLE__)
#include <mach-o/dyld.h>
#include <unistd.h>
#else
#include <unistd.h>
#endif
#if !defined(_WIN32)
#include <sys/stat.h>
#endif
namespace breeze::fs
{
std::optional<std::string> tryReadText(const path& file)
{
std::ifstream in(file, std::ios::binary);
if (!in)
return std::nullopt;
std::ostringstream buffer;
buffer << in.rdbuf();
return buffer.str();
}
std::string readText(const path& file)
{
std::optional<std::string> text = tryReadText(file);
if (!text)
throw Error("cannot read '" + generic(file) + "'");
return *text;
}
void writeText(const path& file, const std::string& text)
{
std::error_code ec;
if (file.has_parent_path())
std::filesystem::create_directories(file.parent_path(), ec);
std::ofstream out(file, std::ios::binary | std::ios::trunc);
if (!out)
throw Error("cannot write '" + generic(file) + "'");
out << text;
if (!out)
throw Error("cannot write '" + generic(file) + "'");
}
void copyTree(const path& from, const path& to, const std::vector<std::string>& skipNames)
{
namespace sfs = std::filesystem;
std::error_code ec;
sfs::create_directories(to, ec);
for (const sfs::directory_entry& entry : sfs::directory_iterator(from))
{
std::string name = entry.path().filename().string();
if (std::find(skipNames.begin(), skipNames.end(), name) != skipNames.end())
continue;
path target = to / name;
if (entry.is_symlink())
{
sfs::copy_symlink(entry.path(), target, ec);
if (ec)
throw Error("cannot copy '" + generic(entry.path()) + "': " + ec.message());
}
else if (entry.is_directory())
{
copyTree(entry.path(), target, skipNames);
}
else
{
sfs::copy_file(entry.path(), target, sfs::copy_options::overwrite_existing, ec);
if (ec)
throw Error("cannot copy '" + generic(entry.path()) + "': " + ec.message());
}
}
}
void removeTree(const path& p)
{
std::error_code ec;
std::filesystem::remove_all(p, ec);
if (ec)
throw Error("cannot remove '" + generic(p) + "': " + ec.message());
}
path makeTempDir(const std::string& prefix)
{
std::random_device rd;
std::mt19937_64 gen(rd());
for (int attempt = 0; attempt < 100; ++attempt)
{
std::ostringstream name;
name << prefix << "-" << std::hex << gen();
path dir = std::filesystem::temp_directory_path() / name.str();
std::error_code ec;
if (std::filesystem::create_directory(dir, ec))
return dir;
}
throw Error("cannot create a temporary directory");
}
path homeDir()
{
#if defined(_WIN32)
if (const char* p = std::getenv("USERPROFILE"))
return path(p);
#endif
if (const char* p = std::getenv("HOME"))
return path(p);
throw Error("cannot tell where your home directory is (HOME is not set)");
}
path executablePath()
{
#if defined(_WIN32)
std::wstring buffer(32768, L'\0');
DWORD n = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
buffer.resize(n);
return path(buffer);
#elif defined(__APPLE__)
std::uint32_t size = 0;
_NSGetExecutablePath(nullptr, &size);
std::string buffer(size, '\0');
if (_NSGetExecutablePath(buffer.data(), &size) != 0)
throw Error("cannot tell where this program is");
buffer.resize(std::char_traits<char>::length(buffer.c_str()));
std::error_code ec;
path canonical = std::filesystem::canonical(buffer, ec);
return ec ? path(buffer) : canonical;
#else
std::error_code ec;
path p = std::filesystem::read_symlink("/proc/self/exe", ec);
if (ec)
throw Error("cannot tell where this program is: " + ec.message());
return p;
#endif
}
std::string hostPlatform()
{
#if defined(_WIN32)
return "windows";
#elif defined(__APPLE__)
return "macos";
#else
return "linux";
#endif
}
std::string hostArch()
{
#if defined(__x86_64__) || defined(_M_X64)
return "x64";
#elif defined(__aarch64__) || defined(_M_ARM64)
return "arm64";
#elif defined(__arm__) || defined(_M_ARM)
return "arm32";
#else
return "x86";
#endif
}
void makeExecutable(const path& file)
{
#if !defined(_WIN32)
std::error_code ec;
std::filesystem::permissions(file,
std::filesystem::perms::owner_exec |
std::filesystem::perms::group_exec |
std::filesystem::perms::others_exec,
std::filesystem::perm_options::add, ec);
if (ec)
throw Error("cannot make '" + generic(file) + "' executable: " + ec.message());
#else
(void)file;
#endif
}
bool isExecutable(const path& file)
{
std::error_code ec;
if (!std::filesystem::is_regular_file(file, ec))
return false;
#if defined(_WIN32)
return true;
#else
return ::access(file.c_str(), X_OK) == 0;
#endif
}
std::string generic(const path& p)
{
return p.generic_string();
}
}
+115
View File
@@ -0,0 +1,115 @@
#include "breeze/Git.hpp"
#include <set>
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Process.hpp"
#include "breeze/Text.hpp"
namespace breeze::git
{
namespace sfs = std::filesystem;
namespace
{
void requireGit()
{
if (!proc::findInPath("git"))
throw Error("git is needed for this, and it is not installed (or not on PATH)");
}
bool isUrl(const std::string& s)
{
return text::startsWith(s, "http://") || text::startsWith(s, "https://") ||
text::startsWith(s, "ssh://") || text::startsWith(s, "git://") ||
text::startsWith(s, "file://") || text::startsWith(s, "git@");
}
// A local repository is cloned through a file:// URL, which keeps git from ignoring
// --depth.
std::string cloneable(const std::string& repository)
{
if (isUrl(repository))
return repository;
std::error_code ec;
sfs::path abs = sfs::absolute(repository, ec);
return "file://" + abs.generic_string();
}
}
bool isRepository(const std::string& source)
{
if (isUrl(source))
return true;
std::error_code ec;
sfs::path p(source);
return sfs::is_directory(p / ".git", ec) ||
(sfs::is_regular_file(p / "HEAD", ec) && sfs::is_directory(p / "objects", ec));
}
std::vector<Tag> versionTags(const std::string& repository)
{
requireGit();
proc::Options options;
options.capture = true;
proc::Result r =
proc::run({"git", "ls-remote", "--tags", "--refs", cloneable(repository)}, options);
if (r.exitCode != 0)
throw Error("cannot read the tags of '" + repository + "' (git ls-remote failed)");
std::vector<Tag> tags;
std::set<std::string> seen;
for (const std::string& line : text::splitLines(r.output))
{
std::size_t pos = line.find("refs/tags/");
if (pos == std::string::npos)
continue;
std::string name = text::trim(line.substr(pos + 10));
std::optional<Version> v = parseVersion(name);
if (!v || !seen.insert(name).second)
continue;
tags.push_back({name, *v});
}
return tags;
}
void exportTree(const std::string& repository, const std::string& tag,
const sfs::path& destination)
{
requireGit();
sfs::path tmp = fs::makeTempDir("breeze-git");
sfs::path work = tmp / "repo";
std::vector<std::string> argv{"git", "clone", "--quiet", "--depth", "1"};
if (!tag.empty())
{
argv.push_back("--branch");
argv.push_back(tag);
}
argv.push_back(cloneable(repository));
argv.push_back(work.string());
proc::Options options;
options.capture = true;
options.mergeStderr = true;
proc::Result r = proc::run(argv, options);
if (r.exitCode != 0)
{
fs::removeTree(tmp);
throw Error("cannot fetch " +
(tag.empty() ? std::string("the default branch") : "'" + tag + "'") +
" of '" + repository + "':\n" + text::trim(r.output));
}
fs::removeTree(work / ".git");
std::error_code ec;
sfs::create_directories(destination.parent_path(), ec);
sfs::rename(work, destination, ec);
if (ec)
{
// Different file systems: copy instead.
fs::copyTree(work, destination);
}
fs::removeTree(tmp);
}
}
+580
View File
@@ -0,0 +1,580 @@
#include "breeze/Modules.hpp"
#include <algorithm>
#include <array>
#include <set>
#include "breeze/CMakeText.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Text.hpp"
namespace breeze
{
namespace sfs = std::filesystem;
std::string kindName(ModuleKind kind)
{
switch (kind)
{
case ModuleKind::Exec:
return "exec";
case ModuleKind::Lib:
return "lib";
case ModuleKind::Obj:
return "obj";
case ModuleKind::Test:
return "test";
case ModuleKind::Example:
return "example";
}
return "?";
}
namespace
{
struct MacroInfo
{
const char* name;
ModuleKind kind;
LibType libType;
};
const std::array<MacroInfo, 7> Macros = {{
{"add_lib_module", ModuleKind::Lib, LibType::Unspecified},
{"add_static_lib_module", ModuleKind::Lib, LibType::Static},
{"add_shared_lib_module", ModuleKind::Lib, LibType::Shared},
{"add_objects_module", ModuleKind::Obj, LibType::Unspecified},
{"add_exe_module", ModuleKind::Exec, LibType::Unspecified},
{"add_test_module", ModuleKind::Test, LibType::Unspecified},
{"add_example_module", ModuleKind::Example, LibType::Unspecified},
}};
const MacroInfo* macroInfo(const std::string& name)
{
std::string lowered = text::lower(name);
for (const MacroInfo& m : Macros)
{
if (lowered == m.name)
return &m;
}
return nullptr;
}
bool isModuleCall(const std::string& name)
{
return macroInfo(name) != nullptr;
}
bool isKeyword(const std::string& s)
{
return s == "OUTPUT_NAME" || s == "TYPE" || s == "DEPENDS" || s == "EXTRA_SOURCES" ||
s == "NO_DOCS";
}
Module toModule(const cmake::Call& call, const sfs::path& dir, ModuleKind /*category*/)
{
const MacroInfo* info = macroInfo(call.name);
Module m;
m.macro = text::lower(call.name);
m.dir = dir;
m.dirName = dir.filename().string();
m.kind = info->kind;
m.libType = info->libType;
std::string mode;
bool haveId = false;
for (const cmake::Token& t : call.args)
{
if (!t.quoted && isKeyword(t.text))
{
mode = t.text;
continue;
}
if (!haveId)
{
m.id = t.text;
haveId = true;
}
else if (mode == "OUTPUT_NAME")
{
m.outputName = t.text;
}
else if (mode == "TYPE")
{
std::string v = text::lower(t.text);
m.libType = v == "shared" ? LibType::Shared
: v == "static" ? LibType::Static
: m.libType;
}
else if (mode == "DEPENDS")
{
m.deps.push_back(t.text);
}
}
return m;
}
void scanDirectory(const sfs::path& container, ModuleKind category,
std::vector<Module>& out, bool skipContainers)
{
std::error_code ec;
if (!sfs::is_directory(container, ec))
return;
std::vector<sfs::path> dirs;
for (const sfs::directory_entry& e : sfs::directory_iterator(container, ec))
{
if (!e.is_directory())
continue;
std::string name = e.path().filename().string();
if (skipContainers && (name == "tests" || name == "examples") &&
!sfs::exists(e.path() / "CMakeLists.txt"))
continue;
if (sfs::is_regular_file(e.path() / "CMakeLists.txt", ec))
dirs.push_back(e.path());
}
std::sort(dirs.begin(), dirs.end());
for (const sfs::path& dir : dirs)
{
std::optional<std::string> text = fs::tryReadText(dir / "CMakeLists.txt");
if (!text)
continue;
for (const cmake::Call& call : cmake::findCalls(*text, isModuleCall))
{
if (call.args.empty())
continue;
out.push_back(toModule(call, dir, category));
}
}
}
sfs::path moduleDirectory(const Project& project, ModuleKind kind, const std::string& name)
{
sfs::path source = project.root / "source";
if (kind == ModuleKind::Test)
return source / "tests" / name;
if (kind == ModuleKind::Example)
return source / "examples" / name;
return source / name;
}
// <name in snake case> plus the kind's suffix, unless the name already ends that way
// (a module called UtilTest is util_test, not util_test_test).
std::string defaultId(ModuleKind kind, const std::string& name)
{
std::string base = text::snakeCase(name);
std::string suffix = "_" + kindName(kind);
if (kind == ModuleKind::Exec)
suffix = "_exe";
return text::endsWith(base, suffix) ? base : base + suffix;
}
std::string applyTemplate(std::string t, const std::string& name, const std::string& snake)
{
t = text::replaceAll(std::move(t), "@NAME@", name);
t = text::replaceAll(std::move(t), "@SNAKE@", snake);
return t;
}
const char* const MainCpp = R"(#include <iostream>
int main()
{
std::cout << "Hello from @NAME@\n";
return 0;
}
)";
const char* const LibHeader = R"(#pragma once
#include <string>
namespace @SNAKE@
{
/// A placeholder: replace it with the module's real interface.
std::string describe();
}
)";
const char* const LibSource = R"(#include "@SNAKE@/@NAME@.hpp"
namespace @SNAKE@
{
std::string describe()
{
return "@NAME@";
}
}
)";
const char* const TestMain =
R"(// A test is an executable that exits with 0 when everything holds; ctest runs it.
int main()
{
return 0;
}
)";
}
std::vector<Module> scanModules(const Project& project)
{
std::vector<Module> modules;
sfs::path source = project.root / "source";
scanDirectory(source, ModuleKind::Exec, modules, true);
scanDirectory(source / "tests", ModuleKind::Test, modules, false);
scanDirectory(source / "examples", ModuleKind::Example, modules, false);
return modules;
}
const Module* findModule(const std::vector<Module>& modules, const std::string& idOrDir)
{
for (const Module& m : modules)
{
if (m.id == idOrDir)
return &m;
}
for (const Module& m : modules)
{
if (m.dirName == idOrDir)
return &m;
}
// A directory name typed in another case, when only one module could mean it.
const Module* found = nullptr;
for (const Module& m : modules)
{
if (text::lower(m.dirName) == text::lower(idOrDir))
{
if (found != nullptr)
return nullptr;
found = &m;
}
}
return found;
}
namespace
{
const Module& require(const std::vector<Module>& modules, const std::string& idOrDir)
{
if (const Module* m = findModule(modules, idOrDir))
return *m;
throw Error("there is no module '" + idOrDir + "' (see `breeze ls`)");
}
}
Module addModule(const Project& project, const NewModule& request)
{
if (!text::isName(request.name))
throw Error("'" + request.name +
"' is not a valid module name: it starts with a letter and has only "
"letters, digits and '_' (it becomes a directory and a C++ namespace)");
if (request.kind == ModuleKind::Obj && !project.sdk.supportsObjects)
throw Error("this project's SDK (" + project.sdk.describe() +
") has no objects modules. Move to an SDK that "
"has them with " +
(project.sdk.layout == SdkLayout::Legacy ? "`breeze sdk migrate <version>`"
: "`breeze sdk upgrade`"));
if (request.kind == ModuleKind::Lib && request.libType == LibType::Unspecified)
throw Error("a library is either --static or --shared; say which");
std::string id = request.id ? *request.id : defaultId(request.kind, request.name);
if (!text::isTargetId(id))
throw Error("'" + id +
"' is not a valid module id (letters, digits, '_', '.', '+', '-')");
std::vector<Module> existing = scanModules(project);
for (const Module& m : existing)
{
if (m.id == id)
throw Error("the id '" + id + "' is already taken by the module in source/" +
m.dirName + (m.kind == ModuleKind::Test ? " (tests)" : ""));
}
sfs::path dir = moduleDirectory(project, request.kind, request.name);
if (sfs::exists(dir))
throw Error("'" + fs::generic(sfs::relative(dir, project.root)) + "' already exists");
std::vector<std::string> deps;
for (const std::string& d : request.deps)
{
if (d == id || d == request.name)
throw Error("a module can't depend on itself ('" + d + "')");
const Module* dep = findModule(existing, d);
if (dep != nullptr)
{
if (dep->kind == ModuleKind::Exec || dep->kind == ModuleKind::Test ||
dep->kind == ModuleKind::Example)
throw Error("'" + d + "' is " +
(dep->kind == ModuleKind::Exec || dep->kind == ModuleKind::Example
? "an "
: "a ") +
kindName(dep->kind) +
" module: only libraries and objects modules can be depended on");
deps.push_back(dep->id);
}
else if (text::isTargetId(d))
{
deps.push_back(d); // a target from elsewhere: a dependency, a system library
}
else
{
throw Error("'" + d + "' is not a module of this project or a valid target name");
}
}
std::string snake = text::snakeCase(request.name);
std::string call;
switch (request.kind)
{
case ModuleKind::Exec:
call = "add_exe_module(" + id + " OUTPUT_NAME " + cmake::quote(snake);
break;
case ModuleKind::Lib:
call = "add_lib_module(" + id + " TYPE " +
(request.libType == LibType::Shared ? "SHARED" : "STATIC") +
" OUTPUT_NAME " + cmake::quote(snake);
break;
case ModuleKind::Obj:
call = "add_objects_module(" + id + " OUTPUT_NAME " + cmake::quote(snake);
break;
case ModuleKind::Test:
call = "add_test_module(" + id;
break;
case ModuleKind::Example:
call = "add_example_module(" + id;
break;
}
if (!deps.empty())
call += " DEPENDS " + text::join(deps, " ");
call += ")\n";
const char* comment = nullptr;
switch (request.kind)
{
case ModuleKind::Exec:
comment = "# An executable: every .cpp in this directory, headers in include/.\n";
break;
case ModuleKind::Lib:
comment = "# A library: sources in src/, public headers in include/.\n";
break;
case ModuleKind::Obj:
comment = "# An objects module: compiled to object files that whatever names it in "
"DEPENDS takes in.\n";
break;
case ModuleKind::Test:
comment = "# A test: an executable ctest runs (built with BUILD_TESTS=ON).\n";
break;
case ModuleKind::Example:
comment = "# An example: an executable (built with BUILD_EXAMPLES=ON).\n";
break;
}
fs::writeText(dir / "CMakeLists.txt", std::string(comment) + call);
if (request.kind == ModuleKind::Lib || request.kind == ModuleKind::Obj)
{
fs::writeText(dir / "include" / snake / (request.name + ".hpp"),
applyTemplate(LibHeader, request.name, snake));
fs::writeText(dir / "src" / (request.name + ".cpp"),
applyTemplate(LibSource, request.name, snake));
}
else if (request.kind == ModuleKind::Test)
{
fs::writeText(dir / "main.cpp", TestMain);
}
else
{
fs::writeText(dir / "main.cpp", applyTemplate(MainCpp, request.name, snake));
}
std::vector<Module> now = scanModules(project);
const Module* created = findModule(now, id);
if (created == nullptr)
throw Error("internal error: the new module '" + id +
"' was not found after writing it");
return *created;
}
std::vector<std::string> dependentsOf(const std::vector<Module>& modules, const Module& module,
bool recursive)
{
std::vector<std::string> out;
std::set<std::string> seen;
std::vector<std::string> queue{module.id};
while (!queue.empty())
{
std::string current = queue.front();
queue.erase(queue.begin());
for (const Module& m : modules)
{
if (std::find(m.deps.begin(), m.deps.end(), current) == m.deps.end())
continue;
if (seen.insert(m.id).second)
{
out.push_back(m.id);
if (recursive)
queue.push_back(m.id);
}
}
if (!recursive)
break;
}
return out;
}
std::vector<std::string> dependenciesOf(const std::vector<Module>& modules,
const Module& module, bool recursive)
{
std::vector<std::string> out;
std::set<std::string> seen{module.id};
std::vector<std::string> queue{module.id};
while (!queue.empty())
{
std::string current = queue.front();
queue.erase(queue.begin());
const Module* m = findModule(modules, current);
if (m == nullptr)
continue;
for (const std::string& d : m->deps)
{
if (seen.insert(d).second)
{
out.push_back(d);
if (recursive)
queue.push_back(d);
}
}
if (!recursive)
break;
}
return out;
}
void deleteModule(const Project& project, const std::string& idOrDir)
{
std::vector<Module> modules = scanModules(project);
const Module& m = require(modules, idOrDir);
std::vector<std::string> users = dependentsOf(modules, m, false);
if (!users.empty())
throw Error("'" + m.id + "' can't be deleted: " + text::join(users, ", ") +
(users.size() == 1 ? " depends" : " depend") + " on it");
fs::removeTree(m.dir);
}
namespace
{
struct Edit
{
cmake::Token token;
std::string value;
};
// Applies replacements from the end of the file backwards so earlier offsets stay valid.
void rewrite(const sfs::path& file, std::string text, std::vector<Edit> edits)
{
std::sort(edits.begin(), edits.end(),
[](const Edit& a, const Edit& b) { return a.token.begin > b.token.begin; });
for (const Edit& e : edits)
cmake::replaceToken(text, e.token, e.value);
fs::writeText(file, text);
}
}
RenameResult renameModule(const Project& project, const std::string& idOrDir,
const std::optional<std::string>& newName,
const std::optional<std::string>& newId)
{
if (!newName && !newId)
throw Error("nothing to rename: give --name and/or --id");
std::vector<Module> modules = scanModules(project);
const Module& m = require(modules, idOrDir);
RenameResult result;
result.oldId = m.id;
result.newId = newId ? *newId : m.id;
result.oldDir = m.dirName;
result.newDir = newName ? *newName : m.dirName;
if (newName && !text::isName(*newName))
throw Error("'" + *newName + "' is not a valid module name");
if (newId && !text::isTargetId(*newId))
throw Error("'" + *newId + "' is not a valid module id");
if (result.newId == result.oldId && result.newDir == result.oldDir)
throw Error("the module already has that name and id");
if (result.newId != result.oldId)
{
for (const Module& other : modules)
{
if (other.id == result.newId)
throw Error("the id '" + result.newId + "' is already taken");
}
}
sfs::path newDirPath = m.dir.parent_path() / result.newDir;
if (result.newDir != result.oldDir && sfs::exists(newDirPath))
throw Error("'" + fs::generic(sfs::relative(newDirPath, project.root)) +
"' already exists");
// Everything is rewritten at the files' present locations first; the directory moves last.
std::set<sfs::path> files;
for (const Module& other : modules)
files.insert(other.dir / "CMakeLists.txt");
for (const sfs::path& file : files)
{
std::string text = fs::readText(file);
std::vector<Edit> edits;
bool isOwn = file == m.dir / "CMakeLists.txt";
bool touchedDependency = false;
for (const cmake::Call& call : cmake::findCalls(text, isModuleCall))
{
if (call.args.empty())
continue;
if (isOwn && result.newId != result.oldId && call.args.front().text == result.oldId)
edits.push_back({call.args.front(), result.newId});
std::string mode;
bool first = true;
for (const cmake::Token& t : call.args)
{
if (!t.quoted && isKeyword(t.text))
{
mode = t.text;
continue;
}
if (first)
{
first = false;
continue;
}
if (mode == "DEPENDS" && result.newId != result.oldId && t.text == result.oldId)
{
edits.push_back({t, result.newId});
touchedDependency = true;
}
}
}
if (edits.empty())
continue;
rewrite(file, text, std::move(edits));
if (touchedDependency)
{
std::string dirName = file.parent_path().filename().string();
if (!isOwn)
result.updatedDependents.push_back(dirName);
}
}
if (result.newDir != result.oldDir)
{
std::error_code ec;
sfs::rename(m.dir, newDirPath, ec);
if (ec)
throw Error("cannot rename '" + fs::generic(m.dir) + "': " + ec.message());
}
return result;
}
}
+169
View File
@@ -0,0 +1,169 @@
#include "breeze/Presets.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Process.hpp"
#include "breeze/Text.hpp"
namespace breeze
{
namespace
{
Preset makePreset(const std::string& name, const std::string& description)
{
Preset p;
p.name = name;
p.description = description;
std::size_t first = name.find('-');
std::size_t second = first == std::string::npos ? first : name.find('-', first + 1);
if (second != std::string::npos)
{
p.platform = name.substr(0, first);
p.arch = name.substr(first + 1, second - first - 1);
p.compiler = name.substr(second + 1);
}
return p;
}
}
std::vector<Preset> listPresets(const Project& project)
{
std::error_code ec;
if (!std::filesystem::exists(project.root / "CMakePresets.json", ec) &&
!std::filesystem::exists(project.root / "CMakeUserPresets.json", ec))
return {}; // an SDK from before presets
proc::Options options;
options.cwd = project.root;
options.capture = true;
proc::Result r = proc::run({"cmake", "--list-presets"}, options);
if (r.exitCode != 0)
throw Error("`cmake --list-presets` failed in '" + fs::generic(project.root) +
"' -- is CMake installed, and is CMakePresets.json valid?");
std::vector<Preset> presets;
for (const std::string& raw : text::splitLines(r.output))
{
std::string line = text::trim(raw);
if (line.size() < 3 || line[0] != '"')
continue;
std::size_t close = line.find('"', 1);
if (close == std::string::npos)
continue;
std::string name = line.substr(1, close - 1);
std::string description;
if (std::size_t dash = line.find('-', close); dash != std::string::npos)
description = text::trim(line.substr(dash + 1));
presets.push_back(makePreset(name, description));
}
return presets;
}
namespace
{
bool same(const std::string& a, const std::string& b)
{
return text::normalized(a) == text::normalized(b);
}
std::string describeChoices(const std::vector<Preset>& presets)
{
std::string out;
for (const Preset& p : presets)
out += " " + p.name + "\n";
return out;
}
}
std::optional<std::string> resolvePreset(const Project& project, const Defaults& defaults,
const PresetQuery& query, std::string* note)
{
std::vector<Preset> presets = listPresets(project);
if (presets.empty())
{
if (query.preset || query.platform || query.arch || query.compiler)
throw Error("this project's SDK (" + project.sdk.describe() +
") has no CMake presets, so there is nothing to choose between. "
"Move to a newer SDK with `breeze sdk migrate <version>`");
return std::nullopt;
}
auto byName = [&](const std::string& name) -> const Preset*
{
for (const Preset& p : presets)
{
if (same(p.name, name))
return &p;
}
return nullptr;
};
if (query.preset)
{
if (const Preset* p = byName(*query.preset))
return p->name;
throw Error("'" + *query.preset +
"' is not a preset this project has for this machine. "
"Choose one of:\n" +
describeChoices(presets));
}
bool anyGiven = query.platform || query.arch || query.compiler;
std::optional<std::string> dPlatform = defaults.build("platform");
std::optional<std::string> dArch = defaults.build("arch");
std::optional<std::string> dCompiler = defaults.build("compiler");
if (!anyGiven)
{
if (std::optional<std::string> dp = defaults.build("preset"))
{
if (const Preset* p = byName(*dp))
return p->name;
throw Error(
"the project's default preset '" + *dp +
"' does not exist for this machine. "
"Choose another with `breeze defaults build preset --set <name>`, from:\n" +
describeChoices(presets));
}
if (!dPlatform && !dArch && !dCompiler)
return std::nullopt;
}
std::string platform = query.platform ? *query.platform
: dPlatform ? *dPlatform
: fs::hostPlatform();
std::string arch = query.arch ? *query.arch : dArch ? *dArch : fs::hostArch();
std::optional<std::string> compiler = query.compiler ? query.compiler : dCompiler;
std::vector<Preset> matches;
for (const Preset& p : presets)
{
if (same(p.platform, platform) && same(p.arch, arch) &&
(!compiler || same(p.compiler, *compiler)))
matches.push_back(p);
}
if (matches.empty())
{
std::vector<Preset> sameTarget;
for (const Preset& p : presets)
{
if (same(p.platform, platform) && same(p.arch, arch))
sameTarget.push_back(p);
}
throw Error("this project has no preset for " + platform + "-" + arch +
(compiler ? "-" + *compiler : std::string()) + " on this machine. " +
(sameTarget.empty() ? "Available:\n" + describeChoices(presets)
: "Presets for " + platform + "-" + arch + ":\n" +
describeChoices(sameTarget)));
}
if (matches.size() > 1 && note != nullptr)
{
*note = "no compiler given: using '" + matches.front().name + "' (the others are: ";
for (std::size_t i = 1; i < matches.size(); ++i)
*note += (i > 1 ? ", " : "") + matches[i].name;
*note += "). Pick one with -C or `breeze defaults build compiler --set <name>`.";
}
return matches.front().name;
}
}
+353
View File
@@ -0,0 +1,353 @@
#include "breeze/Process.hpp"
#include <cstdio>
#include <cstdlib>
#include <array>
#include <iostream>
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Text.hpp"
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <io.h>
#include <windows.h>
#else
#include <fcntl.h>
#include <sys/wait.h>
#include <unistd.h>
#endif
namespace breeze::proc
{
#if defined(_WIN32)
namespace
{
std::wstring widen(const std::string& s)
{
if (s.empty())
return {};
int n =
MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), nullptr, 0);
std::wstring w(static_cast<std::size_t>(n), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), w.data(), n);
return w;
}
// The Microsoft C runtime's rules for one argument: quote when it has spaces or quotes,
// double the backslashes that precede a quote.
std::wstring quoteArg(const std::string& arg)
{
std::wstring w = widen(arg);
if (!w.empty() && w.find_first_of(L" \t\n\v\"") == std::wstring::npos)
return w;
std::wstring out = L"\"";
std::size_t backslashes = 0;
for (wchar_t c : w)
{
if (c == L'\\')
{
++backslashes;
continue;
}
if (c == L'"')
out.append(backslashes * 2 + 1, L'\\');
else
out.append(backslashes, L'\\');
backslashes = 0;
out += c;
}
out.append(backslashes * 2, L'\\');
out += L'"';
return out;
}
Result runCommandLine(std::wstring commandLine, const Options& options)
{
std::vector<std::pair<std::wstring, std::wstring>> saved;
for (const auto& [key, value] : options.env)
{
std::wstring wk = widen(key);
wchar_t previous[32768];
DWORD n = GetEnvironmentVariableW(wk.c_str(), previous, 32768);
saved.emplace_back(wk,
n > 0 ? std::wstring(previous, n) : std::wstring(L"\x01unset"));
SetEnvironmentVariableW(wk.c_str(), widen(value).c_str());
}
SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE};
HANDLE readEnd = nullptr;
HANDLE writeEnd = nullptr;
if (options.capture)
{
CreatePipe(&readEnd, &writeEnd, &sa, 0);
SetHandleInformation(readEnd, HANDLE_FLAG_INHERIT, 0);
}
HANDLE nul = nullptr;
if (options.silent)
nul = CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_WRITE, &sa, OPEN_EXISTING, 0,
nullptr);
STARTUPINFOW si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
si.hStdOutput = options.capture
? writeEnd
: (options.silent ? nul : GetStdHandle(STD_OUTPUT_HANDLE));
si.hStdError = options.capture && options.mergeStderr
? writeEnd
: (options.silent ? nul : GetStdHandle(STD_ERROR_HANDLE));
PROCESS_INFORMATION pi{};
std::wstring cwd = options.cwd.empty() ? std::wstring() : options.cwd.wstring();
BOOL ok = CreateProcessW(nullptr, commandLine.data(), nullptr, nullptr, TRUE, 0,
nullptr, cwd.empty() ? nullptr : cwd.c_str(), &si, &pi);
if (writeEnd)
CloseHandle(writeEnd);
if (nul)
CloseHandle(nul);
Result result;
if (!ok)
{
if (readEnd)
CloseHandle(readEnd);
result.exitCode = 127;
}
else
{
if (readEnd)
{
std::array<char, 4096> buffer{};
DWORD got = 0;
while (ReadFile(readEnd, buffer.data(), static_cast<DWORD>(buffer.size()), &got,
nullptr) &&
got > 0)
result.output.append(buffer.data(), got);
CloseHandle(readEnd);
}
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD code = 0;
GetExitCodeProcess(pi.hProcess, &code);
result.exitCode = static_cast<int>(code);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}
for (const auto& [key, value] : saved)
SetEnvironmentVariableW(key.c_str(),
value == L"\x01unset" ? nullptr : value.c_str());
return result;
}
}
Result run(const std::vector<std::string>& argv, const Options& options)
{
if (argv.empty())
throw Error("internal error: nothing to run");
std::wstring line;
for (std::size_t i = 0; i < argv.size(); ++i)
{
if (i != 0)
line += L' ';
line += quoteArg(argv[i]);
}
return runCommandLine(std::move(line), options);
}
Result runScript(const std::filesystem::path& script, const std::vector<std::string>& args,
const Options& options)
{
// cmd.exe strips the outer quotes of what follows /c, so the whole command is wrapped once
// more.
std::wstring inner = quoteArg(script.string());
for (const std::string& a : args)
inner += L' ' + quoteArg(a);
return runCommandLine(L"cmd.exe /d /s /c \"" + inner + L"\"", options);
}
void execReplace(const std::vector<std::string>& argv)
{
Result r = run(argv);
if (r.exitCode == 127)
throw Error("cannot start '" + argv.front() + "'");
std::exit(r.exitCode);
}
std::optional<std::filesystem::path> findInPath(const std::string& program)
{
const char* pathEnv = std::getenv("PATH");
if (pathEnv == nullptr)
return std::nullopt;
static const std::vector<std::string> extensions = {"", ".exe", ".bat", ".cmd", ".com"};
for (const std::string& dir : text::split(pathEnv, ';'))
{
for (const std::string& ext : extensions)
{
std::filesystem::path candidate = std::filesystem::path(dir) / (program + ext);
if (std::filesystem::is_regular_file(candidate))
return candidate;
}
}
return std::nullopt;
}
void setEnvironment(const std::string& name, const std::string& value)
{
_putenv_s(name.c_str(), value.c_str());
}
bool stdinIsTerminal()
{
return _isatty(_fileno(stdin)) != 0;
}
bool stdoutIsTerminal()
{
return _isatty(_fileno(stdout)) != 0;
}
#else
Result run(const std::vector<std::string>& argv, const Options& options)
{
if (argv.empty())
throw Error("internal error: nothing to run");
std::cout.flush();
std::cerr.flush();
std::fflush(nullptr);
std::array<int, 2> pipeFds = {-1, -1};
if (options.capture && ::pipe(pipeFds.data()) != 0)
throw Error("cannot create a pipe");
pid_t pid = ::fork();
if (pid < 0)
throw Error("cannot create a process");
if (pid == 0)
{
if (!options.cwd.empty() && ::chdir(options.cwd.c_str()) != 0)
{
std::fprintf(stderr, "breeze: cannot enter '%s'\n", options.cwd.c_str());
::_exit(127);
}
for (const auto& [key, value] : options.env)
::setenv(key.c_str(), value.c_str(), 1);
if (options.capture)
{
::dup2(pipeFds[1], STDOUT_FILENO);
if (options.mergeStderr)
::dup2(pipeFds[1], STDERR_FILENO);
::close(pipeFds[0]);
::close(pipeFds[1]);
}
else if (options.silent)
{
int nul = ::open("/dev/null", O_WRONLY);
::dup2(nul, STDOUT_FILENO);
::dup2(nul, STDERR_FILENO);
::close(nul);
}
std::vector<char*> args;
for (const std::string& a : argv)
args.push_back(const_cast<char*>(a.c_str()));
args.push_back(nullptr);
::execvp(args[0], args.data());
std::fprintf(stderr, "breeze: cannot run '%s'\n", args[0]);
::_exit(127);
}
Result result;
if (options.capture)
{
::close(pipeFds[1]);
std::array<char, 4096> buffer{};
ssize_t got = 0;
while ((got = ::read(pipeFds[0], buffer.data(), buffer.size())) > 0)
result.output.append(buffer.data(), static_cast<std::size_t>(got));
::close(pipeFds[0]);
}
int status = 0;
while (::waitpid(pid, &status, 0) < 0)
{
if (errno != EINTR)
throw Error("cannot wait for '" + argv.front() + "'");
}
if (WIFEXITED(status))
result.exitCode = WEXITSTATUS(status);
else if (WIFSIGNALED(status))
result.exitCode = 128 + WTERMSIG(status);
return result;
}
Result runScript(const std::filesystem::path& script, const std::vector<std::string>& args,
const Options& options)
{
std::vector<std::string> argv;
// A project unpacked from a zip often has scripts that lost their executable bit; the SDK's
// scripts are bash, so that is how they are run then.
if (!fs::isExecutable(script))
argv.push_back("bash");
argv.push_back(script.string());
argv.insert(argv.end(), args.begin(), args.end());
return run(argv, options);
}
void execReplace(const std::vector<std::string>& argv)
{
if (argv.empty())
throw Error("internal error: nothing to run");
std::cout.flush();
std::cerr.flush();
std::vector<char*> args;
for (const std::string& a : argv)
args.push_back(const_cast<char*>(a.c_str()));
args.push_back(nullptr);
::execv(args[0], args.data());
throw Error("cannot start '" + argv.front() + "'");
}
std::optional<std::filesystem::path> findInPath(const std::string& program)
{
if (program.find('/') != std::string::npos)
return fs::isExecutable(program) ? std::optional<std::filesystem::path>(program)
: std::nullopt;
const char* pathEnv = std::getenv("PATH");
if (pathEnv == nullptr)
return std::nullopt;
for (const std::string& dir : text::split(pathEnv, ':'))
{
std::filesystem::path candidate =
(dir.empty() ? std::filesystem::path(".") : std::filesystem::path(dir)) / program;
if (fs::isExecutable(candidate))
return candidate;
}
return std::nullopt;
}
void setEnvironment(const std::string& name, const std::string& value)
{
::setenv(name.c_str(), value.c_str(), 1);
}
bool stdinIsTerminal()
{
return ::isatty(STDIN_FILENO) != 0;
}
bool stdoutIsTerminal()
{
return ::isatty(STDOUT_FILENO) != 0;
}
#endif
}
+338
View File
@@ -0,0 +1,338 @@
#include "breeze/Project.hpp"
#include <algorithm>
#include "breeze/CMakeText.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Text.hpp"
namespace breeze
{
namespace sfs = std::filesystem;
std::string Sdk::describe() const
{
if (layout == SdkLayout::Legacy)
return "legacy (flat layout, unversioned)";
if (version)
return version->toString();
return "unversioned (breeze/ layout)";
}
sfs::path Project::metaDir() const
{
return root / ".breeze";
}
sfs::path Project::defaultsFile() const
{
return metaDir() / "defaults.json";
}
sfs::path Project::toolsDir() const
{
return metaDir() / "tools";
}
sfs::path Project::script(const std::string& name) const
{
return sdk.scriptsDir / (name + (fs::hostPlatform() == "windows" ? ".bat" : ".sh"));
}
std::optional<Sdk> detectSdk(const sfs::path& root)
{
std::error_code ec;
if (!sfs::is_regular_file(root / "CMakeLists.txt", ec))
return std::nullopt;
Sdk sdk;
if (sfs::is_regular_file(root / "breeze" / "cmake" / "Breeze.cmake", ec) &&
sfs::is_regular_file(root / "project" / "Configuration.cmake", ec))
{
sdk.layout = SdkLayout::Breeze;
sdk.dir = root / "breeze";
sdk.scriptsDir = sdk.dir / "scripts";
sdk.cmakeDir = sdk.dir / "cmake";
sdk.settingsFile = root / "project" / "Configuration.cmake";
if (std::optional<std::string> v = fs::tryReadText(sdk.dir / "VERSION"))
sdk.version = parseVersion(*v);
}
else if (sfs::is_regular_file(root / "Configuration.cmake", ec) &&
sfs::is_regular_file(root / "cmake" / "BuildHelpers.cmake", ec))
{
sdk.layout = SdkLayout::Legacy;
sdk.dir = root;
sdk.scriptsDir = root / "scripts";
sdk.cmakeDir = root / "cmake";
sdk.settingsFile = root / "Configuration.cmake";
}
else
{
return std::nullopt;
}
if (std::optional<std::string> helpers =
fs::tryReadText(sdk.cmakeDir / "BuildHelpers.cmake"))
sdk.supportsObjects = helpers->find("macro(add_objects_module") != std::string::npos;
return sdk;
}
std::optional<Project> findProject(const sfs::path& start)
{
std::error_code ec;
sfs::path dir = sfs::weakly_canonical(start, ec);
if (ec)
dir = start;
while (true)
{
if (std::optional<Sdk> sdk = detectSdk(dir))
return Project{dir, *sdk};
if (!dir.has_parent_path() || dir.parent_path() == dir)
return std::nullopt;
dir = dir.parent_path();
}
}
Project requireProject(const sfs::path& start)
{
if (std::optional<Project> p = findProject(start))
return *p;
throw Error(
"this is not inside a breeze project (no CMakeLists.txt with a breeze SDK found in '" +
fs::generic(start) + "' or above). Create one with: breeze project new <name>");
}
namespace
{
const char* variableOf(Setting which)
{
switch (which)
{
case Setting::Name:
return "PROJECT_TITLE";
case Setting::Version:
return "PROJECT_VERSION_NUMBER";
case Setting::Id:
return "PROJECT_CODE_NAME";
}
return "";
}
bool isCmakeVersion(const std::string& v)
{
std::vector<std::string> parts = text::split(v, '.');
if (parts.empty() || parts.size() > 4)
return false;
return std::all_of(parts.begin(), parts.end(),
[](const std::string& p)
{
return !p.empty() &&
std::all_of(p.begin(), p.end(),
[](char c) { return c >= '0' && c <= '9'; });
});
}
void validate(Setting which, const std::string& value)
{
switch (which)
{
case Setting::Name:
if (!text::isName(value, true))
throw Error(
"'" + value +
"' is not a valid project name: it starts with a letter and has "
"only letters, digits, '_' and '-'");
break;
case Setting::Version:
if (!isCmakeVersion(value))
throw Error("'" + value +
"' is not a valid project version: use numbers separated by "
"dots, like 1.2.0 (CMake's project(VERSION))");
break;
case Setting::Id:
if (!text::isName(value) || std::any_of(value.begin(), value.end(), [](char c)
{ return c >= 'A' && c <= 'Z'; }))
throw Error(
"'" + value +
"' is not a valid project id: lower-case letters, digits and '_', "
"starting with a letter");
break;
}
}
// Where a setting is set: the settings file, except in older legacy projects that kept the
// id in CMakeLists.txt.
sfs::path settingFile(const Project& project, Setting which)
{
std::optional<std::string> text = fs::tryReadText(project.sdk.settingsFile);
if (text && cmake::findSet(*text, variableOf(which)))
return project.sdk.settingsFile;
if (project.sdk.layout == SdkLayout::Legacy)
{
std::optional<std::string> cmakeLists =
fs::tryReadText(project.root / "CMakeLists.txt");
if (cmakeLists && cmake::findSet(*cmakeLists, variableOf(which)))
return project.root / "CMakeLists.txt";
}
return project.sdk.settingsFile;
}
// The name and the version of a legacy project live in the project() call of
// CMakeLists.txt.
struct LegacyProjectCall
{
cmake::Call call;
std::string text;
};
LegacyProjectCall legacyProjectCall(const Project& p)
{
std::string text = fs::readText(p.root / "CMakeLists.txt");
std::vector<cmake::Call> calls = cmake::findCalls(
text, [](const std::string& n) { return text::lower(n) == "project"; });
if (calls.empty() || calls.front().args.empty())
throw Error("CMakeLists.txt has no project() call to read the project's name and "
"version from");
return {calls.front(), std::move(text)};
}
std::size_t versionArgIndex(const cmake::Call& call)
{
for (std::size_t i = 0; i + 1 < call.args.size(); ++i)
{
if (call.args[i].text == "VERSION")
return i + 1;
}
return call.args.size();
}
}
std::string readSetting(const Project& project, Setting which)
{
if (project.sdk.layout == SdkLayout::Legacy && which != Setting::Id)
{
LegacyProjectCall lp = legacyProjectCall(project);
if (which == Setting::Name)
return lp.call.args[0].text;
std::size_t idx = versionArgIndex(lp.call);
if (idx >= lp.call.args.size())
throw Error("the project() call in CMakeLists.txt has no VERSION");
return lp.call.args[idx].text;
}
sfs::path file = settingFile(project, which);
std::string text = fs::readText(file);
std::optional<cmake::Call> set = cmake::findSet(text, variableOf(which));
if (!set || set->args.size() < 2)
throw Error(std::string("'") + fs::generic(file) + "' does not set " +
variableOf(which));
return set->args[1].text;
}
void writeSetting(const Project& project, Setting which, const std::string& value)
{
validate(which, value);
if (project.sdk.layout == SdkLayout::Legacy && which != Setting::Id)
{
LegacyProjectCall lp = legacyProjectCall(project);
const cmake::Token* token = nullptr;
if (which == Setting::Name)
{
token = &lp.call.args[0];
}
else
{
std::size_t idx = versionArgIndex(lp.call);
if (idx >= lp.call.args.size())
throw Error("the project() call in CMakeLists.txt has no VERSION to change");
token = &lp.call.args[idx];
}
std::string text = lp.text;
cmake::replaceToken(text, *token, value);
fs::writeText(project.root / "CMakeLists.txt", text);
return;
}
sfs::path file = settingFile(project, which);
std::string text = fs::readText(file);
std::optional<cmake::Call> set = cmake::findSet(text, variableOf(which));
if (!set || set->args.size() < 2)
throw Error(std::string("'") + fs::generic(file) + "' does not set " +
variableOf(which));
cmake::replaceToken(text, set->args[1], cmake::quote(value));
fs::writeText(file, text);
}
Defaults Defaults::load(const Project& project)
{
Defaults d;
if (std::optional<std::string> text = fs::tryReadText(project.defaultsFile()))
{
nlohmann::json parsed = nlohmann::json::parse(*text, nullptr, false);
if (parsed.is_discarded() || !parsed.is_object())
throw Error("'" + fs::generic(project.defaultsFile()) +
"' is not valid JSON; fix or delete it");
d.m_data = std::move(parsed);
}
return d;
}
std::optional<std::string> Defaults::build(const std::string& key) const
{
auto b = m_data.find("build");
if (b == m_data.end() || !b->is_object())
return std::nullopt;
auto it = b->find(key);
if (it == b->end() || !it->is_string())
return std::nullopt;
return it->get<std::string>();
}
void Defaults::setBuild(const std::string& key, const std::string& value)
{
m_data["build"][key] = value;
}
void Defaults::clearBuild(const std::string& key)
{
if (m_data.contains("build"))
{
m_data["build"].erase(key);
if (m_data["build"].empty())
m_data.erase("build");
}
}
std::optional<std::string> Defaults::run() const
{
auto it = m_data.find("run");
if (it == m_data.end() || !it->is_string())
return std::nullopt;
return it->get<std::string>();
}
void Defaults::setRun(const std::string& id)
{
m_data["run"] = id;
}
void Defaults::clearRun()
{
m_data.erase("run");
}
void Defaults::save(const Project& project) const
{
if (m_data.empty())
{
std::error_code ec;
sfs::remove(project.defaultsFile(), ec);
return;
}
fs::writeText(project.defaultsFile(), m_data.dump(2) + "\n");
}
}
+616
View File
@@ -0,0 +1,616 @@
#include "breeze/Sdks.hpp"
#include <cstdint>
#include <algorithm>
#include <array>
#include <iostream>
#include <sstream>
#include "breeze/CMakeText.hpp"
#include "breeze/Config.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Git.hpp"
#include "breeze/Modules.hpp"
#include "breeze/Process.hpp"
#include "breeze/Text.hpp"
namespace breeze::sdk
{
namespace sfs = std::filesystem;
namespace
{
// What a snapshot never hands to a project.
const std::vector<std::string> Skip = {".git", "out", ".cache", "compile_commands.json"};
// Root files the tools look for there, so they stay at the root and belong to the project.
const std::array<const char*, 4> DotFiles = {".clang-format", ".clang-tidy",
".editorconfig", ".gitattributes"};
std::string hashOf(const std::string& s)
{
std::uint64_t h = 1469598103934665603ULL; // FNV-1a
for (unsigned char c : s)
{
h ^= c;
h *= 1099511628211ULL;
}
std::ostringstream out;
out << std::hex << h;
return out.str();
}
bool isBreezeSnapshot(const sfs::path& snap)
{
std::error_code ec;
return sfs::is_regular_file(snap / "breeze" / "cmake" / "Breeze.cmake", ec) &&
sfs::is_regular_file(snap / "project" / "Configuration.cmake", ec) &&
sfs::is_regular_file(snap / "CMakeLists.txt", ec);
}
const Source::Entry* findEntry(const std::vector<Source::Entry>& all, const Version& v)
{
for (const Source::Entry& e : all)
{
if (e.version == v)
return &e;
}
return nullptr;
}
// The newest release (a pre-release only when there is no release), optionally of one major
// version.
const Source::Entry* newest(const std::vector<Source::Entry>& all, std::optional<int> major)
{
const Source::Entry* best = nullptr;
for (int pass = 0; pass < 2 && best == nullptr; ++pass)
{
for (const Source::Entry& e : all)
{
if (major && e.version.major != *major)
continue;
if (pass == 0 && !e.version.pre.empty())
continue;
if (best == nullptr || best->version < e.version)
best = &e;
}
}
return best;
}
std::string list(const std::vector<Source::Entry>& all)
{
std::vector<std::string> names;
for (const Source::Entry& e : all)
names.push_back(e.version.toString());
return text::join(names, ", ");
}
Source::Entry requireSnapshot(const Source& source, const Source::Entry& entry,
sfs::path& snapshot)
{
snapshot = source.snapshot(entry);
if (!isBreezeSnapshot(snapshot))
throw Error("SDK " + entry.version.toString() + " (" + source.location() +
") has no breeze/ folder with project/Configuration.cmake: it is not a "
"template this "
"CLI can install");
return entry;
}
// Replaces the first value of set(<var> ...) in `text` by `raw` (already in CMake syntax).
bool setRaw(std::string& text, const std::string& var, const std::string& raw)
{
std::optional<cmake::Call> set = cmake::findSet(text, var);
if (!set || set->args.size() < 2)
return false;
cmake::replaceToken(text, set->args[1], raw);
return true;
}
// Settings the snapshot defines and the project's file does not yet: appended, as written.
std::vector<std::string> mergeMissingSettings(const sfs::path& projectFile,
const sfs::path& snapshotFile)
{
std::string project = fs::readText(projectFile);
std::string snapshot = fs::readText(snapshotFile);
std::vector<std::string> added;
for (const cmake::Call& call : cmake::findCalls(snapshot, [](const std::string& n)
{ return text::lower(n) == "set"; }))
{
if (call.args.empty())
continue;
const std::string& var = call.args[0].text;
if (cmake::findSet(project, var))
continue;
if (!project.empty() && project.back() != '\n')
project += '\n';
project += "\n# Added by `breeze sdk`: new in this SDK version.\n" +
snapshot.substr(call.begin, call.end - call.begin) + "\n";
added.push_back(var);
}
if (!added.empty())
fs::writeText(projectFile, project);
return added;
}
std::vector<std::string> differingDotFiles(const sfs::path& root, const sfs::path& snapshot)
{
std::vector<std::string> differing;
for (const char* name : DotFiles)
{
std::optional<std::string> mine = fs::tryReadText(root / name);
std::optional<std::string> theirs = fs::tryReadText(snapshot / name);
if (mine && theirs && *mine != *theirs)
differing.push_back(name);
}
return differing;
}
void moveAside(const sfs::path& from, const sfs::path& to)
{
std::error_code ec;
sfs::create_directories(to.parent_path(), ec);
fs::removeTree(to);
sfs::rename(from, to, ec);
if (ec)
{
if (sfs::is_directory(from))
fs::copyTree(from, to);
else
sfs::copy_file(from, to, sfs::copy_options::overwrite_existing, ec);
fs::removeTree(from);
}
}
void keepBackupsOutOfGit(const Project& project)
{
sfs::path ignore = project.metaDir() / ".gitignore";
if (!sfs::exists(ignore))
fs::writeText(ignore, "backup/\n");
}
std::string fromLabel(const Project& project)
{
return project.sdk.version ? project.sdk.version->toString() : "unversioned";
}
// ----------------------------------------------------------- moving a breeze/ project
// between versions
void applyBreeze(const Project& project, const sfs::path& snapshot,
const Source::Entry& entry)
{
sfs::path backup = project.metaDir() / "backup" / ("breeze-" + fromLabel(project));
moveAside(project.root / "breeze", backup);
keepBackupsOutOfGit(project);
fs::copyTree(snapshot / "breeze", project.root / "breeze");
std::vector<std::string> added = mergeMissingSettings(
project.sdk.settingsFile, snapshot / "project" / "Configuration.cmake");
std::vector<std::string> dotFiles = differingDotFiles(project.root, snapshot);
std::cout << "SDK " << fromLabel(project) << " -> " << entry.version.toString() << "\n";
std::cout << " breeze/ replaced (the old one is in "
<< fs::generic(sfs::relative(backup, project.root)) << ")\n";
for (const std::string& a : added)
std::cout << " added the setting " << a << " to project/Configuration.cmake\n";
for (const std::string& d : dotFiles)
std::cout << " " << d
<< " differs from the new SDK's; compare them if you want its changes\n";
std::cout
<< " delete out/build/ if the next build complains about a stale configuration\n";
}
// ----------------------------------------------------------- moving a legacy (flat)
// project to breeze/
struct OldProject
{
std::string name;
std::string version;
std::string description;
std::vector<std::string> languages;
std::string cxxStandard;
};
OldProject readOldProject(const std::string& cmakeLists)
{
std::vector<cmake::Call> calls = cmake::findCalls(
cmakeLists, [](const std::string& n) { return text::lower(n) == "project"; });
if (calls.empty() || calls.front().args.empty())
throw Error("the project's CMakeLists.txt has no project() call to read the name "
"and version from");
OldProject old;
old.name = calls.front().args.front().text;
std::string mode;
for (std::size_t i = 1; i < calls.front().args.size(); ++i)
{
const cmake::Token& t = calls.front().args[i];
if (!t.quoted && (t.text == "VERSION" || t.text == "DESCRIPTION" ||
t.text == "LANGUAGES" || t.text == "HOMEPAGE_URL"))
{
mode = t.text;
continue;
}
if (mode == "VERSION")
old.version = t.text;
else if (mode == "DESCRIPTION")
old.description = t.text;
else if (mode == "LANGUAGES")
old.languages.push_back(t.text);
}
if (std::optional<cmake::Call> std = cmake::findSet(cmakeLists, "CMAKE_CXX_STANDARD");
std && std->args.size() > 1)
old.cxxStandard = std->args[1].text;
return old;
}
void migrateLegacy(const Project& project, const sfs::path& snapshot,
const Source::Entry& entry)
{
const sfs::path& root = project.root;
std::string oldCmake = fs::readText(root / "CMakeLists.txt");
std::string oldConfig = fs::readText(root / "Configuration.cmake");
OldProject old = readOldProject(oldCmake);
// The new settings: the snapshot's file with what this project said put in.
std::string settings = fs::readText(snapshot / "project" / "Configuration.cmake");
setRaw(settings, "PROJECT_TITLE", cmake::quote(old.name));
if (!old.version.empty())
setRaw(settings, "PROJECT_VERSION_NUMBER", cmake::quote(old.version));
if (!old.description.empty())
setRaw(settings, "PROJECT_SUMMARY", cmake::quote(old.description));
if (!old.languages.empty())
setRaw(settings, "PROJECT_LANGUAGES", text::join(old.languages, " "));
if (!old.cxxStandard.empty())
setRaw(settings, "PROJECT_CXX_STANDARD", old.cxxStandard);
// Every other setting the project had changed, by name: collected first, applied after,
// because each edit moves the offsets of what follows.
std::vector<std::pair<std::string, std::string>> changes;
for (const cmake::Call& call : cmake::findCalls(settings, [](const std::string& n)
{ return text::lower(n) == "set"; }))
{
if (call.args.size() < 2)
continue;
const std::string var = call.args[0].text;
if (var.rfind("PROJECT_", 0) == 0 && var != "PROJECT_CODE_NAME")
continue;
for (const std::string* source : {&oldConfig, &oldCmake})
{
std::optional<cmake::Call> was = cmake::findSet(*source, var);
if (!was || was->args.size() < 2)
continue;
std::string raw =
source->substr(was->args[1].begin, was->args[1].end - was->args[1].begin);
std::string now =
settings.substr(call.args[1].begin, call.args[1].end - call.args[1].begin);
if (raw != now)
changes.emplace_back(var, raw);
break;
}
}
std::vector<std::string> carried;
for (const auto& [var, raw] : changes)
{
setRaw(settings, var, raw);
carried.push_back(var);
}
// What the project had of the template's goes aside, nothing is thrown away.
sfs::path backup =
project.metaDir() / "backup" / ("legacy-to-" + entry.version.toString());
fs::removeTree(backup);
// Only what the template owned: the project's README.md and any other documents stay.
for (const char* name : {"CMakeLists.txt", "CMakePresets.json", "Configuration.cmake",
"cmake", "scripts", ".githooks", "StyleGuide.md"})
{
sfs::path p = root / name;
if (sfs::exists(p))
moveAside(p, backup / name);
}
keepBackupsOutOfGit(project);
fs::copyTree(snapshot / "breeze", root / "breeze");
sfs::copy_file(snapshot / "CMakeLists.txt", root / "CMakeLists.txt",
sfs::copy_options::overwrite_existing);
sfs::copy_file(snapshot / "CMakePresets.json", root / "CMakePresets.json",
sfs::copy_options::overwrite_existing);
fs::writeText(root / "project" / "Configuration.cmake", settings);
for (const char* name : DotFiles)
{
if (!sfs::exists(root / name) && sfs::exists(snapshot / name))
sfs::copy_file(snapshot / name, root / name);
}
std::cout << "legacy layout -> SDK " << entry.version.toString()
<< " (breeze/ layout)\n";
std::cout << " breeze/ and project/Configuration.cmake created; the new root "
"CMakeLists.txt and "
"CMakePresets.json are the SDK's\n";
std::cout << " the old CMakeLists.txt, Configuration.cmake, cmake/, scripts/ and "
".githooks/ are in "
<< fs::generic(sfs::relative(backup, root)) << "\n";
std::cout << " carried over: project name '" << old.name << "'"
<< (old.version.empty() ? "" : ", version " + old.version)
<< (carried.empty() ? "" : ", " + text::join(carried, ", ")) << "\n";
// The git hook directory moved.
if (sfs::exists(root / ".git"))
{
proc::Options o;
o.cwd = root;
o.capture = true;
proc::Result r = proc::run({"git", "config", "core.hooksPath"}, o);
if (r.exitCode == 0 && text::trim(r.output) == ".githooks")
{
proc::Options q;
q.cwd = root;
q.silent = true;
proc::run({"git", "config", "core.hooksPath", "breeze/githooks"}, q);
std::cout << " git hooks now come from breeze/githooks\n";
}
}
// Build directories made by the old scripts point at files that are gone.
if (sfs::exists(root / "out" / "build"))
{
fs::removeTree(root / "out" / "build");
std::cout << " removed out/build/ (it was configured by the old layout)\n";
}
std::cout << "next: breeze setup, then breeze build\n";
}
}
// ----------------------------------------------------------------------- Source
Source Source::configured()
{
GlobalConfig cfg = GlobalConfig::load();
std::optional<std::string> location = cfg.effective(config::SdkSource);
if (!location)
throw Error("no SDK source is configured. Say where SDK versions come from with:\n"
" breeze config sdk-source --set <git repository (URL or path) | "
"directory of versions>");
return Source(*location);
}
Source::Source(std::string location) : m_location(std::move(location))
{
if (git::isRepository(m_location))
m_isRepository = true;
else if (!sfs::is_directory(m_location))
throw Error("the SDK source '" + m_location +
"' is neither a git repository nor a directory");
}
std::vector<Source::Entry> Source::versions() const
{
std::vector<Entry> out;
if (m_isRepository)
{
for (const git::Tag& t : git::versionTags(m_location))
out.push_back({t.version, t.name});
}
else
{
for (const sfs::directory_entry& e : sfs::directory_iterator(m_location))
{
if (!e.is_directory())
continue;
if (std::optional<Version> v = parseVersion(e.path().filename().string()))
out.push_back({*v, e.path().filename().string()});
}
}
std::sort(out.begin(), out.end(),
[](const Entry& a, const Entry& b) { return a.version < b.version; });
return out;
}
sfs::path Source::snapshot(const Entry& entry) const
{
if (!m_isRepository)
return sfs::path(m_location) / entry.ref;
sfs::path cache = breezeHome() / "sdk" / hashOf(m_location) / entry.version.toString();
if (sfs::exists(cache / "breeze"))
return cache;
std::cout << "fetching SDK " << entry.version.toString() << " from " << m_location << "\n";
fs::removeTree(cache);
git::exportTree(m_location, entry.ref, cache);
return cache;
}
Source::Entry Source::resolve(const std::optional<std::string>& wanted) const
{
std::vector<Entry> all = versions();
if (all.empty())
throw Error("the SDK source '" + m_location + "' has no versions (tags like v1.0.0)");
if (!wanted || text::lower(*wanted) == "latest")
return *newest(all, std::nullopt);
std::optional<Version> v = parseVersion(*wanted);
if (!v)
throw Error("'" + *wanted + "' is not a version (use 1.2.3, or latest)");
if (const Entry* e = findEntry(all, *v))
return *e;
throw Error("there is no SDK " + v->toString() + " in '" + m_location +
"'. Available: " + list(all));
}
// ----------------------------------------------------------------------- commands
namespace
{
std::string moduleNameFor(const std::string& projectName)
{
std::string out;
bool upper = true;
for (char c : projectName)
{
if (c == '-' || c == '_')
{
upper = true;
continue;
}
out += upper ? static_cast<char>(std::toupper(static_cast<unsigned char>(c))) : c;
upper = false;
}
return out;
}
std::string idFor(const std::string& projectName)
{
return text::replaceAll(text::lower(projectName), "-", "_");
}
}
void createProject(const std::string& name, const std::optional<std::string>& id,
const std::optional<std::string>& sdkVersion)
{
if (!text::isName(name, true))
throw Error(
"'" + name +
"' is not a valid project name: it starts with a letter and has only letters, "
"digits, '_' and '-'");
std::string projectId = id ? *id : idFor(name);
sfs::path dest = sfs::current_path() / name;
std::error_code ec;
if (sfs::exists(dest, ec) && !sfs::is_empty(dest, ec))
throw Error("'" + name + "' already exists and is not empty");
Source source = Source::configured();
Source::Entry entry = source.resolve(sdkVersion);
sfs::path snapshot;
requireSnapshot(source, entry, snapshot);
fs::copyTree(snapshot, dest, Skip);
// A new project starts without the template's demonstration code.
for (const char* dir : {"source", "vendor"})
{
fs::removeTree(dest / dir);
}
fs::writeText(dest / "source" / ".gitkeep", "");
std::optional<Sdk> detected = detectSdk(dest);
if (!detected)
throw Error("internal error: the project made from SDK " + entry.version.toString() +
" is not recognised as one");
Project project{dest, *detected};
writeSetting(project, Setting::Name, name);
writeSetting(project, Setting::Id, projectId);
writeSetting(project, Setting::Version, "0.1.0");
{
std::string settings = fs::readText(project.sdk.settingsFile);
setRaw(settings, "PROJECT_SUMMARY", cmake::quote(name));
fs::writeText(project.sdk.settingsFile, settings);
}
fs::writeText(
dest / "README.md",
"# " + name +
"\n\nThe project's own README: say what it is here. Doxygen uses this file as "
"the documentation's main page.\n\nBuilt with the breeze SDK "
"(breeze/):\n\n```sh\nbreeze setup "
"# once, to check the tools\nbreeze build\nbreeze run\n```\n");
NewModule first;
first.kind = ModuleKind::Exec;
first.name = moduleNameFor(name);
addModule(project, first);
fs::removeTree(dest / "source" / ".gitkeep");
std::cout << "created " << name << " with SDK " << entry.version.toString() << "\n";
std::cout << " an executable module '" << first.name << "' to start from\n";
std::cout << "next:\n cd " << name << "\n breeze setup\n breeze build\n breeze run\n";
}
void update(const Project& project)
{
Source source = Source::configured();
std::vector<Source::Entry> all = source.versions();
std::cout << "this project's SDK: " << project.sdk.describe() << "\n";
std::cout << "source: " << source.location() << "\n";
if (all.empty())
{
std::cout << "the source has no versions\n";
return;
}
std::cout << "available: " << list(all) << "\n";
const Source::Entry* latest = newest(all, std::nullopt);
if (project.sdk.layout == SdkLayout::Legacy)
{
std::cout << "this project has the legacy layout: `breeze sdk migrate "
<< latest->version.toString() << "` moves it to the breeze/ layout\n";
return;
}
if (!project.sdk.version)
{
std::cout << "this SDK has no version file: `breeze sdk upgrade` moves to "
<< latest->version.toString() << "\n";
return;
}
const Version& current = *project.sdk.version;
const Source::Entry* compatible = newest(all, current.major);
bool behind = compatible != nullptr && current < compatible->version;
if (behind)
std::cout << "update available: " << compatible->version.toString()
<< " (`breeze sdk upgrade`)\n";
if (latest->version.major > current.major)
std::cout << "a newer major version exists: " << latest->version.toString()
<< " (`breeze sdk migrate " << latest->version.toString() << "`)\n";
if (!behind && latest->version.major <= current.major)
std::cout << "up to date\n";
}
void upgrade(const Project& project)
{
if (project.sdk.layout == SdkLayout::Legacy)
throw Error("this project has the legacy layout, which has no upgrades: `breeze sdk "
"migrate <version>` "
"moves it to the breeze/ layout (see `breeze sdk update`)");
Source source = Source::configured();
std::vector<Source::Entry> all = source.versions();
if (all.empty())
throw Error("the SDK source '" + source.location() + "' has no versions");
const Source::Entry* target = project.sdk.version ? newest(all, project.sdk.version->major)
: newest(all, std::nullopt);
if (project.sdk.version && (target == nullptr || !(*project.sdk.version < target->version)))
{
std::cout << "SDK " << project.sdk.describe()
<< " is the newest of its major version\n";
const Source::Entry* latest = newest(all, std::nullopt);
if (latest->version.major > project.sdk.version->major)
std::cout << "a newer major version exists: `breeze sdk migrate "
<< latest->version.toString() << "`\n";
return;
}
sfs::path snapshot;
requireSnapshot(source, *target, snapshot);
applyBreeze(project, snapshot, *target);
}
void migrate(const Project& project, const std::string& version)
{
Source source = Source::configured();
Source::Entry entry = source.resolve(version);
sfs::path snapshot;
requireSnapshot(source, entry, snapshot);
if (project.sdk.layout == SdkLayout::Legacy)
{
migrateLegacy(project, snapshot, entry);
return;
}
if (project.sdk.version && *project.sdk.version == entry.version)
{
std::cout << "this project is already on SDK " << entry.version.toString() << "\n";
return;
}
if (project.sdk.version && entry.version < *project.sdk.version)
std::cout << "note: this is an older SDK than the project's (" << fromLabel(project)
<< ")\n";
applyBreeze(project, snapshot, entry);
}
}
+168
View File
@@ -0,0 +1,168 @@
#include "breeze/Text.hpp"
#include <cctype>
#include <algorithm>
namespace breeze::text
{
std::string trim(std::string_view s)
{
std::size_t b = 0;
std::size_t e = s.size();
while (b < e && std::isspace(static_cast<unsigned char>(s[b])))
++b;
while (e > b && std::isspace(static_cast<unsigned char>(s[e - 1])))
--e;
return std::string(s.substr(b, e - b));
}
std::vector<std::string> split(std::string_view s, char delimiter)
{
std::vector<std::string> out;
std::size_t start = 0;
while (true)
{
std::size_t pos = s.find(delimiter, start);
if (pos == std::string_view::npos)
{
out.emplace_back(s.substr(start));
return out;
}
out.emplace_back(s.substr(start, pos - start));
start = pos + 1;
}
}
std::vector<std::string> splitLines(std::string_view s)
{
std::vector<std::string> lines = split(s, '\n');
for (std::string& line : lines)
{
if (!line.empty() && line.back() == '\r')
line.pop_back();
}
if (!lines.empty() && lines.back().empty())
lines.pop_back();
return lines;
}
std::string join(const std::vector<std::string>& parts, std::string_view separator)
{
std::string out;
for (std::size_t i = 0; i < parts.size(); ++i)
{
if (i != 0)
out += separator;
out += parts[i];
}
return out;
}
std::string lower(std::string_view s)
{
std::string out(s);
std::transform(out.begin(), out.end(), out.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return out;
}
bool startsWith(std::string_view s, std::string_view prefix)
{
return s.size() >= prefix.size() && s.substr(0, prefix.size()) == prefix;
}
bool endsWith(std::string_view s, std::string_view suffix)
{
return s.size() >= suffix.size() && s.substr(s.size() - suffix.size()) == suffix;
}
std::string replaceAll(std::string s, std::string_view from, std::string_view to)
{
if (from.empty())
return s;
std::size_t pos = 0;
while ((pos = s.find(from, pos)) != std::string::npos)
{
s.replace(pos, from.size(), to);
pos += to.size();
}
return s;
}
std::string snakeCase(std::string_view s)
{
std::string out;
for (std::size_t i = 0; i < s.size(); ++i)
{
unsigned char c = static_cast<unsigned char>(s[i]);
if (c == '-' || c == ' ' || c == '.')
c = '_';
if (std::isupper(c))
{
bool prevLowerOrDigit =
i > 0 && (std::islower(static_cast<unsigned char>(s[i - 1])) ||
std::isdigit(static_cast<unsigned char>(s[i - 1])));
bool acronymEnds = i > 0 && std::isupper(static_cast<unsigned char>(s[i - 1])) &&
i + 1 < s.size() &&
std::islower(static_cast<unsigned char>(s[i + 1]));
if ((prevLowerOrDigit || acronymEnds) && !out.empty() && out.back() != '_')
out += '_';
out += static_cast<char>(std::tolower(c));
}
else
{
if (c == '_' && (out.empty() || out.back() == '_'))
continue;
out += static_cast<char>(c);
}
}
while (!out.empty() && out.back() == '_')
out.pop_back();
return out;
}
std::string normalized(std::string_view s)
{
std::string out;
for (char c : s)
{
unsigned char u = static_cast<unsigned char>(c);
if (std::isalnum(u))
out += static_cast<char>(std::tolower(u));
}
return out;
}
bool isName(std::string_view s, bool allowDash)
{
if (s.empty() || !std::isalpha(static_cast<unsigned char>(s[0])))
return false;
return std::all_of(s.begin(), s.end(),
[allowDash](char c)
{
unsigned char u = static_cast<unsigned char>(c);
return std::isalnum(u) || c == '_' || (allowDash && c == '-');
});
}
bool isTargetId(std::string_view s)
{
if (s.empty() || !(std::isalpha(static_cast<unsigned char>(s[0])) || s[0] == '_'))
return false;
return std::all_of(s.begin(), s.end(),
[](char c)
{
unsigned char u = static_cast<unsigned char>(c);
return std::isalnum(u) || c == '_' || c == '.' || c == '+' ||
c == '-' || c == ':';
});
}
std::string orNone(std::string_view s)
{
return s.empty() ? std::string("<none>") : std::string(s);
}
}
+422
View File
@@ -0,0 +1,422 @@
#include "breeze/Tools.hpp"
#include <algorithm>
#include <array>
#include <iostream>
#include <nlohmann/json.hpp>
#include "breeze/Config.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Git.hpp"
#include "breeze/Process.hpp"
#include "breeze/Text.hpp"
#include "breeze/Version.hpp"
namespace breeze::tools
{
namespace sfs = std::filesystem;
using nlohmann::json;
bool isReservedName(const std::string& word)
{
static const std::array<const char*, 6> Reserved = {"install", "uninstall", "update",
"upgrade", "migrate", "ls"};
return std::any_of(Reserved.begin(), Reserved.end(),
[&](const char* r) { return word == r; });
}
namespace
{
sfs::path registryFile(const Project& project)
{
return project.metaDir() / "tools.json";
}
json loadRegistry(const Project& project)
{
json registry = json::object();
if (std::optional<std::string> text = fs::tryReadText(registryFile(project)))
{
registry = json::parse(*text, nullptr, false);
if (registry.is_discarded() || !registry.is_object())
throw Error("'" + fs::generic(registryFile(project)) +
"' is not valid JSON; fix or delete it");
}
if (!registry.contains("tools") || !registry["tools"].is_object())
registry["tools"] = json::object();
return registry;
}
void saveRegistry(const Project& project, const json& registry)
{
if (registry["tools"].empty())
{
std::error_code ec;
sfs::remove(registryFile(project), ec);
return;
}
fs::writeText(registryFile(project), registry.dump(2) + "\n");
}
struct Manifest
{
std::string id;
std::string version;
std::string description;
std::string entry; // relative to the tool's directory, for this OS
};
Manifest readManifest(const sfs::path& dir)
{
std::optional<std::string> text = fs::tryReadText(dir / "breeze-tool.json");
if (!text)
throw Error("this is not a breeze tool: it has no breeze-tool.json at its root");
json j = json::parse(*text, nullptr, false);
if (j.is_discarded() || !j.is_object())
throw Error("breeze-tool.json is not valid JSON");
Manifest m;
m.id = j.value("id", "");
m.version = j.value("version", "");
m.description = j.value("description", "");
if (!text::isName(m.id, true))
throw Error("breeze-tool.json needs an \"id\" (letters, digits, '_', '-'; starting "
"with a letter)");
if (isReservedName(m.id))
throw Error("'" + m.id + "' can't be a tool id: `breeze tool " + m.id +
"` is a command of the CLI");
if (j.contains("entry") && j["entry"].is_string())
{
m.entry = j["entry"].get<std::string>();
}
else if (j.contains("entry") && j["entry"].is_object())
{
m.entry =
j["entry"].value(fs::hostPlatform() == "windows" ? "windows" : "posix", "");
}
if (m.entry.empty())
throw Error("breeze-tool.json needs an \"entry\" for this OS (a path, or { "
"\"posix\": ..., \"windows\": ... })");
if (!sfs::is_regular_file(dir / m.entry))
throw Error("the tool's entry '" + m.entry + "' is not in the repository");
return m;
}
struct Fetched
{
sfs::path dir; // a temporary directory holding the tool's files
Manifest manifest;
std::string version;
};
enum class Pick
{
Newest,
SameMajor,
Exact
};
// Gets the tool from `repo`: the version wanted, if the repository has version tags; else
// its files as they are.
Fetched fetch(const std::string& repo, Pick pick, const std::optional<Version>& basis)
{
sfs::path tmp = fs::makeTempDir("breeze-tool");
sfs::path dir = tmp / "tool";
std::string version;
try
{
if (git::isRepository(repo))
{
std::vector<git::Tag> tags = git::versionTags(repo);
const git::Tag* chosen = nullptr;
for (int pass = 0; pass < 2 && chosen == nullptr; ++pass)
{
for (const git::Tag& t : tags)
{
if (pick == Pick::Exact && basis && !(t.version == *basis))
continue;
if (pick == Pick::SameMajor && basis && t.version.major != basis->major)
continue;
if (pass == 0 && pick != Pick::Exact && !t.version.pre.empty())
continue;
if (chosen == nullptr || chosen->version < t.version)
chosen = &t;
}
}
if (pick == Pick::Exact && chosen == nullptr)
{
std::vector<std::string> names;
for (const git::Tag& t : tags)
names.push_back(t.version.toString());
std::sort(names.begin(), names.end());
throw Error(
"there is no version " + basis->toString() + " of the tool in '" +
repo + "'. Available: " +
(names.empty() ? std::string("none") : text::join(names, ", ")));
}
git::exportTree(repo, chosen ? chosen->name : "", dir);
if (chosen)
version = chosen->version.toString();
}
else if (sfs::is_directory(repo))
{
fs::copyTree(repo, dir, {".git"});
}
else
{
throw Error("'" + repo + "' is neither a git repository nor a directory");
}
Manifest manifest = readManifest(dir);
if (version.empty())
version = manifest.version.empty() ? "0.0.0" : manifest.version;
return {dir, manifest, version};
}
catch (...)
{
fs::removeTree(tmp);
throw;
}
}
void discard(const Fetched& f)
{
fs::removeTree(f.dir.parent_path());
}
// Puts fetched files in the project and records them.
void place(const Project& project, const Fetched& f, const std::string& repo)
{
sfs::path target = project.toolsDir() / f.manifest.id;
fs::removeTree(target);
std::error_code ec;
sfs::create_directories(target.parent_path(), ec);
fs::copyTree(f.dir, target);
fs::makeExecutable(target / f.manifest.entry);
json registry = loadRegistry(project);
registry["tools"][f.manifest.id] = {{"repo", repo}, {"version", f.version}};
saveRegistry(project, registry);
}
Tool toolFrom(const Project& project, const std::string& id, const json& entry)
{
Tool t;
t.id = id;
t.repo = entry.value("repo", "");
t.version = entry.value("version", "");
t.dir = project.toolsDir() / id;
if (std::optional<std::string> text = fs::tryReadText(t.dir / "breeze-tool.json"))
{
json j = json::parse(*text, nullptr, false);
if (j.is_object())
t.description = j.value("description", "");
}
return t;
}
Tool requireTool(const Project& project, const std::string& id)
{
json registry = loadRegistry(project);
if (!registry["tools"].contains(id))
throw Error("the tool '" + id +
"' is not installed in this project (see `breeze tool ls`)");
return toolFrom(project, id, registry["tools"][id]);
}
Version versionOf(const Tool& t)
{
std::optional<Version> v = parseVersion(t.version);
return v ? *v : Version{};
}
}
std::vector<Tool> installed(const Project& project)
{
std::vector<Tool> out;
json registry = loadRegistry(project);
for (auto it = registry["tools"].begin(); it != registry["tools"].end(); ++it)
out.push_back(toolFrom(project, it.key(), it.value()));
return out;
}
void install(const Project& project, const std::string& repo)
{
Fetched f = fetch(repo, Pick::Newest, std::nullopt);
try
{
json registry = loadRegistry(project);
if (registry["tools"].contains(f.manifest.id))
throw Error("the tool '" + f.manifest.id +
"' is already installed (`breeze tool upgrade " + f.manifest.id + "`)");
place(project, f, repo);
}
catch (...)
{
discard(f);
throw;
}
discard(f);
std::cout << "installed " << f.manifest.id << " " << f.version << "\n";
if (!f.manifest.description.empty())
std::cout << " " << f.manifest.description << "\n";
std::cout << "run it with: breeze tool " << f.manifest.id << " ...\n";
}
void uninstall(const Project& project, const std::string& id)
{
Tool t = requireTool(project, id);
fs::removeTree(t.dir);
json registry = loadRegistry(project);
registry["tools"].erase(id);
saveRegistry(project, registry);
std::error_code ec;
sfs::remove(project.toolsDir(),
ec); // only when empty; so is .breeze/ then, if nothing else is in it
sfs::remove(project.metaDir(), ec);
std::cout << "uninstalled " << id << "\n";
}
void update(const Project& project, const std::string& id)
{
Tool t = requireTool(project, id);
if (!git::isRepository(t.repo))
{
std::cout << id << " " << t.version
<< " comes from a directory, which has no versions to look for\n";
return;
}
Version current = versionOf(t);
std::vector<git::Tag> tags = git::versionTags(t.repo);
const git::Tag* sameMajor = nullptr;
const git::Tag* newest = nullptr;
for (const git::Tag& tag : tags)
{
if (!tag.version.pre.empty())
continue;
if (newest == nullptr || newest->version < tag.version)
newest = &tag;
if (tag.version.major == current.major &&
(sameMajor == nullptr || sameMajor->version < tag.version))
sameMajor = &tag;
}
std::cout << id << " " << t.version << " (" << t.repo << ")\n";
bool behind = sameMajor != nullptr && current < sameMajor->version;
if (behind)
std::cout << "update available: " << sameMajor->version.toString()
<< " (`breeze tool upgrade " << id << "`)\n";
if (newest != nullptr && newest->version.major > current.major)
std::cout << "a newer major version exists: " << newest->version.toString()
<< " (`breeze tool migrate " << id << " " << newest->version.toString()
<< "`)\n";
if (!behind && (newest == nullptr || newest->version.major <= current.major))
std::cout << "up to date\n";
}
void upgrade(const Project& project, const std::string& id)
{
Tool t = requireTool(project, id);
Version current = versionOf(t);
Fetched f = fetch(t.repo, Pick::SameMajor, current);
if (!(current < *parseVersion(f.version)) && git::isRepository(t.repo))
{
discard(f);
std::cout << id << " " << t.version << " is the newest of its major version\n";
return;
}
try
{
if (f.manifest.id != id)
throw Error("the repository now says its tool is '" + f.manifest.id + "', not '" +
id + "'");
place(project, f, t.repo);
}
catch (...)
{
discard(f);
throw;
}
discard(f);
std::cout << id << " " << t.version << " -> " << f.version << "\n";
}
void migrate(const Project& project, const std::string& id, const std::string& version)
{
Tool t = requireTool(project, id);
std::optional<Version> wanted = parseVersion(version);
if (!wanted)
throw Error("'" + version + "' is not a version (use 1.2.3)");
if (!git::isRepository(t.repo))
throw Error("'" + id + "' comes from a directory, which has only one version");
Fetched f = fetch(t.repo, Pick::Exact, wanted);
try
{
if (f.manifest.id != id)
throw Error("that version says its tool is '" + f.manifest.id + "', not '" + id +
"'");
place(project, f, t.repo);
}
catch (...)
{
discard(f);
throw;
}
discard(f);
std::cout << id << " " << t.version << " -> " << f.version << "\n";
}
void list(const Project& project)
{
std::vector<Tool> all = installed(project);
if (all.empty())
{
std::cout << "no tools installed (`breeze tool install <repository>`)\n";
return;
}
std::size_t idWidth = 2;
std::size_t versionWidth = 7;
for (const Tool& t : all)
{
idWidth = std::max(idWidth, t.id.size());
versionWidth = std::max(versionWidth, t.version.size());
}
for (const Tool& t : all)
{
std::cout << t.id << std::string(idWidth - t.id.size() + 2, ' ') << t.version
<< std::string(versionWidth - t.version.size() + 2, ' ') << t.repo << "\n";
if (!t.description.empty())
std::cout << " " << t.description << "\n";
}
}
std::optional<int> run(const Project& project, const std::string& id,
const std::vector<std::string>& args)
{
json registry = loadRegistry(project);
if (!registry["tools"].contains(id))
return std::nullopt;
Tool t = toolFrom(project, id, registry["tools"][id]);
Manifest manifest = readManifest(t.dir);
sfs::path entry = t.dir / manifest.entry;
proc::Options options;
options.cwd = project.root;
GlobalConfig cfg = GlobalConfig::load();
options.env = {
{"BREEZE_PROJECT_ROOT", project.root.string()},
{"BREEZE_SDK_DIR", project.sdk.dir.string()},
{"BREEZE_SDK_VERSION", project.sdk.version ? project.sdk.version->toString() : ""},
{"BREEZE_TOOL_ID", id},
{"BREEZE_TOOL_DIR", t.dir.string()},
{"BREEZE_CLI", fs::executablePath().string()},
{"BREEZE_HOME", breezeHome().string()},
{"BREEZE_EDITOR", cfg.effective(config::Editor).value_or("")}};
std::string ext = text::lower(entry.extension().string());
if (fs::hostPlatform() == "windows" && (ext == ".bat" || ext == ".cmd"))
return proc::runScript(entry, args, options).exitCode;
std::vector<std::string> argv{entry.string()};
argv.insert(argv.end(), args.begin(), args.end());
return proc::run(argv, options).exitCode;
}
}
+100
View File
@@ -0,0 +1,100 @@
#include "breeze/Version.hpp"
#include <algorithm>
#include <array>
#include <charconv>
#include "breeze/Text.hpp"
namespace breeze
{
std::string Version::toString() const
{
std::string s =
std::to_string(major) + "." + std::to_string(minor) + "." + std::to_string(patch);
if (!pre.empty())
s += "-" + pre;
return s;
}
namespace
{
bool parseNumber(std::string_view s, int& out)
{
if (s.empty())
return false;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out);
return ec == std::errc() && ptr == s.data() + s.size() && out >= 0;
}
}
std::optional<Version> parseVersion(std::string_view text)
{
std::string t = text::trim(text);
if (!t.empty() && (t[0] == 'v' || t[0] == 'V'))
t.erase(0, 1);
if (std::size_t plus = t.find('+'); plus != std::string::npos)
t.erase(plus);
Version v;
if (std::size_t dash = t.find('-'); dash != std::string::npos)
{
v.pre = t.substr(dash + 1);
t.erase(dash);
if (v.pre.empty())
return std::nullopt;
}
std::vector<std::string> parts = text::split(t, '.');
if (parts.empty() || parts.size() > 3)
return std::nullopt;
std::array<int*, 3> fields = {&v.major, &v.minor, &v.patch};
for (std::size_t i = 0; i < parts.size(); ++i)
{
if (!parseNumber(parts[i], *fields[i]))
return std::nullopt;
}
return v;
}
int compare(const Version& a, const Version& b)
{
if (a.major != b.major)
return a.major < b.major ? -1 : 1;
if (a.minor != b.minor)
return a.minor < b.minor ? -1 : 1;
if (a.patch != b.patch)
return a.patch < b.patch ? -1 : 1;
if (a.pre.empty() != b.pre.empty())
return a.pre.empty() ? 1 : -1; // a release is newer than its own pre-release
if (a.pre != b.pre)
return a.pre < b.pre ? -1 : 1;
return 0;
}
bool operator<(const Version& a, const Version& b)
{
return compare(a, b) < 0;
}
bool operator==(const Version& a, const Version& b)
{
return compare(a, b) == 0;
}
std::optional<Version> greatest(const std::vector<Version>& versions,
std::optional<int> sameMajorAs)
{
std::optional<Version> best;
for (const Version& v : versions)
{
if (sameMajorAs && v.major != *sameMajorAs)
continue;
if (!best || *best < v)
best = v;
}
return best;
}
}
+1
View File
@@ -0,0 +1 @@
add_lib_module(cliforge OUTPUT_NAME "cliforge")
@@ -0,0 +1,8 @@
#pragma once
#include "Command.hpp"
#include "Engine.hpp"
#include "FunctionTraits.hpp"
#include "Levenshtein.hpp"
#include "Slot.hpp"
#include "Value.hpp"
@@ -0,0 +1,499 @@
#pragma once
// Command is the fluent builder AND the parser for one CLI command. A
// command is built like:
//
// engine.command()
// .keyword("project")
// .parameter<std::string>("name", "Project name")
// .keyword("delete")
// .flag("force", 'f', "Skip the confirmation prompt")
// .option<std::string>("reason", 'r', "Why it's being deleted")
// .describe("Delete a project by name")
// .action(&deleteProject);
//
// Every parameter/option is explicitly typed where it's declared, so the
// engine never has to invent a type from the bound function -- `.action()`
// instead *validates* that the function's corresponding argument is a
// consistent binding (bool for a flag; T, std::optional<T>, or
// std::vector<T> for a parameter/option; by value, by reference, or by
// const reference all work). Mismatches are RegistrationErrors caught at
// startup, not silent surprises at run time.
#include <cctype>
#include <algorithm>
#include <functional>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <tuple>
#include <type_traits>
#include <typeindex>
#include <vector>
#include "FunctionTraits.hpp"
#include "Levenshtein.hpp"
#include "Slot.hpp"
#include "Value.hpp"
namespace cliforge
{
namespace detail
{
// --- small type-trait helpers used only for action() binding ----------
template <typename T> struct IsOptional : std::false_type
{
};
template <typename T> struct IsOptional<std::optional<T>> : std::true_type
{
using Inner = T;
};
template <typename T> inline constexpr bool IsOptionalV = IsOptional<T>::value;
template <typename T> struct IsVector : std::false_type
{
};
template <typename T> struct IsVector<std::vector<T>> : std::true_type
{
using Inner = T;
};
template <typename T> inline constexpr bool IsVectorV = IsVector<T>::value;
template <typename T>
inline constexpr bool IsBuiltinScalarV =
std::is_same_v<T, int8_t> || std::is_same_v<T, uint8_t> || std::is_same_v<T, int16_t> ||
std::is_same_v<T, uint16_t> || std::is_same_v<T, int32_t> ||
std::is_same_v<T, uint32_t> || std::is_same_v<T, int64_t> ||
std::is_same_v<T, uint64_t> || std::is_same_v<T, float> || std::is_same_v<T, double> ||
std::is_same_v<T, std::string> || std::is_same_v<T, char> || std::is_same_v<T, bool>;
// A token is where the unstructured (flags/options) part begins if it
// starts with a dash and isn't shaped like a negative number -- that way
// `build -5 -3 -1` (variadic int parameters) still works even though the
// tokens start with '-'.
bool looksLikeOptionStart(std::string_view tok);
bool isValidKeyword(std::string_view s);
// A human-readable name for a bound action() argument type, used in
// RegistrationError messages. Only built-in scalars and enums are ever
// legal here (anything else is already rejected by parameter<T>()'s own
// static_assert), so this never needs to handle the general case.
template <typename T> std::string friendlyTypeName()
{
if constexpr (IsBuiltinScalarV<T>)
{
return TypeOf<T>::get().displayName;
}
else if constexpr (std::is_enum_v<T>)
{
return "a different enum type";
}
else
{
return "an unsupported type";
}
}
}
// Result of trying to line a command's structured (keyword+parameter)
// signature up against the front of the user's token list.
struct StructuredMatch
{
bool success = false;
std::size_t consumed = 0; // tokens belonging to the structured part
int keywordScore = 0; // number of literal keywords matched
std::vector<Value> paramValues;
// Set when every literal keyword matched but a parameter's value
// either failed to parse or was never given -- lets the Engine
// surface this specific, actionable error instead of a generic
// "no match" + fuzzy suggestions.
std::string error;
};
class Command
{
public:
// Keywords carry no description of their own -- .describe() on the
// command is what shows up in help; a keyword is purely a literal
// token to match.
Command& keyword(const std::string& literal);
// T is explicit and required: every parameter is typed at the point
// it's declared, not inferred later from action(). Called before any
// flag()/option(), this is a fixed-position structured parameter;
// called after, it's a "loose" parameter matched positionally among
// other loose parameters regardless of where flags/options fall
// around it.
template <typename T>
Command& parameter(std::string name, std::string description, bool variadic = false)
{
if (name.empty())
{
throw RegistrationError("parameter name may not be empty");
}
Slot s;
s.kind = SlotKind::Parameter;
s.name = std::move(name);
s.description = std::move(description);
s.variadic = variadic;
s.cppType = std::type_index(typeid(T));
if constexpr (std::is_enum_v<T>)
{
s.awaitingChoices = true; // choices<T>(...) must follow immediately
}
else
{
static_assert(detail::IsBuiltinScalarV<T>,
"cliforge: unsupported parameter type -- use a fixed-width int, "
"float/double, std::string, char, bool, or an enum (with "
"choices<T>() called immediately after)");
s.type = TypeOf<T>::get();
}
if (!m_unstructuredStarted)
{
if (m_structuredSealed)
{
throw RegistrationError("can't declare parameter('" + s.name +
"') after a variadic parameter -- a variadic parameter "
"must be the last thing in the structured part");
}
m_structured.push_back(std::move(s));
m_lastSlot = &m_structured.back();
if (variadic)
m_structuredSealed = true;
}
else
{
if (m_looseSealed)
{
throw RegistrationError("can't declare parameter('" + s.name +
"') after a variadic parameter -- a variadic parameter "
"must be the last parameter declared");
}
m_unstructured.push_back(std::move(s));
m_lastSlot = &m_unstructured.back();
if (variadic)
m_looseSealed = true;
}
return *this;
}
// shortName is 0 (the default) for "no short form".
Command& flag(std::string longName, char shortName, const std::string& description);
// T is explicit and required, same as parameter<T>(). Options are
// always optional in the calling convention (see action()) -- there
// is no way to declare a "required" option, by design.
template <typename T>
Command& option(std::string longName, char shortName, std::string description,
bool variadic = false)
{
longName = stripDashes(std::move(longName));
validateLongName(longName);
validateShortName(shortName);
checkNameFree(longName, shortName);
Slot s;
s.kind = SlotKind::Option;
s.name = std::move(longName);
s.shortName = shortName;
s.description = std::move(description);
s.variadic = variadic;
s.cppType = std::type_index(typeid(T));
if constexpr (std::is_enum_v<T>)
{
s.awaitingChoices = true;
}
else
{
static_assert(detail::IsBuiltinScalarV<T>,
"cliforge: unsupported option type -- use a fixed-width int, "
"float/double, std::string, char, bool, or an enum (with "
"choices<T>() called immediately after)");
s.type = TypeOf<T>::get();
}
m_unstructured.push_back(std::move(s));
m_lastSlot = &m_unstructured.back();
m_unstructuredStarted = true;
return *this;
}
Command& describe(const std::string& description);
// Restricts the most-recently-declared parameter/option to a fixed
// set of named values -- this is how custom enums plug in (T is
// deduced from the initializer list, no need to spell it out again),
// and it doubles as a generic "choice" restriction for any type. T
// must match the type declared at parameter<T>()/option<T>().
template <typename T>
Command& choices(std::initializer_list<std::pair<std::string, T>> values,
std::string typeName = "")
{
if (m_lastSlot == nullptr ||
(m_lastSlot->kind != SlotKind::Parameter && m_lastSlot->kind != SlotKind::Option))
{
throw RegistrationError("choices<>() must directly follow the parameter<T>() or "
"option<T>() it restricts");
}
if (!m_lastSlot->cppType || *m_lastSlot->cppType != std::type_index(typeid(T)))
{
throw RegistrationError("choices<>() type doesn't match the type declared for '" +
m_lastSlot->name + "'");
}
std::vector<std::pair<std::string, T>> table(values);
m_lastSlot->type = makeChoiceTypeInfo<T>(
typeName.empty() ? std::string("enum") : std::move(typeName), table);
m_lastSlot->awaitingChoices = false;
return *this;
}
// Binds this command to a free function (or captureless/capturing
// lambda, or std::function). Argument types are validated against
// what was already declared via parameter<T>()/option<T>()/flag() --
// action() never invents a type, it only checks consistency -- and
// matched, in order, to [parameters in declaration order] then
// [flags/options/loose-parameters in declaration order]. Arguments
// may be taken by value, by reference, or by const reference.
template <typename Func> Command& action(Func f)
{
using Traits = detail::FunctionTraits<std::decay_t<Func>>;
if (m_structured.empty() ||
std::none_of(m_structured.begin(), m_structured.end(),
[](const Slot& s) { return s.kind == SlotKind::Keyword; }))
{
throw RegistrationError("a command must declare at least one keyword() so it can "
"be identified on the command line");
}
for (const Slot& s : m_structured)
checkNotAwaitingChoices(s);
for (const Slot& s : m_unstructured)
checkNotAwaitingChoices(s);
std::vector<Slot*> ordered = orderedBindableSlots();
if (ordered.size() != Traits::Arity)
{
throw RegistrationError(
"action() function takes " + std::to_string(Traits::Arity) +
" argument(s) but this command declared " + std::to_string(ordered.size()) +
" parameter/flag/option slot(s) -- these must match 1:1, "
"in declaration order (parameters first, then flags/options)");
}
bindArgs(std::function{f}, ordered, std::make_index_sequence<Traits::Arity>{});
m_sealed = true;
return *this;
}
[[nodiscard]] bool sealed() const;
[[nodiscard]] const std::string& description() const;
// --- matching / parsing entry points, used by Engine -------------
[[nodiscard]] StructuredMatch tryMatchStructured(
const std::vector<std::string>& tokens) const;
// Parses the (order-agnostic) flags/options/loose-parameters tail.
// Throws CliError (with a specific ErrorKind) describing exactly what
// went wrong: an unrecognized flag/option, a missing option value, a
// missing/extra positional argument, or a type mismatch.
[[nodiscard]] std::vector<Value> parseUnstructured(
const std::vector<std::string>& tokens) const;
void invoke(std::vector<Value> allValues) const;
[[nodiscard]] std::size_t structuredSlotCount() const;
// Lenient prefix check used only for --help / `help <words>` : does
// `tokens` look like the start of (or a complete instance of) this
// command, without requiring every required argument to actually be
// present? Keywords still must match exactly; parameter slots accept
// any token; running out of input mid-command is fine (that's exactly
// what makes it a "prefix"). Used to find *every* command consistent
// with what's been typed so far, so an ambiguous prefix can show help
// for all of them instead of guessing one.
[[nodiscard]] bool couldMatchPrefix(const std::vector<std::string>& tokens) const;
// --- help / usage --------------------------------------------------
[[nodiscard]] std::string usageLine() const;
[[nodiscard]] std::string helpText(const std::string& progName) const;
[[nodiscard]] std::vector<std::string> flagOptionNames() const;
// Fuzzy structural distance used for "did you mean...?" suggestions.
struct FuzzyScore
{
std::size_t distance = 0;
std::size_t keywordChars = 0; // denominator for a relative threshold
};
[[nodiscard]] FuzzyScore fuzzyStructuralDistance(
const std::vector<std::string>& tokens) const;
private:
std::vector<Slot> m_structured;
std::vector<Slot> m_unstructured;
std::string m_description;
std::function<void(std::vector<Value>&)> m_invoke;
Slot* m_lastSlot = nullptr;
bool m_unstructuredStarted = false;
bool m_structuredSealed = false;
bool m_looseSealed = false;
bool m_sealed = false;
static std::string stripDashes(std::string s);
void validateLongName(const std::string& s) const;
void validateShortName(char c) const;
void checkNameFree(const std::string& longName, char shortName) const;
static void checkNotAwaitingChoices(const Slot& s);
std::vector<Slot*> orderedBindableSlots();
// Validates (never invents) that a slot's declared type matches T.
template <typename T> static void checkTypeMatches(const Slot& slot)
{
if (!slot.cppType || *slot.cppType != std::type_index(typeid(T)))
{
throw RegistrationError("'" + slot.name + "' was declared as " +
slot.type.displayName + " but action() expects " +
detail::friendlyTypeName<T>());
}
}
// ArgT is the *raw* action() parameter type, e.g. `const std::string&`
// or `std::vector<int32_t>` -- validated after stripping cv/ref.
template <typename ArgT> static void configureSlot(Slot& slot)
{
using Stripped = std::remove_cvref_t<ArgT>;
if (slot.kind == SlotKind::Flag)
{
if constexpr (!std::is_same_v<Stripped, bool>)
{
throw RegistrationError("flag '--" + slot.name +
"' must bind to bool in action()");
}
return;
}
if constexpr (detail::IsOptionalV<Stripped>)
{
using Inner = typename detail::IsOptional<Stripped>::Inner;
if (slot.kind == SlotKind::Parameter)
{
throw RegistrationError("parameter '<" + slot.name +
">' can't bind to std::optional<> -- positional "
"parameters are always required");
}
if (slot.variadic)
{
throw RegistrationError(
"option '--" + slot.name +
"' is variadic; bind it to std::vector<>, not std::optional<>");
}
checkTypeMatches<Inner>(slot);
}
else if constexpr (detail::IsVectorV<Stripped>)
{
using Inner = typename detail::IsVector<Stripped>::Inner;
if (!slot.variadic)
{
throw RegistrationError(
"'" + slot.name +
"' binds to std::vector<> but wasn't declared variadic=true");
}
checkTypeMatches<Inner>(slot);
}
else
{
if (slot.variadic)
{
throw RegistrationError(
"'" + slot.name + "' was declared variadic=true; bind it to std::vector<>");
}
if (slot.kind == SlotKind::Option)
{
throw RegistrationError(
"option '--" + slot.name +
"' must bind to std::optional<T> (or std::vector<T> if "
"variadic) -- options are always optional, never required");
}
checkTypeMatches<Stripped>(slot);
}
}
template <typename ArgT> static ArgT extractArg(const Value& v)
{
if constexpr (std::is_same_v<ArgT, bool>)
{
return v.as<bool>();
}
else if constexpr (detail::IsOptionalV<ArgT>)
{
using Inner = typename detail::IsOptional<ArgT>::Inner;
return v.hasValue() ? std::optional<Inner>(v.as<Inner>()) : std::nullopt;
}
else if constexpr (detail::IsVectorV<ArgT>)
{
using Inner = typename detail::IsVector<ArgT>::Inner;
return v.asVector<Inner>();
}
else
{
return v.as<ArgT>();
}
}
// Extracted values are materialized into a tuple of *value* types
// first, then handed to f via std::apply -- std::get on that (named,
// lvalue) tuple yields lvalues, which is what lets f's parameters be
// declared by value, by reference, or by const reference and all
// still bind correctly.
template <typename Ret, typename... Args, std::size_t... I>
void bindArgs(std::function<Ret(Args...)> f, std::vector<Slot*>& ordered,
std::index_sequence<I...>)
{
(configureSlot<Args>(*ordered[I]), ...);
m_invoke = [f = std::move(f)](std::vector<Value>& values)
{
std::tuple<std::remove_cvref_t<Args>...> extracted{
extractArg<std::remove_cvref_t<Args>>(values[I])...};
std::apply(f, extracted);
};
}
[[nodiscard]] int findByLong(std::string_view name) const;
[[nodiscard]] int findByShort(char c) const;
[[nodiscard]] std::string suggestFlagOption(const std::string& given) const;
[[noreturn]] void throwUnknown(std::string_view tok) const;
static std::string describeType(const Slot& slot);
static std::string padRight(const std::string& s, std::size_t width);
static void consumeSlot(const Command& self, int idxIn, bool hasInline,
std::string_view inlineValue,
const std::vector<std::string>& tokens, std::size_t& i,
std::vector<bool>& provided,
std::vector<std::vector<Scalar>>& collected,
std::string_view tokForError);
};
}
@@ -0,0 +1,56 @@
#pragma once
// Engine owns every registered Command and is the single entry point:
//
// Engine cli;
// cli.describe("A demo CLI");
// cli.command()....action(&fn);
// return cli.run(argc, argv);
#include <algorithm>
#include <iostream>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "Command.hpp"
#include "Levenshtein.hpp"
namespace cliforge
{
class Engine
{
public:
explicit Engine(std::string programName = "");
Engine& describe(const std::string& description);
// Registers a new, empty command and returns a reference to it so it
// can be built up with the fluent .keyword()/.parameter()/... API.
// The Engine owns it for the rest of the program's lifetime.
Command& command();
int run(int argc, char** argv);
private:
std::string m_programName;
std::string m_description;
std::vector<std::unique_ptr<Command>> m_commands;
static std::string basename(const std::string& path);
static std::string join(const std::vector<std::string>& tokens);
// Finds every command consistent with `queryTokens` as a prefix and
// shows its help -- one command's help if there's exactly one match,
// all of them (clearly separated) if the prefix is genuinely
// ambiguous, or the global command list plus a hint if there's none.
[[nodiscard]] int showHelp(const std::vector<std::string>& queryTokens) const;
void printGlobalHelp(std::ostream& os) const;
void suggestCommands(const std::vector<std::string>& tokens, std::ostream& os) const;
static std::string firstLine(const std::string& s);
static std::string padRight(const std::string& s, std::size_t width);
};
}
@@ -0,0 +1,61 @@
#pragma once
// Deduces the return type and argument-type pack of "anything callable":
// plain free-function pointers, non-generic lambdas (captureless or
// capturing), and std::function instances. This is what lets
// Command::action() figure out, at compile time, exactly which C++ types
// it needs to convert parsed CLI tokens into -- without the user ever
// having to spell the types out twice.
#include <cstddef>
#include <functional>
#include <tuple>
namespace cliforge::detail
{
template <typename T> struct FunctionTraits;
// Plain free function: void foo(std::string, int)
template <typename Ret, typename... Args> struct FunctionTraits<Ret (*)(Args...)>
{
using ReturnType = Ret;
static constexpr std::size_t Arity = sizeof...(Args);
template <std::size_t I> using Arg = std::tuple_element_t<I, std::tuple<Args...>>;
using ArgsTuple = std::tuple<Args...>;
};
// std::function<void(std::string, int)>
template <typename Ret, typename... Args> struct FunctionTraits<std::function<Ret(Args...)>>
{
using ReturnType = Ret;
static constexpr std::size_t Arity = sizeof...(Args);
template <std::size_t I> using Arg = std::tuple_element_t<I, std::tuple<Args...>>;
using ArgsTuple = std::tuple<Args...>;
};
// Pointer-to-member-function of operator(), const and non-const, used to
// peel apart lambdas (which are just anonymous functors under the hood).
template <typename C, typename Ret, typename... Args>
struct FunctionTraits<Ret (C::*)(Args...) const>
{
using ReturnType = Ret;
static constexpr std::size_t Arity = sizeof...(Args);
template <std::size_t I> using Arg = std::tuple_element_t<I, std::tuple<Args...>>;
using ArgsTuple = std::tuple<Args...>;
};
template <typename C, typename Ret, typename... Args> struct FunctionTraits<Ret (C::*)(Args...)>
{
using ReturnType = Ret;
static constexpr std::size_t Arity = sizeof...(Args);
template <std::size_t I> using Arg = std::tuple_element_t<I, std::tuple<Args...>>;
using ArgsTuple = std::tuple<Args...>;
};
// Fallback: any other callable (lambda, functor) -- peel off operator().
template <typename F> struct FunctionTraits : FunctionTraits<decltype(&F::operator())>
{
};
}
@@ -0,0 +1,21 @@
#pragma once
// Plain edit-distance + a small "closest matches" helper, used to power
// the "did you mean...?" suggestions for unknown commands, flags and
// options.
#include <algorithm>
#include <string>
#include <vector>
namespace cliforge::detail
{
std::size_t levenshtein(const std::string& a, const std::string& b);
// Returns up to `limit` candidates from `pool` that are close to `query`,
// ordered by distance. Anything farther than a generous relative
// threshold is dropped so we never suggest something wildly unrelated.
std::vector<std::string> closestMatches(const std::string& query,
const std::vector<std::string>& pool,
std::size_t limit = 3);
}
+64
View File
@@ -0,0 +1,64 @@
#pragma once
// A `Slot` is one piece of a command's signature: a literal keyword, a
// typed positional parameter, a boolean flag, or a typed option. The
// same struct is reused for all four so Command can keep them in plain
// vectors and the matcher/parser code doesn't need four parallel types.
#include <optional>
#include <string>
#include <typeindex>
#include "Value.hpp"
namespace cliforge
{
enum class SlotKind
{
Keyword,
Parameter,
Flag,
Option
};
struct Slot
{
SlotKind kind{};
// Keyword: the literal text the user must type (e.g. "delete").
// Parameter: the display name (e.g. "name" -> shown as <name>).
// Flag/Option: the long name WITHOUT the leading "--" (e.g. "force").
std::string name;
// Flags/Options only: single-letter short name (e.g. 'f'), or '\0'
// if no short form was registered. A plain char instead of a string
// avoids a heap allocation (and the temptation to compare via
// std::string) for what is always exactly zero or one character.
char shortName = '\0';
std::string description;
// Parameter/Option only: may this slot consume more than one token?
// Only the *last* structured parameter, or the *last* loose
// parameter, or any option, may be variadic.
bool variadic = false;
// Set immediately by parameter<T>()/option<T>(), or by choices<T>(),
// never deferred -- so the type is always known and displayable even
// before action() is called.
TypeInfo type;
// The exact C++ type T declared via parameter<T>()/option<T>(), kept
// so action() can *validate* (not invent) the bound function's
// corresponding argument type. Unset for Keyword/Flag slots.
std::optional<std::type_index> cppType;
// True from parameter<T>()/option<T>() (when T is an enum) until the
// matching choices<T>() call supplies the name table; action() checks
// this is false for every slot before binding.
bool awaitingChoices = false;
[[nodiscard]] std::string longFlag() const;
[[nodiscard]] std::string shortFlag() const;
};
}
+367
View File
@@ -0,0 +1,367 @@
#pragma once
// The type system. A `Scalar` is a closed variant over every primitive
// type the engine understands. A `Value` wraps either a single Scalar
// (for ordinary parameters/options) or a vector of them (for variadic
// parameters/options). `TypeInfo` bundles a display name with a parser:
// for the built-in scalar types this is a *plain stateless function
// pointer* (no heap allocation, unlike std::function), and only the
// rarer enum/choice-restricted case pays for a one-time, shared, heap
// allocation to hold its name table.
#include <cstdint>
#include <algorithm>
#include <charconv>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <type_traits>
#include <variant>
#include <vector>
namespace cliforge
{
// ---------------------------------------------------------------------
// Exceptions
// ---------------------------------------------------------------------
// Every distinct reason a run-time parse can fail. Exposed on CliError so
// callers (and Engine internally, for its own error-selection logic) can
// branch on *why* something failed, not just read a free-text message.
enum class ErrorKind
{
Generic,
UnknownOption, // a flag/option name isn't recognized for this command
TooManyArguments, // more positional values than the command declares
MissingArgument, // a required positional value (or option value) was never given
MissingValue, // an option/flag was named but its value token is missing
TypeMismatch, // a token couldn't convert to the slot's declared type
};
// Base of every error the engine raises while registering commands or
// parsing user input. Engine::run() catches CliError and prints it
// nicely instead of letting it escape as an unhandled exception.
class CliError : public std::runtime_error
{
public:
explicit CliError(const std::string& msg, ErrorKind kind = ErrorKind::Generic);
[[nodiscard]] ErrorKind kind() const noexcept;
private:
ErrorKind m_kind;
};
// A token couldn't be converted to the type a slot expects -- always
// ErrorKind::TypeMismatch.
class ParseError : public CliError
{
public:
explicit ParseError(const std::string& msg);
};
// Something is wrong with how a command was *built* (mismatched arity
// between declared slots and the bound function, duplicate names, a
// choices<T>() call whose T doesn't match the declared type, an option
// bound to a non-optional type, an action() parameter type that doesn't
// match what was declared, etc). These fire at program startup (many of
// them at compile time via static_assert), not at end-user parse time.
struct RegistrationError : CliError
{
explicit RegistrationError(const std::string& msg);
};
// ---------------------------------------------------------------------
// Enum storage
// ---------------------------------------------------------------------
// Custom enums aren't a native Scalar alternative (there are infinitely
// many enum types), so when a slot is restricted to an enum via
// choices<T>(), the underlying value is boxed here. Value::as<T>() knows
// to unbox + static_cast back to the caller's real enum type.
struct EnumTag
{
long long value;
};
// ---------------------------------------------------------------------
// Scalar: the closed set of representable primitive values
// ---------------------------------------------------------------------
using Scalar = std::variant<int8_t, uint8_t, int16_t, uint16_t, int32_t, uint32_t, int64_t,
uint64_t, float, double, std::string, char, bool, EnumTag>;
enum class Kind
{
I8,
U8,
I16,
U16,
I32,
U32,
I64,
U64,
F32,
F64,
Str,
Char,
Bool,
Enum
};
// ---------------------------------------------------------------------
// TypeInfo: how to parse + describe one type
// ---------------------------------------------------------------------
// Holds the name table for an enum/choice-restricted slot. Built once at
// registration time (a single, amortized allocation via shared_ptr);
// parsing against it never allocates beyond the error path.
struct ChoiceTable
{
std::vector<std::pair<std::string, Scalar>> entries;
[[nodiscard]] Scalar parse(std::string_view token, std::string_view label) const;
};
struct TypeInfo
{
Kind kind{};
std::string displayName;
// Stateless parser for built-in scalar types -- a plain function
// pointer costs nothing to store or copy, unlike std::function.
Scalar (*parsePlain)(std::string_view token, std::string_view label) = nullptr;
// Populated only for choices<T>()-restricted slots.
std::shared_ptr<const ChoiceTable> choiceTable;
[[nodiscard]] bool valid() const;
[[nodiscard]] Scalar parse(std::string_view token, std::string_view label) const;
};
namespace detail
{
template <typename Int>
Int parseInteger(std::string_view token, std::string_view label, std::string_view typeName)
{
Int out{};
auto [ptr, ec] = std::from_chars(token.data(), token.data() + token.size(), out);
if (ec != std::errc{} || ptr != token.data() + token.size())
{
throw ParseError("type mismatch for '" + std::string(label) + "': got '" +
std::string(token) + "', expected " + std::string(typeName));
}
return out;
}
template <typename Float>
Float parseFloating(std::string_view token, std::string_view label,
std::string_view typeName)
{
Float out{};
auto [ptr, ec] = std::from_chars(token.data(), token.data() + token.size(), out);
if (ec != std::errc{} || ptr != token.data() + token.size())
{
throw ParseError("type mismatch for '" + std::string(label) + "': got '" +
std::string(token) + "', expected " + std::string(typeName));
}
return out;
}
std::string toLower(std::string_view s);
}
// Maps a C++ type T to its Kind + a ready-to-use TypeInfo. Specialized
// below for every built-in type; enums go through choices<T>() instead
// since they need a name<->value table supplied by the caller.
template <typename T> struct TypeOf; // intentionally undefined for unsupported T
#define CLIFORGE_INT_TYPE(CPP_T, KIND, NAME) \
template <> struct TypeOf<CPP_T> \
{ \
static TypeInfo get() \
{ \
TypeInfo t; \
t.kind = Kind::KIND; \
t.displayName = NAME; \
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar \
{ return Scalar{detail::parseInteger<CPP_T>(tok, lbl, NAME)}; }; \
return t; \
} \
};
CLIFORGE_INT_TYPE(int8_t, I8, "int8")
CLIFORGE_INT_TYPE(uint8_t, U8, "uint8")
CLIFORGE_INT_TYPE(int16_t, I16, "int16")
CLIFORGE_INT_TYPE(uint16_t, U16, "uint16")
CLIFORGE_INT_TYPE(int32_t, I32, "int32")
CLIFORGE_INT_TYPE(uint32_t, U32, "uint32")
CLIFORGE_INT_TYPE(int64_t, I64, "int64")
CLIFORGE_INT_TYPE(uint64_t, U64, "uint64")
#undef CLIFORGE_INT_TYPE
template <> struct TypeOf<float>
{
static TypeInfo get()
{
TypeInfo t;
t.kind = Kind::F32;
t.displayName = "float32";
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar
{ return Scalar{detail::parseFloating<float>(tok, lbl, "float32")}; };
return t;
}
};
template <> struct TypeOf<double>
{
static TypeInfo get()
{
TypeInfo t;
t.kind = Kind::F64;
t.displayName = "float64";
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar
{ return Scalar{detail::parseFloating<double>(tok, lbl, "float64")}; };
return t;
}
};
template <> struct TypeOf<std::string>
{
static TypeInfo get()
{
TypeInfo t;
t.kind = Kind::Str;
t.displayName = "string";
t.parsePlain = [](std::string_view tok, std::string_view) -> Scalar
{ return Scalar{std::string(tok)}; };
return t;
}
};
template <> struct TypeOf<char>
{
static TypeInfo get()
{
TypeInfo t;
t.kind = Kind::Char;
t.displayName = "char";
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar
{
if (tok.size() != 1)
{
throw ParseError("type mismatch for '" + std::string(lbl) + "': got '" +
std::string(tok) + "', expected a single char");
}
return Scalar{tok[0]};
};
return t;
}
};
template <> struct TypeOf<bool>
{
static TypeInfo get()
{
TypeInfo t;
t.kind = Kind::Bool;
t.displayName = "bool";
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar
{
std::string lower = detail::toLower(tok);
if (lower == "true" || lower == "1" || lower == "yes" || lower == "on")
{
return Scalar{true};
}
if (lower == "false" || lower == "0" || lower == "no" || lower == "off")
{
return Scalar{false};
}
throw ParseError("type mismatch for '" + std::string(lbl) + "': got '" +
std::string(tok) +
"', expected bool (true/false, 1/0, yes/no, on/off)");
};
return t;
}
};
// Builds a TypeInfo for a `choices<T>()` restricted slot: only the
// listed string keys are accepted, each mapped to a T. Works both for
// genuine C++ enums (boxed into EnumTag) and for restricting a built-in
// type (e.g. a string option limited to a fixed set of values). The
// choice table is converted to type-erased Scalars once, here, so
// nothing downstream needs to be templated on T again.
template <typename T>
TypeInfo makeChoiceTypeInfo(std::string typeName,
const std::vector<std::pair<std::string, T>>& choices)
{
auto table = std::make_shared<ChoiceTable>();
table->entries.reserve(choices.size());
for (const auto& [name, val] : choices)
{
if constexpr (std::is_enum_v<T>)
{
table->entries.emplace_back(name, Scalar{EnumTag{static_cast<long long>(val)}});
}
else
{
table->entries.emplace_back(name, Scalar{val});
}
}
TypeInfo t;
t.kind = Kind::Enum;
t.displayName = std::move(typeName);
t.choiceTable = std::move(table);
return t;
}
// ---------------------------------------------------------------------
// Value: what actually flows out of the parser and into bound functions
// ---------------------------------------------------------------------
class Value
{
public:
Value();
static Value ofScalar(Scalar s);
static Value ofVector(std::vector<Scalar> v);
[[nodiscard]] bool hasValue() const;
[[nodiscard]] bool isVector() const;
template <typename T> [[nodiscard]] T as() const
{
const Scalar& s = std::get<Scalar>(m_data);
if constexpr (std::is_enum_v<T>)
{
return static_cast<T>(std::get<EnumTag>(s).value);
}
else
{
return std::get<T>(s);
}
}
template <typename T> std::vector<T> asVector() const
{
const auto& vec = std::get<std::vector<Scalar>>(m_data);
std::vector<T> out;
out.reserve(vec.size());
for (const Scalar& s : vec)
{
if constexpr (std::is_enum_v<T>)
{
out.push_back(static_cast<T>(std::get<EnumTag>(s).value));
}
else
{
out.push_back(std::get<T>(s));
}
}
return out;
}
private:
std::variant<std::monostate, Scalar, std::vector<Scalar>> m_data;
};
}
+705
View File
@@ -0,0 +1,705 @@
#include "CLIForge/Command.hpp"
namespace cliforge
{
namespace detail
{
bool looksLikeOptionStart(std::string_view tok)
{
if (tok.size() < 2 || tok[0] != '-')
return false;
if (tok[1] == '-')
return true; // "--foo"
if (std::isdigit(static_cast<unsigned char>(tok[1])))
return false; // "-5"
return true; // "-n", "-ndi"
}
bool isValidKeyword(std::string_view s)
{
if (s.empty())
return false;
if (s[0] == '-')
return false; // reserved for flags/options
for (char c : s)
{
if (!(std::isalnum(static_cast<unsigned char>(c)) || c == '.' || c == '_' ||
c == '-'))
{
return false;
}
}
return true;
}
}
Command& Command::keyword(const std::string& literal)
{
if (m_unstructuredStarted)
{
throw RegistrationError("can't declare keyword('" + literal +
"') after a flag()/option() was added -- keywords must "
"come entirely before the unstructured part");
}
if (m_structuredSealed)
{
throw RegistrationError("can't declare keyword('" + literal +
"') after a variadic parameter -- a variadic parameter "
"must be the last thing in the structured part");
}
if (!detail::isValidKeyword(literal))
{
throw RegistrationError("invalid keyword '" + literal +
"': keywords may contain letters, digits, '.', '_' and "
"'-', and may not start with '-'");
}
Slot s;
s.kind = SlotKind::Keyword;
s.name = std::move(literal);
m_structured.push_back(std::move(s));
m_lastSlot = &m_structured.back();
return *this;
}
// shortName is 0 (the default) for "no short form".
Command& Command::flag(std::string longName, char shortName, const std::string& description)
{
longName = stripDashes(std::move(longName));
validateLongName(longName);
validateShortName(shortName);
checkNameFree(longName, shortName);
Slot s;
s.kind = SlotKind::Flag;
s.name = std::move(longName);
s.shortName = shortName;
s.description = std::move(description);
m_unstructured.push_back(std::move(s));
m_lastSlot = &m_unstructured.back();
m_unstructuredStarted = true;
return *this;
}
Command& Command::describe(const std::string& description)
{
m_description = std::move(description);
return *this;
}
bool Command::sealed() const
{
return m_sealed;
}
const std::string& Command::description() const
{
return m_description;
}
// --- matching / parsing entry points, used by Engine -------------
StructuredMatch Command::tryMatchStructured(const std::vector<std::string>& tokens) const
{
StructuredMatch m;
std::size_t i = 0;
try
{
for (std::size_t s = 0; s < m_structured.size(); ++s)
{
const Slot& slot = m_structured[s];
bool isLast = (s + 1 == m_structured.size());
if (slot.kind == SlotKind::Keyword)
{
if (i >= tokens.size() || tokens[i] != slot.name)
return StructuredMatch{};
++i;
++m.keywordScore;
}
else
{
if (slot.variadic && isLast)
{
std::vector<Scalar> collected;
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i]))
{
collected.push_back(slot.type.parse(tokens[i], slot.name));
++i;
}
m.paramValues.push_back(Value::ofVector(std::move(collected)));
}
else
{
if (i >= tokens.size() || detail::looksLikeOptionStart(tokens[i]))
{
// Every keyword up to here matched literally, so
// this is almost certainly the intended command
// -- just missing a required argument.
throw CliError("missing required argument '<" + slot.name +
">': expected a value of type " +
slot.type.displayName,
ErrorKind::MissingArgument);
}
m.paramValues.push_back(
Value::ofScalar(slot.type.parse(tokens[i], slot.name)));
++i;
}
}
}
}
catch (const CliError& e)
{
// Either a type mismatch (ParseError, a CliError) or the
// MissingArgument thrown just above -- both mean "this is the
// command you meant, but something about the value is wrong",
// so remember it instead of treating it as a plain non-match.
m.success = false;
m.error = e.what();
return m;
}
m.success = true;
m.consumed = i;
return m;
}
// Parses the (order-agnostic) flags/options/loose-parameters tail.
// Throws CliError (with a specific ErrorKind) describing exactly what
// went wrong: an unrecognized flag/option, a missing option value, a
// missing/extra positional argument, or a type mismatch.
std::vector<Value> Command::parseUnstructured(const std::vector<std::string>& tokens) const
{
std::vector<bool> provided(m_unstructured.size(), false);
std::vector<std::vector<Scalar>> collected(m_unstructured.size());
std::vector<std::string_view> looseTokens; // views into `tokens`, which outlives this call
looseTokens.reserve(tokens.size());
std::size_t i = 0;
while (i < tokens.size())
{
std::string_view tok = tokens[i];
if (!detail::looksLikeOptionStart(tok))
{
// Not flag/option syntax (includes negative numbers like
// "-5") -- it's a value for the next loose parameter.
looseTokens.push_back(tok);
++i;
continue;
}
if (tok[1] == '-')
{
std::string_view rest = tok.substr(2);
auto eq = rest.find('=');
bool hasInline = (eq != std::string_view::npos);
std::string_view longName = hasInline ? rest.substr(0, eq) : rest;
std::string_view inlineValue = hasInline ? rest.substr(eq + 1) : std::string_view{};
int idx = findByLong(longName);
if (idx < 0)
throwUnknown(tok);
++i;
consumeSlot(*this, idx, hasInline, inlineValue, tokens, i, provided, collected,
tok);
}
else
{
std::string_view rest = tok.substr(1);
if (rest.size() == 1)
{
int idx = findByShort(rest[0]);
if (idx < 0)
throwUnknown(tok);
++i;
consumeSlot(*this, idx, false, std::string_view{}, tokens, i, provided,
collected, tok);
}
else
{
// Combined short flags, e.g. "-ndi" == -n -d -i.
for (char c : rest)
{
int idx = findByShort(c);
if (idx < 0)
{
throw CliError(std::string("unknown flag '-") + c +
"' in combined group '" + std::string(tok) + "'" +
suggestFlagOption(std::string("-") + c),
ErrorKind::UnknownOption);
}
if (m_unstructured[static_cast<size_t>(idx)].kind != SlotKind::Flag)
{
throw CliError(std::string("'-") + c + "' in combined group '" +
std::string(tok) +
"' is an option (needs a value), so it can't "
"be combined with other short flags");
}
provided[static_cast<size_t>(idx)] = true;
}
++i;
}
}
}
// Assign the collected loose tokens to any declared loose
// Parameter slots, positionally, in declaration order -- this is
// independent of where those tokens fell relative to flags/options.
std::vector<std::size_t> looseParamIdx;
for (std::size_t idx = 0; idx < m_unstructured.size(); ++idx)
{
if (m_unstructured[idx].kind == SlotKind::Parameter)
looseParamIdx.push_back(idx);
}
std::vector<Value> looseValues(looseParamIdx.size());
std::size_t li = 0;
for (std::size_t k = 0; k < looseParamIdx.size(); ++k)
{
const Slot& slot = m_unstructured[looseParamIdx[k]];
bool isLastLoose = (k + 1 == looseParamIdx.size());
if (slot.variadic && isLastLoose)
{
std::vector<Scalar> vals;
vals.reserve(looseTokens.size() > li ? looseTokens.size() - li : 0);
while (li < looseTokens.size())
{
vals.push_back(slot.type.parse(looseTokens[li], slot.name));
++li;
}
looseValues[k] = Value::ofVector(std::move(vals));
}
else
{
if (li >= looseTokens.size())
{
throw CliError("missing required argument '<" + slot.name +
">': expected a value of type " + slot.type.displayName,
ErrorKind::MissingArgument);
}
looseValues[k] = Value::ofScalar(slot.type.parse(looseTokens[li], slot.name));
++li;
}
}
if (li < looseTokens.size())
{
std::size_t extra = looseTokens.size() - li;
std::string msg =
"too many arguments: unexpected '" + std::string(looseTokens[li]) + "'";
if (extra > 1)
msg += " (and " + std::to_string(extra - 1) + " more)";
throw CliError(msg, ErrorKind::TooManyArguments);
}
// Assemble the final values in m_unstructured's declaration order --
// this is what gets paired with the bound function's arguments.
std::vector<Value> out;
out.reserve(m_unstructured.size());
std::size_t looseCursor = 0;
for (std::size_t idx = 0; idx < m_unstructured.size(); ++idx)
{
const Slot& slot = m_unstructured[idx];
if (slot.kind == SlotKind::Parameter)
{
out.push_back(std::move(looseValues[looseCursor++]));
}
else if (slot.kind == SlotKind::Flag)
{
out.push_back(Value::ofScalar(Scalar{provided[idx]}));
}
else if (slot.variadic)
{
out.push_back(Value::ofVector(collected[idx]));
}
else if (provided[idx])
{
out.push_back(Value::ofScalar(collected[idx][0]));
}
else
{
out.push_back(Value{}); // options are always optional -> unset means nullopt
}
}
return out;
}
void Command::invoke(std::vector<Value> allValues) const
{
m_invoke(allValues);
}
std::size_t Command::structuredSlotCount() const
{
return m_structured.size();
}
bool Command::couldMatchPrefix(const std::vector<std::string>& tokens) const
{
std::size_t i = 0;
for (std::size_t s = 0; s < m_structured.size(); ++s)
{
if (i >= tokens.size())
break; // ran out of input -- a valid, incomplete prefix
const Slot& slot = m_structured[s];
bool isLast = (s + 1 == m_structured.size());
if (slot.kind == SlotKind::Keyword)
{
if (tokens[i] != slot.name)
return false;
++i;
}
else if (slot.variadic && isLast)
{
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i]))
++i;
}
else
{
if (detail::looksLikeOptionStart(tokens[i]))
break; // structured part left incomplete
++i;
}
}
// Whatever tokens remain must be plausible flags/options (assumed
// fine without deep validation) or fit a declared loose parameter.
std::vector<const Slot*> looseParams;
for (const Slot& s : m_unstructured)
{
if (s.kind == SlotKind::Parameter)
looseParams.push_back(&s);
}
std::size_t looseIdx = 0;
for (; i < tokens.size(); ++i)
{
if (detail::looksLikeOptionStart(tokens[i]))
continue;
if (looseIdx >= looseParams.size())
return false; // nothing left to absorb this token
if (!looseParams[looseIdx]->variadic)
++looseIdx;
}
return true;
}
// --- help / usage --------------------------------------------------
std::string Command::usageLine() const
{
std::ostringstream os;
for (std::size_t s = 0; s < m_structured.size(); ++s)
{
const Slot& slot = m_structured[s];
if (s)
os << ' ';
if (slot.kind == SlotKind::Keyword)
{
os << slot.name;
}
else
{
os << '<' << slot.name << (slot.variadic ? "..." : "") << '>';
}
}
bool anyFlagOrOption =
std::any_of(m_unstructured.begin(), m_unstructured.end(),
[](const Slot& s) { return s.kind != SlotKind::Parameter; });
if (anyFlagOrOption)
os << " [OPTIONS]";
for (const Slot& slot : m_unstructured)
{
if (slot.kind != SlotKind::Parameter)
continue;
os << " <" << slot.name << (slot.variadic ? "..." : "") << '>';
}
return os.str();
}
std::string Command::helpText(const std::string& progName) const
{
std::ostringstream os;
os << "Usage: " << progName << " " << usageLine() << "\n";
if (!m_description.empty())
os << "\n" << m_description << "\n";
bool anyParams = std::any_of(m_structured.begin(), m_structured.end(),
[](const Slot& s) { return s.kind == SlotKind::Parameter; }) ||
std::any_of(m_unstructured.begin(), m_unstructured.end(),
[](const Slot& s) { return s.kind == SlotKind::Parameter; });
if (anyParams)
{
os << "\nArguments:\n";
for (const Slot& slot : m_structured)
{
if (slot.kind != SlotKind::Parameter)
continue;
std::string label = "<" + slot.name + (slot.variadic ? "..." : "") + ">";
os << " " << padRight(label, 22) << describeType(slot) << slot.description << "\n";
}
for (const Slot& slot : m_unstructured)
{
if (slot.kind != SlotKind::Parameter)
continue;
std::string label = "<" + slot.name + (slot.variadic ? "..." : "") + ">";
os << " " << padRight(label, 22) << describeType(slot) << slot.description << "\n";
}
}
bool anyFlagOrOption =
std::any_of(m_unstructured.begin(), m_unstructured.end(),
[](const Slot& s) { return s.kind != SlotKind::Parameter; });
os << "\nOptions:\n";
if (anyFlagOrOption)
{
for (const Slot& slot : m_unstructured)
{
if (slot.kind == SlotKind::Parameter)
continue;
std::string names = slot.shortName == '\0'
? (" --" + slot.name)
: (std::string("-") + slot.shortName + ", --" + slot.name);
if (slot.kind == SlotKind::Option)
{
names += " <" + slot.type.displayName + (slot.variadic ? "...>" : ">");
}
os << " " << padRight(names, 28) << slot.description << "\n";
}
}
os << " " << padRight("-h, --help", 28) << "Show this help message\n";
return os.str();
}
std::vector<std::string> Command::flagOptionNames() const
{
std::vector<std::string> out;
for (const Slot& s : m_unstructured)
{
if (s.kind == SlotKind::Parameter)
continue;
out.push_back("--" + s.name);
if (s.shortName != '\0')
out.push_back(std::string("-") + s.shortName);
}
return out;
}
Command::FuzzyScore Command::fuzzyStructuralDistance(
const std::vector<std::string>& tokens) const
{
std::size_t i = 0;
FuzzyScore score;
for (std::size_t s = 0; s < m_structured.size(); ++s)
{
const Slot& slot = m_structured[s];
bool isLast = (s + 1 == m_structured.size());
if (slot.kind == SlotKind::Keyword)
{
score.keywordChars += slot.name.size();
if (i >= tokens.size())
{
score.distance += slot.name.size(); // command is longer than what was typed
continue;
}
score.distance += detail::levenshtein(tokens[i], slot.name);
++i;
}
else if (slot.variadic && isLast)
{
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i]))
++i;
}
else if (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i]))
{
++i; // parameters are wildcards: any token "fits", no penalty
}
}
score.distance += (tokens.size() > i) ? (tokens.size() - i) : 0; // leftover tokens
return score;
}
std::string Command::stripDashes(std::string s)
{
while (!s.empty() && s.front() == '-')
s.erase(s.begin());
return s;
}
void Command::validateLongName(const std::string& s) const
{
if (s.empty())
throw RegistrationError("a flag/option needs a non-empty long name");
if (s == "help")
{
throw RegistrationError("'help' is reserved for the built-in --help flag");
}
for (char c : s)
{
if (!(std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_'))
{
throw RegistrationError("invalid long name '--" + s + "'");
}
}
}
void Command::validateShortName(char c) const
{
if (c == '\0')
return; // no short form
if (!std::isalnum(static_cast<unsigned char>(c)))
{
throw RegistrationError(
std::string("short names must be a single letter/digit, got '-") + c + "'");
}
if (c == 'h')
throw RegistrationError("'-h' is reserved for the built-in --help flag");
}
void Command::checkNameFree(const std::string& longName, char shortName) const
{
for (const Slot& s : m_unstructured)
{
if (s.kind == SlotKind::Parameter)
continue; // different namespace (bare, not "--name")
if (s.name == longName)
{
throw RegistrationError("duplicate long name '--" + longName + "'");
}
if (shortName != '\0' && s.shortName == shortName)
{
throw RegistrationError(std::string("duplicate short name '-") + shortName + "'");
}
}
}
void Command::checkNotAwaitingChoices(const Slot& s)
{
if (s.awaitingChoices)
{
throw RegistrationError("'" + s.name +
"' is a custom enum type and needs choices<T>(...) "
"called right after it is declared");
}
}
std::vector<Slot*> Command::orderedBindableSlots()
{
std::vector<Slot*> out;
for (Slot& s : m_structured)
{
if (s.kind == SlotKind::Parameter)
out.push_back(&s);
}
for (Slot& s : m_unstructured)
out.push_back(&s);
return out;
}
int Command::findByLong(std::string_view name) const
{
for (std::size_t i = 0; i < m_unstructured.size(); ++i)
{
if (m_unstructured[i].kind != SlotKind::Parameter && m_unstructured[i].name == name)
{
return static_cast<int>(i);
}
}
return -1;
}
int Command::findByShort(char c) const
{
if (c == '\0')
return -1;
for (std::size_t i = 0; i < m_unstructured.size(); ++i)
{
if (m_unstructured[i].kind != SlotKind::Parameter && m_unstructured[i].shortName == c)
{
return static_cast<int>(i);
}
}
return -1;
}
std::string Command::suggestFlagOption(const std::string& given) const
{
auto names = flagOptionNames();
names.push_back("--help");
names.push_back("-h");
auto close = detail::closestMatches(given, names, 1);
if (close.empty())
return "";
return " -- did you mean '" + close.front() + "'?";
}
[[noreturn]] void Command::throwUnknown(std::string_view tok) const
{
throw CliError("unknown flag/option '" + std::string(tok) + "' for this command" +
suggestFlagOption(std::string(tok)),
ErrorKind::UnknownOption);
}
std::string Command::describeType(const Slot& slot)
{
return "<" + slot.type.displayName + "> ";
}
std::string Command::padRight(const std::string& s, std::size_t width)
{
if (s.size() >= width)
return s + " ";
return s + std::string(width - s.size(), ' ');
}
void Command::consumeSlot(const Command& self, int idxIn, bool hasInline,
std::string_view inlineValue, const std::vector<std::string>& tokens,
std::size_t& i, std::vector<bool>& provided,
std::vector<std::vector<Scalar>>& collected,
std::string_view tokForError)
{
std::size_t idx = static_cast<std::size_t>(idxIn);
const Slot& slot = self.m_unstructured[idx];
if (slot.kind == SlotKind::Flag)
{
if (hasInline)
throw CliError("flag '--" + slot.name + "' does not take a value");
provided[idx] = true;
return;
}
// Option
if (hasInline)
{
collected[idx].push_back(slot.type.parse(inlineValue, slot.name));
provided[idx] = true;
if (slot.variadic)
{
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i]))
{
collected[idx].push_back(slot.type.parse(tokens[i], slot.name));
++i;
}
}
return;
}
if (slot.variadic)
{
std::size_t before = i;
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i]))
{
collected[idx].push_back(slot.type.parse(tokens[i], slot.name));
++i;
}
if (i == before)
{
throw CliError("option '" + std::string(tokForError) +
"' requires at least one value",
ErrorKind::MissingValue);
}
provided[idx] = true;
}
else
{
if (i >= tokens.size() || detail::looksLikeOptionStart(tokens[i]))
{
throw CliError("option '" + std::string(tokForError) + "' requires a value",
ErrorKind::MissingValue);
}
collected[idx] = {slot.type.parse(tokens[i], slot.name)};
provided[idx] = true;
++i;
}
};
}
+296
View File
@@ -0,0 +1,296 @@
#include "CLIForge/Engine.hpp"
namespace cliforge
{
Engine::Engine(std::string programName) : m_programName(std::move(programName))
{
}
Engine& Engine::describe(const std::string& description)
{
m_description = std::move(description);
return *this;
}
// Registers a new, empty command and returns a reference to it so it
// can be built up with the fluent .keyword()/.parameter()/... API.
// The Engine owns it for the rest of the program's lifetime.
Command& Engine::command()
{
m_commands.push_back(std::make_unique<Command>());
return *m_commands.back();
}
int Engine::run(int argc, char** argv)
{
if (m_programName.empty() && argc > 0)
m_programName = basename(argv[0]);
for (auto& cmd : m_commands)
{
if (!cmd->sealed())
{
std::cerr << "cliforge BUG: a command was registered without calling .action() "
"on it -- every command must be bound to a function.\n";
return 2;
}
}
std::vector<std::string> tokens;
for (int i = 1; i < argc; ++i)
tokens.emplace_back(argv[i]);
if (tokens.empty())
{
printGlobalHelp(std::cout);
return 0;
}
// Centralized help handling: `help [words...]`, or --help/-h
// appearing anywhere. Both funnel into the same prefix search, so
// an ambiguous prefix (e.g. "project --help" matching create/
// delete/info) shows help for every command it's consistent with,
// instead of silently picking one or matching nothing.
std::optional<std::vector<std::string>> helpQuery;
if (tokens[0] == "help")
{
helpQuery = std::vector<std::string>(tokens.begin() + 1, tokens.end());
}
else if (auto it = std::find_if(tokens.begin(), tokens.end(), [](const std::string& t)
{ return t == "--help" || t == "-h"; });
it != tokens.end())
{
std::vector<std::string> q = tokens;
q.erase(q.begin() + (it - tokens.begin()));
helpQuery = std::move(q);
}
if (helpQuery)
{
return showHelp(*helpQuery);
}
struct Candidate
{
Command* cmd{};
StructuredMatch match;
};
std::vector<Candidate> candidates;
std::string bestPartialError;
std::vector<Command*> bestPartialCmds; // every command tied for the best partial score
int bestPartialScore = -1;
for (auto& cmd : m_commands)
{
StructuredMatch m = cmd->tryMatchStructured(tokens);
if (m.success)
{
candidates.push_back({cmd.get(), std::move(m)});
}
else if (!m.error.empty())
{
if (m.keywordScore > bestPartialScore)
{
bestPartialScore = m.keywordScore;
bestPartialError = m.error;
bestPartialCmds.clear();
bestPartialCmds.push_back(cmd.get());
}
else if (m.keywordScore == bestPartialScore)
{
// Genuinely tied -- e.g. "project" alone is an equally
// incomplete prefix of create/delete/info, all of which
// report the identical "missing <name>" error. Rather
// than silently picking whichever was registered first
// (which read as an arbitrary, misleading suggestion),
// remember all of them and show every option.
bestPartialCmds.push_back(cmd.get());
}
}
}
if (candidates.empty())
{
if (!bestPartialCmds.empty())
{
// Every keyword matched some command, just with a bad or
// missing argument value -- this is much more actionable
// than a generic "no command matches" + fuzzy suggestions.
std::cerr << "Error: " << bestPartialError << "\n";
if (bestPartialCmds.size() == 1)
{
std::cerr << "Usage: " << m_programName << " "
<< bestPartialCmds.front()->usageLine() << "\n";
}
else
{
std::cerr << "'" << join(tokens) << "' matches " << bestPartialCmds.size()
<< " commands:\n";
for (auto* cmd : bestPartialCmds)
{
std::string line = m_programName + " " + cmd->usageLine();
std::cerr << " " << padRight(line, 46) << firstLine(cmd->description())
<< "\n";
}
}
return 1;
}
std::cerr << "Error: no command matches '" << join(tokens) << "'.\n";
suggestCommands(tokens, std::cerr);
return 1;
}
std::sort(candidates.begin(), candidates.end(),
[](const Candidate& a, const Candidate& b)
{
if (a.match.keywordScore != b.match.keywordScore)
return a.match.keywordScore > b.match.keywordScore;
return a.cmd->structuredSlotCount() > b.cmd->structuredSlotCount();
});
std::string firstError;
Command* firstErrorCmd = nullptr;
bool haveError = false;
for (auto& c : candidates)
{
std::vector<std::string> remaining(tokens.begin() + static_cast<long>(c.match.consumed),
tokens.end());
try
{
std::vector<Value> values = c.cmd->parseUnstructured(remaining);
std::vector<Value> all = c.match.paramValues;
for (auto& v : values)
all.push_back(std::move(v));
c.cmd->invoke(std::move(all));
return 0;
}
catch (const CliError& e)
{
// Candidates are ranked best-first (most keyword matches
// first), so the *first* failure is the one most likely to
// be what the user actually meant -- keep that, not
// whichever candidate happens to fail last.
if (!haveError)
{
firstError = e.what();
firstErrorCmd = c.cmd;
haveError = true;
}
}
}
std::cerr << "Error: " << firstError << "\n";
if (firstErrorCmd)
std::cerr << "Usage: " << m_programName << " " << firstErrorCmd->usageLine() << "\n";
return 1;
}
std::string Engine::basename(const std::string& path)
{
auto pos = path.find_last_of("/\\");
return pos == std::string::npos ? path : path.substr(pos + 1);
}
std::string Engine::join(const std::vector<std::string>& tokens)
{
std::string out;
for (std::size_t i = 0; i < tokens.size(); ++i)
{
if (i)
out += ' ';
out += tokens[i];
}
return out;
}
// Finds every command consistent with `queryTokens` as a prefix and
// shows its help -- one command's help if there's exactly one match,
// all of them (clearly separated) if the prefix is genuinely
// ambiguous, or the global command list plus a hint if there's none.
int Engine::showHelp(const std::vector<std::string>& queryTokens) const
{
if (queryTokens.empty())
{
printGlobalHelp(std::cout);
return 0;
}
std::vector<Command*> matches;
for (auto& cmd : m_commands)
{
if (cmd->couldMatchPrefix(queryTokens))
matches.push_back(cmd.get());
}
if (matches.empty())
{
std::cout << "No command matches '" << join(queryTokens) << "'.\n\n";
printGlobalHelp(std::cout);
return 1;
}
if (matches.size() == 1)
{
std::cout << matches.front()->helpText(m_programName);
return 0;
}
std::cout << "'" << join(queryTokens) << "' matches " << matches.size() << " commands:\n\n";
for (std::size_t i = 0; i < matches.size(); ++i)
{
if (i)
std::cout << "\n";
std::cout << matches[i]->helpText(m_programName);
}
return 0;
}
void Engine::printGlobalHelp(std::ostream& os) const
{
os << "Usage: " << m_programName << " <command> [ARGUMENTS] [OPTIONS]\n";
if (!m_description.empty())
os << "\n" << m_description << "\n";
os << "\nCommands:\n";
for (auto& cmd : m_commands)
{
std::string line = m_programName + " " + cmd->usageLine();
os << " " << padRight(line, 46) << firstLine(cmd->description()) << "\n";
}
os << "\nRun '" << m_programName << " help <command>' or add --help/-h to any command "
<< "for details.\n";
}
void Engine::suggestCommands(const std::vector<std::string>& tokens, std::ostream& os) const
{
std::vector<std::pair<double, Command*>> scored;
for (auto& cmd : m_commands)
{
Command::FuzzyScore s = cmd->fuzzyStructuralDistance(tokens);
double ratio = s.keywordChars ? static_cast<double>(s.distance) /
static_cast<double>(s.keywordChars)
: 1.0;
scored.emplace_back(ratio, cmd.get());
}
std::sort(scored.begin(), scored.end(), [](auto& a, auto& b) { return a.first < b.first; });
std::vector<Command*> close;
for (auto& [ratio, cmd] : scored)
{
if (close.size() >= 3)
break;
if (ratio <= 0.5)
close.push_back(cmd);
}
if (close.empty())
return;
os << "\nDid you mean:\n";
for (auto* cmd : close)
os << " " << m_programName << " " << cmd->usageLine() << "\n";
}
std::string Engine::firstLine(const std::string& s)
{
auto pos = s.find('\n');
return pos == std::string::npos ? s : s.substr(0, pos);
}
std::string Engine::padRight(const std::string& s, std::size_t width)
{
if (s.size() >= width)
return s + " ";
return s + std::string(width - s.size(), ' ');
}
}
+58
View File
@@ -0,0 +1,58 @@
#include "CLIForge/Levenshtein.hpp"
namespace cliforge::detail
{
std::size_t levenshtein(const std::string& a, const std::string& b)
{
const std::size_t n = a.size(), m = b.size();
std::vector<std::size_t> prev(m + 1), cur(m + 1);
for (std::size_t j = 0; j <= m; ++j)
prev[j] = j;
for (std::size_t i = 1; i <= n; ++i)
{
cur[0] = i;
for (std::size_t j = 1; j <= m; ++j)
{
std::size_t cost = (a[i - 1] == b[j - 1]) ? 0 : 1;
cur[j] = std::min({prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost});
}
std::swap(prev, cur);
}
return prev[m];
}
// Returns up to `limit` candidates from `pool` that are close to `query`,
// ordered by distance. Anything farther than a generous relative
// threshold is dropped so we never suggest something wildly unrelated.
std::vector<std::string> closestMatches(const std::string& query,
const std::vector<std::string>& pool, std::size_t limit)
{
std::vector<std::pair<std::size_t, std::string>> scored;
scored.reserve(pool.size());
for (const auto& candidate : pool)
{
std::size_t d = levenshtein(query, candidate);
// A prefix relationship (e.g. "--arch" for "--architectures") is a
// very strong signal -- treat it as near-identical even though its
// raw edit distance is large, so common abbreviations still surface.
if (query.size() >= 2 && candidate.size() > query.size() &&
candidate.compare(0, query.size(), query) == 0)
{
d = std::min<std::size_t>(d, 1);
}
scored.emplace_back(d, candidate);
}
std::sort(scored.begin(), scored.end(), [](auto& a, auto& b) { return a.first < b.first; });
std::size_t threshold = std::max<std::size_t>(3, query.size() / 2);
std::vector<std::string> out;
for (auto& [dist, name] : scored)
{
if (out.size() >= limit)
break;
if (dist <= threshold)
out.push_back(name);
}
return out;
}
}
+13
View File
@@ -0,0 +1,13 @@
#include "CLIForge/Slot.hpp"
namespace cliforge
{
std::string Slot::longFlag() const
{
return "--" + name;
}
std::string Slot::shortFlag() const
{
return shortName ? std::string("-") + shortName : std::string();
}
}
+85
View File
@@ -0,0 +1,85 @@
#include "CLIForge/Value.hpp"
namespace cliforge
{
CliError::CliError(const std::string& msg, ErrorKind kind)
: std::runtime_error(msg), m_kind(kind)
{
}
ErrorKind CliError::kind() const noexcept
{
return m_kind;
}
ParseError::ParseError(const std::string& msg) : CliError(msg, ErrorKind::TypeMismatch)
{
}
RegistrationError::RegistrationError(const std::string& msg) : CliError(msg, ErrorKind::Generic)
{
}
Scalar ChoiceTable::parse(std::string_view token, std::string_view label) const
{
for (const auto& [name, val] : entries)
{
if (name == token)
return val;
}
std::string allowed;
for (size_t i = 0; i < entries.size(); ++i)
{
if (i)
allowed += ", ";
allowed += entries[i].first;
}
throw ParseError("type mismatch for '" + std::string(label) + "': got '" +
std::string(token) + "', expected one of: " + allowed);
}
bool TypeInfo::valid() const
{
return parsePlain != nullptr || choiceTable != nullptr;
}
Scalar TypeInfo::parse(std::string_view token, std::string_view label) const
{
return choiceTable ? choiceTable->parse(token, label) : parsePlain(token, label);
}
namespace detail
{
std::string toLower(std::string_view s)
{
std::string out(s);
std::transform(out.begin(), out.end(), out.begin(),
[](unsigned char c) { return std::tolower(c); });
return out;
}
}
Value::Value() = default;
Value Value::ofScalar(Scalar s)
{
Value v;
v.m_data = std::move(s);
return v;
}
Value Value::ofVector(std::vector<Scalar> v)
{
Value out;
out.m_data = std::move(v);
return out;
}
bool Value::hasValue() const
{
return !std::holds_alternative<std::monostate>(m_data);
}
bool Value::isVector() const
{
return std::holds_alternative<std::vector<Scalar>>(m_data);
}
}
+3
View File
@@ -0,0 +1,3 @@
# Unit tests of breeze_core: what can be checked without running the SDK's scripts. The end-to-end
# test (tests/e2e.sh at the project root) covers the rest against the built program.
add_test_module(core_tests DEPENDS breeze_core)
+500
View File
@@ -0,0 +1,500 @@
// A small harness and the tests of breeze_core's logic: text, versions, CMake editing, projects and
// their settings, modules, argument handling and process running. A test is a function that checks;
// the program exits with the number of failed checks (0 when all hold).
#include <filesystem>
#include <functional>
#include <iostream>
#include <source_location>
#include <string>
#include <vector>
#include "breeze/Arguments.hpp"
#include "breeze/CMakeText.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Modules.hpp"
#include "breeze/Process.hpp"
#include "breeze/Project.hpp"
#include "breeze/Text.hpp"
#include "breeze/Version.hpp"
namespace
{
int g_failures = 0;
void check(bool condition, std::source_location where = std::source_location::current())
{
if (!condition)
{
++g_failures;
std::cerr << where.file_name() << ":" << where.line() << ": check failed\n";
}
}
template <typename A, typename E>
void checkEq(const A& actual, const E& expected,
std::source_location where = std::source_location::current())
{
if (!(actual == expected))
{
++g_failures;
std::cerr << where.file_name() << ":" << where.line() << ": was '" << actual
<< "', expected '" << expected << "'\n";
}
}
template <typename F> bool throwsError(F&& f)
{
try
{
f();
}
catch (const breeze::Error&)
{
return true;
}
return false;
}
namespace sfs = std::filesystem;
struct TempDir
{
TempDir() = default;
TempDir(const TempDir&) = delete;
TempDir& operator=(const TempDir&) = delete;
TempDir(TempDir&&) = delete;
TempDir& operator=(TempDir&&) = delete;
sfs::path path = breeze::fs::makeTempDir("breeze-unit");
~TempDir()
{
std::error_code ec;
sfs::remove_all(path, ec);
}
};
void write(const sfs::path& file, const std::string& text)
{
breeze::fs::writeText(file, text);
}
// A project with the breeze/ layout and these settings and modules.
sfs::path makeProject(const sfs::path& root)
{
write(root / "CMakeLists.txt", "cmake_minimum_required(VERSION 3.25)\n");
write(root / "breeze" / "cmake" / "Breeze.cmake", "# sdk\n");
write(root / "breeze" / "cmake" / "BuildHelpers.cmake",
"macro(add_objects_module NAME)\nendmacro()\n");
write(root / "breeze" / "VERSION", "1.2.3\n");
write(root / "project" / "Configuration.cmake",
"# settings\n"
"set(PROJECT_TITLE \"Demo\")\n"
"set(PROJECT_VERSION_NUMBER \"0.1.0\")\n"
"set(PROJECT_CODE_NAME \"demo\" CACHE STRING\n"
" \"A machine-friendly name.\")\n");
return root;
}
// -----------------------------------------------------------------------------------------
// text
void textHelpers()
{
using namespace breeze::text;
checkEq(snakeCase("CoreFarewell"), "core_farewell");
checkEq(snakeCase("HelloApp"), "hello_app");
checkEq(snakeCase("HTTPServer"), "http_server");
checkEq(snakeCase("my-app"), "my_app");
checkEq(snakeCase("already_snake"), "already_snake");
checkEq(snakeCase("Util"), "util");
checkEq(normalized("Clang-CL"), "clangcl");
checkEq(normalized("MinGW64"), "mingw64");
check(isName("Core"));
check(isName("my_mod2"));
check(!isName("9lives"));
check(!isName("bad name"));
check(!isName("with-dash"));
check(isName("with-dash", true));
check(isTargetId("fmt::fmt"));
check(isTargetId("core_lib"));
check(!isTargetId("bad id"));
checkEq(trim(" x \n"), "x");
checkEq(join({"a", "b", "c"}, ", "), "a, b, c");
checkEq(replaceAll("a-b-c", "-", "+"), "a+b+c");
checkEq(splitLines("one\r\ntwo\nthree\n").size(), std::size_t(3));
check(startsWith("breeze", "bre"));
check(endsWith("breeze", "eze"));
}
// --------------------------------------------------------------------------------------
// versions
void versions()
{
using namespace breeze;
checkEq(parseVersion("1.2.3")->toString(), "1.2.3");
checkEq(parseVersion("v1.2.3")->toString(), "1.2.3");
checkEq(parseVersion("1.2")->toString(), "1.2.0");
checkEq(parseVersion("2")->toString(), "2.0.0");
checkEq(parseVersion("1.0.0-rc1")->toString(), "1.0.0-rc1");
checkEq(parseVersion("1.0.0+build5")->toString(), "1.0.0");
check(!parseVersion("a.b.c"));
check(!parseVersion("1.2.3.4"));
check(!parseVersion(""));
check(!parseVersion("notaversion"));
check(*parseVersion("1.0.0-rc1") < *parseVersion("1.0.0"));
check(*parseVersion("1.9.0") < *parseVersion("1.10.0"));
check(*parseVersion("1.2.3") == *parseVersion("v1.2.3"));
std::vector<Version> all{*parseVersion("1.0.0"), *parseVersion("1.2.0"),
*parseVersion("2.0.0"), *parseVersion("1.1.5")};
checkEq(greatest(all)->toString(), "2.0.0");
checkEq(greatest(all, 1)->toString(), "1.2.0");
check(!greatest(all, 3));
}
// ----------------------------------------------------------------------------------- cmake
// text
void cmakeText()
{
using namespace breeze::cmake;
std::string text = "# add_exe_module(in_a_comment)\n"
"message(\"add_exe_module(in_a_string)\")\n"
"#[[ add_exe_module(in_a_bracket_comment) ]]\n"
"add_exe_module(real_one OUTPUT_NAME \"real\"\n"
" DEPENDS a # trailing comment\n"
" b::c)\n"
"set(NAME \"old value\" CACHE STRING\n"
" \"doc\")\n";
auto wanted = [](const std::string& n) { return n == "add_exe_module"; };
std::vector<Call> calls = findCalls(text, wanted);
checkEq(calls.size(), std::size_t(1));
if (calls.size() == 1)
{
checkEq(calls[0].args.size(), std::size_t(6));
checkEq(calls[0].args[0].text, "real_one");
checkEq(calls[0].args[2].text, "real");
check(calls[0].args[2].quoted);
checkEq(calls[0].args[5].text, "b::c");
}
std::optional<Call> set = findSet(text, "NAME");
check(set.has_value());
if (set)
{
checkEq(set->args[1].text, "old value");
std::string edited = text;
replaceToken(edited, set->args[1], quote("new \"value\""));
check(edited.find("set(NAME \"new \\\"value\\\"\" CACHE STRING\n \"doc\")") !=
std::string::npos);
check(edited.find("# add_exe_module(in_a_comment)") !=
std::string::npos); // everything else untouched
}
check(!findSet(text, "MISSING"));
}
// ------------------------------------------------------------------------------------ projects
void projectDetection()
{
using namespace breeze;
TempDir t;
makeProject(t.path);
std::optional<Sdk> sdk = detectSdk(t.path);
check(sdk.has_value());
if (sdk)
{
check(sdk->layout == SdkLayout::Breeze);
checkEq(sdk->version->toString(), "1.2.3");
check(sdk->supportsObjects);
checkEq(sdk->describe(), "1.2.3");
}
write(t.path / "source" / "Mod" / "x.cpp", "");
std::optional<Project> found = findProject(t.path / "source" / "Mod");
check(found.has_value());
if (found)
check(found->root == sfs::weakly_canonical(t.path));
TempDir empty;
check(!detectSdk(empty.path));
check(throwsError([&] { requireProject(empty.path); }));
// the layout before breeze/: scripts/, cmake/ and Configuration.cmake at the root
TempDir legacy;
write(legacy.path / "CMakeLists.txt", "project(Old VERSION 3.4.5)\n");
write(legacy.path / "Configuration.cmake", "set(PROJECT_CODE_NAME \"old\")\n");
write(legacy.path / "cmake" / "BuildHelpers.cmake", "# no objects\n");
std::optional<Sdk> old = detectSdk(legacy.path);
check(old.has_value());
if (old)
{
check(old->layout == SdkLayout::Legacy);
check(!old->supportsObjects);
check(!old->version.has_value());
}
}
void settings()
{
using namespace breeze;
TempDir t;
makeProject(t.path);
Project p = *findProject(t.path);
checkEq(readSetting(p, Setting::Name), "Demo");
checkEq(readSetting(p, Setting::Version), "0.1.0");
checkEq(readSetting(p, Setting::Id), "demo");
writeSetting(p, Setting::Name, "Renamed");
writeSetting(p, Setting::Version, "2.0.1");
writeSetting(p, Setting::Id, "renamed_id");
checkEq(readSetting(p, Setting::Name), "Renamed");
checkEq(readSetting(p, Setting::Version), "2.0.1");
checkEq(readSetting(p, Setting::Id), "renamed_id");
std::string file = fs::readText(p.sdk.settingsFile);
check(file.find("CACHE STRING\n \"A machine-friendly name.\")") !=
std::string::npos); // the rest is kept
check(throwsError([&] { writeSetting(p, Setting::Version, "1.x"); }));
check(throwsError([&] { writeSetting(p, Setting::Id, "Upper"); }));
check(throwsError([&] { writeSetting(p, Setting::Name, "bad name"); }));
TempDir legacy;
write(legacy.path / "CMakeLists.txt",
"cmake_minimum_required(VERSION 3.25)\nproject(Old VERSION 3.4.5 LANGUAGES CXX)\n");
write(legacy.path / "Configuration.cmake",
"set(PROJECT_CODE_NAME \"old\" CACHE STRING \"x\")\n");
write(legacy.path / "cmake" / "BuildHelpers.cmake", "");
Project lp = *findProject(legacy.path);
checkEq(readSetting(lp, Setting::Name), "Old");
checkEq(readSetting(lp, Setting::Version), "3.4.5");
checkEq(readSetting(lp, Setting::Id), "old");
writeSetting(lp, Setting::Version, "4.0");
writeSetting(lp, Setting::Name, "Newer");
check(fs::readText(legacy.path / "CMakeLists.txt")
.find("project(Newer VERSION 4.0 LANGUAGES CXX)") != std::string::npos);
}
void defaults()
{
using namespace breeze;
TempDir t;
makeProject(t.path);
Project p = *findProject(t.path);
Defaults d = Defaults::load(p);
check(!d.build("preset"));
d.setBuild("preset", "linux-x64-gcc");
d.setRun("hello_exe");
d.save(p);
Defaults again = Defaults::load(p);
checkEq(*again.build("preset"), "linux-x64-gcc");
checkEq(*again.run(), "hello_exe");
again.clearBuild("preset");
again.clearRun();
again.save(p);
check(!sfs::exists(p.defaultsFile())); // nothing left to keep: the file goes
}
// ------------------------------------------------------------------------------------ modules
void modules()
{
using namespace breeze;
TempDir t;
makeProject(t.path);
write(t.path / "source" / "Core" / "CMakeLists.txt",
"add_lib_module(core_lib TYPE SHARED OUTPUT_NAME \"core\" DEPENDS fmt::fmt "
"core_pieces)\n");
write(t.path / "source" / "CorePieces" / "CMakeLists.txt",
"add_objects_module(core_pieces)\n");
write(t.path / "source" / "App" / "CMakeLists.txt",
"add_exe_module(app_exe DEPENDS core_lib)\n");
write(t.path / "source" / "tests" / "CoreTest" / "CMakeLists.txt",
"add_test_module(core_test DEPENDS core_pieces)\n");
Project p = *findProject(t.path);
std::vector<Module> mods = scanModules(p);
checkEq(mods.size(), std::size_t(4));
const Module* core = findModule(mods, "core_lib");
check(core != nullptr);
if (core)
{
check(core->kind == ModuleKind::Lib);
check(core->libType == LibType::Shared);
checkEq(core->outputName, "core");
checkEq(core->deps.size(), std::size_t(2));
}
check(findModule(mods, "CorePieces") != nullptr); // by directory
check(findModule(mods, "corepieces") != nullptr); // by directory, another case
check(findModule(mods, "core_pieces")->kind == ModuleKind::Obj);
check(findModule(mods, "core_test")->kind == ModuleKind::Test);
// dependency queries
checkEq(dependenciesOf(mods, *findModule(mods, "app_exe"), false).size(), std::size_t(1));
std::vector<std::string> all = dependenciesOf(mods, *findModule(mods, "app_exe"), true);
checkEq(all.size(), std::size_t(3)); // core_lib, fmt::fmt, core_pieces
checkEq(dependentsOf(mods, *findModule(mods, "core_pieces"), false).size(), std::size_t(2));
checkEq(dependentsOf(mods, *findModule(mods, "core_pieces"), true).size(), std::size_t(3));
// adding
NewModule lib;
lib.kind = ModuleKind::Lib;
lib.name = "Strings";
lib.libType = LibType::Static;
lib.deps = {"core_pieces", "fmt::fmt"};
Module made = addModule(p, lib);
checkEq(made.id, "strings_lib");
check(sfs::exists(t.path / "source" / "Strings" / "include" / "strings" / "Strings.hpp"));
check(sfs::exists(t.path / "source" / "Strings" / "src" / "Strings.cpp"));
NewModule test;
test.kind = ModuleKind::Test;
test.name = "StringsTest";
checkEq(addModule(p, test).id, "strings_test"); // not strings_test_test
check(sfs::exists(t.path / "source" / "tests" / "StringsTest" / "main.cpp"));
NewModule noType;
noType.kind = ModuleKind::Lib;
noType.name = "NoType";
check(throwsError([&] { addModule(p, noType); }));
NewModule duplicate;
duplicate.kind = ModuleKind::Exec;
duplicate.name = "Other";
duplicate.id = "app_exe";
check(throwsError([&] { addModule(p, duplicate); }));
NewModule onExe;
onExe.kind = ModuleKind::Exec;
onExe.name = "OnExe";
onExe.deps = {"app_exe"};
check(throwsError([&] { addModule(p, onExe); }));
NewModule badName;
badName.kind = ModuleKind::Exec;
badName.name = "9bad";
check(throwsError([&] { addModule(p, badName); }));
// renaming: the id, the directory, and the DEPENDS of whoever names it
RenameResult r =
renameModule(p, "core_pieces", std::string("Pieces"), std::string("pieces_obj"));
checkEq(r.newId, "pieces_obj");
check(sfs::exists(t.path / "source" / "Pieces" / "CMakeLists.txt"));
check(!sfs::exists(t.path / "source" / "CorePieces"));
mods = scanModules(p);
check(findModule(mods, "pieces_obj") != nullptr);
check(findModule(mods, "core_pieces") == nullptr);
checkEq(findModule(mods, "core_lib")->deps[1], "pieces_obj");
checkEq(findModule(mods, "core_test")->deps[0], "pieces_obj");
checkEq(findModule(mods, "strings_lib")->deps[0], "pieces_obj");
check(throwsError([&] { renameModule(p, "app_exe", std::nullopt, std::nullopt); }));
check(throwsError([&]
{ renameModule(p, "app_exe", std::nullopt, std::string("core_lib")); }));
// deleting: refused while something depends on it
check(throwsError([&] { deleteModule(p, "pieces_obj"); }));
deleteModule(p, "app_exe");
check(!sfs::exists(t.path / "source" / "App"));
check(throwsError([&] { deleteModule(p, "app_exe"); }));
}
void objectsNeedSdkSupport()
{
using namespace breeze;
TempDir t;
makeProject(t.path);
write(t.path / "breeze" / "cmake" / "BuildHelpers.cmake",
"# an SDK from before objects modules\n");
Project p = *findProject(t.path);
NewModule obj;
obj.kind = ModuleKind::Obj;
obj.name = "Pieces";
check(throwsError([&] { addModule(p, obj); }));
}
// ------------------------------------------------------------------------------------
// arguments
void arguments()
{
using breeze::cli::normalizeArguments;
using V = std::vector<std::string>;
check(normalizeArguments({"build", "--cargs", "-DA=1", "-DB=2", "-j", "4"}) ==
V({"build", "--cargs=-DA=1", "--cargs=-DB=2", "-j", "4"}));
check(normalizeArguments({"run", "hello_exe", "-x", "--verbose", "-q"}) ==
V({"run", "hello_exe", "--args=--verbose", "--args=-q"}));
check(normalizeArguments({"build", "--cargs=-DA=1"}) ==
V({"build", "--cargs=-DA=1"})); // already in the form
check(normalizeArguments({"build", "--cargs"}) ==
V({"build", "--cargs"})); // nothing: CLIForge reports it
check(normalizeArguments({"ls", "--cargs", "-D"}) ==
V({"ls", "--cargs", "-D"})); // not a build command
check(normalizeArguments({}).empty());
}
// ------------------------------------------------------------------------------------
// processes
void processes()
{
#ifndef _WIN32
using namespace breeze;
proc::Options o;
o.capture = true;
proc::Result r = proc::run({"sh", "-c", "echo out; echo err >&2; exit 3"}, o);
checkEq(r.exitCode, 3);
checkEq(r.output, "out\n");
o.mergeStderr = true;
r = proc::run({"sh", "-c", "echo out; echo err >&2"}, o);
check(r.output.find("err") != std::string::npos);
proc::Options env;
env.capture = true;
env.env = {{"BREEZE_TEST_VARIABLE", "present"}};
checkEq(proc::run({"sh", "-c", "printf %s \"$BREEZE_TEST_VARIABLE\""}, env).output,
"present");
TempDir t;
proc::Options in;
in.capture = true;
in.cwd = t.path;
checkEq(
sfs::weakly_canonical(
proc::run({"pwd"}, in).output.substr(0, proc::run({"pwd"}, in).output.size() - 1)),
sfs::weakly_canonical(t.path));
proc::Options quiet;
quiet.silent = true;
checkEq(proc::run({"sh", "-c", "echo hidden; exit 5"}, quiet).exitCode, 5);
checkEq(proc::run({"/no/such/program"}).exitCode, 127);
check(proc::findInPath("sh").has_value());
check(!proc::findInPath("no-such-program-anywhere").has_value());
#endif
}
struct Case
{
const char* name;
std::function<void()> run;
};
}
int main()
{
const std::vector<Case> cases = {
{"text helpers", textHelpers}, {"versions", versions},
{"cmake text", cmakeText}, {"project detection", projectDetection},
{"settings", settings}, {"defaults", defaults},
{"modules", modules}, {"objects modules need SDK support", objectsNeedSdkSupport},
{"arguments", arguments}, {"processes", processes},
};
for (const Case& c : cases)
{
int before = g_failures;
try
{
c.run();
}
catch (const std::exception& e)
{
++g_failures;
std::cerr << c.name << ": unexpected exception: " << e.what() << "\n";
}
std::cout << (g_failures == before ? "ok " : "FAIL ") << c.name << "\n";
}
std::cout << (g_failures == 0 ? "all tests passed"
: std::to_string(g_failures) + " check(s) failed")
<< "\n";
return g_failures == 0 ? 0 : 1;
}