Add project files
This commit is contained in:
commit
f2fe6b8855
114 files changed
+20525
No files matched your search
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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, ¬e);
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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(), ' ');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user