Add project files

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

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
#include "breeze/Arguments.hpp"
#include <set>
namespace breeze::cli
{
std::vector<std::string> normalizeArguments(const std::vector<std::string>& args)
{
if (args.empty())
return args;
const std::string& command = args.front();
if (command != "build" && command != "install" && command != "run")
return args;
// The options of these commands that take other programs' arguments.
static const std::set<std::string> Passthrough = {"--cargs", "--bargs", "--args", "-x"};
// Every option these commands have: where a pass-through list ends.
static const std::set<std::string> Known = {
"--platform", "-P", "--arch", "-A", "--compiler", "-C",
"--mode", "-M", "--jobs", "-j", "--preset", "-p",
"--cargs", "--bargs", "--args", "-x", "--no-configure", "--no-build",
"--help", "-h"};
std::vector<std::string> out;
for (std::size_t i = 0; i < args.size(); ++i)
{
const std::string& token = args[i];
if (i == 0 || Passthrough.count(token) == 0)
{
out.push_back(token);
continue;
}
// `--cargs=...` written by the user is already in the right form and not in
// `Passthrough`.
std::string longName = token == "-x" ? "--args" : token;
while (i + 1 < args.size() && Known.count(args[i + 1]) == 0)
{
out.push_back(longName + "=" + args[i + 1]);
++i;
}
if (out.empty() || out.back().rfind(longName + "=", 0) != 0)
out.push_back(token); // no values: let CLIForge report that
}
return out;
}
}
+321
View File
@@ -0,0 +1,321 @@
#include "breeze/Build.hpp"
#include <iostream>
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Modules.hpp"
#include "breeze/Process.hpp"
#include "breeze/Text.hpp"
namespace breeze
{
namespace sfs = std::filesystem;
std::string buildTypeFor(const std::string& mode)
{
std::string m = text::normalized(mode);
if (m == "release")
return "Release";
if (m == "debug")
return "Debug";
if (m == "releasedebugsymbols" || m == "relwithdebinfo")
return "RelWithDebInfo";
if (m == "minsizerel")
return "MinSizeRel";
throw Error("'" + mode +
"' is not a build mode: use Release, Debug, ReleaseDebugSymbols or MinSizeRel");
}
namespace
{
bool hasFile(const sfs::path& p)
{
std::error_code ec;
return sfs::is_regular_file(p, ec);
}
proc::Options inProject(const Project& project)
{
proc::Options o;
o.cwd = project.root;
return o;
}
sfs::path requireScript(const Project& project, const std::string& name)
{
sfs::path script = project.script(name);
if (!hasFile(script))
throw Error("this project's SDK (" + project.sdk.describe() + ") has no '" +
fs::generic(sfs::relative(script, project.root)) + "'");
return script;
}
std::optional<std::string> modeOf(const BuildRequest& request, const Defaults& defaults)
{
std::optional<std::string> mode = request.mode ? request.mode : defaults.build("mode");
if (!mode)
return std::nullopt;
return buildTypeFor(*mode);
}
std::optional<std::string> presetOf(const Project& project, const Defaults& defaults,
const PresetQuery& query)
{
std::string note;
std::optional<std::string> preset = resolvePreset(project, defaults, query, &note);
if (!note.empty())
std::cerr << "breeze: " << note << "\n";
return preset;
}
bool isUnder(const sfs::path& root, const sfs::path& p)
{
std::error_code ec;
sfs::path r = sfs::weakly_canonical(root, ec);
sfs::path c = sfs::weakly_canonical(p, ec);
auto rel = c.lexically_relative(r);
return !rel.empty() && *rel.begin() != "..";
}
}
std::map<std::string, std::string> readConfigVariables(const Project& project,
const std::vector<std::string>& names)
{
std::map<std::string, std::string> values;
sfs::path printConfig = project.sdk.cmakeDir / "PrintConfig.cmake";
if (hasFile(printConfig))
{
proc::Options o = inProject(project);
o.capture = true;
proc::Result r = proc::run(
{"cmake", "-DPRINT_VARS=" + text::join(names, ";"), "-P", printConfig.string()}, o);
if (r.exitCode == 0)
{
for (const std::string& line : text::splitLines(r.output))
{
if (!text::startsWith(line, "-- "))
continue;
std::size_t eq = line.find('=');
if (eq == std::string::npos)
continue;
values[line.substr(3, eq - 3)] = line.substr(eq + 1);
}
}
}
// Anything the SDK couldn't tell: the layout every SDK so far has used.
for (const std::string& n : names)
{
if (values.count(n) != 0 && !values[n].empty())
continue;
if (n == "BUILD_OUTPUT_DIR")
values[n] = fs::generic(project.root / "out" / "build");
else if (n == "DOCS_OUTPUT_DIR")
values[n] = fs::generic(project.root / "out" / "docs");
}
return values;
}
sfs::path buildDirFor(const Project& project, const std::optional<std::string>& preset)
{
sfs::path buildDirScript = project.sdk.cmakeDir / "BuildDir.cmake";
if (hasFile(buildDirScript))
{
proc::Options o = inProject(project);
o.capture = true;
std::vector<std::string> argv{"cmake"};
if (preset)
argv.push_back("-DPMN_NAME=" + *preset);
argv.push_back("-P");
argv.push_back(buildDirScript.string());
proc::Result r = proc::run(argv, o);
for (const std::string& line : text::splitLines(r.output))
{
if (text::startsWith(line, "-- BUILD_DIR="))
return sfs::path(line.substr(13));
if (text::startsWith(line, "-- ERROR="))
throw Error(line.substr(9));
}
throw Error("could not tell which build directory to use (the SDK's BuildDir.cmake "
"said nothing)");
}
std::map<std::string, std::string> vars =
readConfigVariables(project, {"BUILD_OUTPUT_DIR"});
return sfs::path(vars["BUILD_OUTPUT_DIR"]) / (preset ? *preset : "default");
}
int build(const Project& project, const BuildRequest& request)
{
Defaults defaults = Defaults::load(project);
std::optional<std::string> preset = presetOf(project, defaults, request.preset);
std::optional<std::string> type = modeOf(request, defaults);
proc::Options options = inProject(project);
if (request.jobs)
options.env.emplace_back("CMAKE_BUILD_PARALLEL_LEVEL", std::to_string(*request.jobs));
if (request.buildArgs.empty() && !request.noConfigure)
{
// The ordinary path: the SDK's script configures and builds.
std::vector<std::string> args;
if (preset)
{
args.push_back("--preset");
args.push_back(*preset);
}
if (type)
args.push_back(*type);
args.insert(args.end(), request.configureArgs.begin(), request.configureArgs.end());
return proc::runScript(requireScript(project, "build"), args, options).exitCode;
}
// Extra build arguments, or no configure: build the configured directory directly.
sfs::path dir = buildDirFor(project, preset);
if (!hasFile(dir / "CMakeCache.txt"))
throw Error("'" + fs::generic(dir) +
"' is not configured yet. Run `breeze build` once without " +
(request.noConfigure ? "--no-configure" : "--bargs") + " first");
if (request.noConfigure && !request.configureArgs.empty())
std::cerr << "breeze: --cargs are ignored with --no-configure\n";
std::vector<std::string> argv{"cmake", "--build", dir.string()};
if (type)
{
argv.push_back("--config");
argv.push_back(*type);
}
if (request.jobs)
{
argv.push_back("-j");
argv.push_back(std::to_string(*request.jobs));
}
argv.insert(argv.end(), request.buildArgs.begin(), request.buildArgs.end());
return proc::run(argv, options).exitCode;
}
int install(const Project& project, const BuildRequest& request)
{
if (!request.noBuild)
{
int rc = build(project, request);
if (rc != 0)
return rc;
}
Defaults defaults = Defaults::load(project);
std::optional<std::string> preset = presetOf(project, defaults, request.preset);
std::optional<std::string> type = modeOf(request, defaults);
std::vector<std::string> args;
if (preset)
{
args.push_back("--preset");
args.push_back(*preset);
}
if (type)
args.push_back(*type);
return proc::runScript(requireScript(project, "install"), args, inProject(project))
.exitCode;
}
int docs(const Project& project)
{
Defaults defaults = Defaults::load(project);
std::vector<std::string> args;
if (std::optional<std::string> preset = defaults.build("preset"))
{
args.push_back("--preset");
args.push_back(*preset);
}
int rc = proc::runScript(requireScript(project, "docs"), args, inProject(project)).exitCode;
if (rc != 0 && rc != 127)
std::cerr << "breeze: the documentation needs a build first: run `breeze build`\n";
return rc;
}
int run(const Project& project, const std::optional<std::string>& id, const PresetQuery& where,
const std::vector<std::string>& programArgs)
{
Defaults defaults = Defaults::load(project);
std::string target;
if (id)
{
target = *id;
// A directory name works too, when the module is there.
std::vector<Module> modules = scanModules(project);
if (const Module* m = findModule(modules, *id))
target = m->id;
}
else if (std::optional<std::string> d = defaults.run())
{
target = *d;
}
else
{
std::vector<Module> modules = scanModules(project);
std::vector<std::string> execs;
for (const Module& m : modules)
{
if (m.kind == ModuleKind::Exec)
execs.push_back(m.id);
}
if (execs.size() != 1)
throw Error(std::string(execs.empty() ? "this project has no executable module"
: "which executable? This project has " +
text::join(execs, ", ")) +
". Give an id, or set a default with `breeze defaults run --set <id>`");
target = execs.front();
}
std::optional<std::string> preset = presetOf(project, defaults, where);
std::vector<std::string> args;
if (preset)
{
args.push_back("--preset");
args.push_back(*preset);
}
args.push_back(target);
if (!programArgs.empty())
{
args.push_back("--");
args.insert(args.end(), programArgs.begin(), programArgs.end());
}
return proc::runScript(requireScript(project, "run"), args, inProject(project)).exitCode;
}
int setup(const Project& project, bool required, bool all)
{
std::vector<std::string> args;
if (all)
args.push_back("--all");
else if (required)
args.push_back("--yes");
return proc::runScript(requireScript(project, "setup"), args, inProject(project)).exitCode;
}
std::vector<std::string> clean(const Project& project, bool keepBuild, bool alsoDocs)
{
std::vector<std::string> names;
if (!keepBuild)
names.push_back("BUILD_OUTPUT_DIR");
if (alsoDocs)
names.push_back("DOCS_OUTPUT_DIR");
std::vector<std::string> removed;
if (names.empty())
return removed;
std::map<std::string, std::string> vars = readConfigVariables(project, names);
for (const std::string& n : names)
{
sfs::path dir = vars[n];
if (!isUnder(project.root, dir))
throw Error("refusing to remove '" + fs::generic(dir) +
"': it is outside the project");
std::error_code ec;
if (!sfs::exists(dir, ec))
continue;
fs::removeTree(dir);
removed.push_back(fs::generic(sfs::relative(dir, project.root)));
}
return removed;
}
}
+210
View File
@@ -0,0 +1,210 @@
#include "breeze/CMakeText.hpp"
#include <cctype>
#include "breeze/Text.hpp"
namespace breeze::cmake
{
namespace
{
bool isIdentStart(char c)
{
return std::isalpha(static_cast<unsigned char>(c)) || c == '_';
}
bool isIdentChar(char c)
{
return std::isalnum(static_cast<unsigned char>(c)) || c == '_';
}
// Skips whitespace and comments from pos; returns the new position.
std::size_t skipBlank(const std::string& t, std::size_t pos)
{
while (pos < t.size())
{
char c = t[pos];
if (std::isspace(static_cast<unsigned char>(c)))
{
++pos;
}
else if (c == '#')
{
// bracket comment: #[[ ... ]] or #[=[ ... ]=]
std::size_t eq = pos + 1;
if (eq < t.size() && t[eq] == '[')
{
std::size_t level = 0;
std::size_t p = eq + 1;
while (p < t.size() && t[p] == '=')
{
++level;
++p;
}
if (p < t.size() && t[p] == '[')
{
std::string close = "]" + std::string(level, '=') + "]";
std::size_t endPos = t.find(close, p + 1);
pos = endPos == std::string::npos ? t.size() : endPos + close.size();
continue;
}
}
while (pos < t.size() && t[pos] != '\n')
++pos;
}
else
{
break;
}
}
return pos;
}
// Parses the arguments of a call whose '(' is at `open`; returns the position just after
// the matching ')'.
std::size_t parseArgs(const std::string& t, std::size_t open, std::vector<Token>& args)
{
std::size_t pos = open + 1;
int depth = 1;
while (pos < t.size())
{
pos = skipBlank(t, pos);
if (pos >= t.size())
break;
char c = t[pos];
if (c == ')')
{
if (--depth == 0)
return pos + 1;
++pos;
continue;
}
if (c == '(')
{
++depth;
++pos;
continue;
}
Token tok;
tok.begin = pos;
if (c == '"')
{
tok.quoted = true;
++pos;
while (pos < t.size() && t[pos] != '"')
{
if (t[pos] == '\\' && pos + 1 < t.size())
++pos;
tok.text += t[pos++];
}
if (pos < t.size())
++pos; // closing quote
}
else if (c == '[' && pos + 1 < t.size() && (t[pos + 1] == '[' || t[pos + 1] == '='))
{
std::size_t p = pos + 1;
std::size_t level = 0;
while (p < t.size() && t[p] == '=')
{
++level;
++p;
}
if (p < t.size() && t[p] == '[')
{
std::string close = "]" + std::string(level, '=') + "]";
std::size_t endPos = t.find(close, p + 1);
std::size_t contentEnd = endPos == std::string::npos ? t.size() : endPos;
tok.quoted = true;
tok.text = t.substr(p + 1, contentEnd - p - 1);
pos = endPos == std::string::npos ? t.size() : endPos + close.size();
}
else
{
tok.text = "[";
++pos;
}
}
else
{
while (pos < t.size() && !std::isspace(static_cast<unsigned char>(t[pos])) &&
t[pos] != ')' && t[pos] != '(' && t[pos] != '#' && t[pos] != '"')
tok.text += t[pos++];
if (tok.text.empty())
{
++pos; // a stray character: step over it rather than loop
continue;
}
}
tok.end = pos;
args.push_back(std::move(tok));
}
return t.size();
}
}
std::vector<Call> findCalls(const std::string& text,
const std::function<bool(const std::string&)>& wanted)
{
std::vector<Call> calls;
std::size_t pos = 0;
while (pos < text.size())
{
pos = skipBlank(text, pos);
if (pos >= text.size())
break;
if (!isIdentStart(text[pos]))
{
++pos;
continue;
}
std::size_t nameBegin = pos;
while (pos < text.size() && isIdentChar(text[pos]))
++pos;
std::string name = text.substr(nameBegin, pos - nameBegin);
std::size_t open = pos;
while (open < text.size() && (text[open] == ' ' || text[open] == '\t'))
++open;
if (open >= text.size() || text[open] != '(')
continue;
Call call;
call.name = std::move(name);
call.begin = nameBegin;
call.end = parseArgs(text, open, call.args);
pos = call.end;
if (wanted(call.name))
calls.push_back(std::move(call));
}
return calls;
}
std::optional<Call> findSet(const std::string& text, const std::string& variable)
{
for (Call& call :
findCalls(text, [](const std::string& n) { return text::lower(n) == "set"; }))
{
if (!call.args.empty() && call.args[0].text == variable)
return std::move(call);
}
return std::nullopt;
}
std::string quote(const std::string& value)
{
std::string out = "\"";
for (char c : value)
{
if (c == '"' || c == '\\')
out += '\\';
out += c;
}
out += '"';
return out;
}
void replaceToken(std::string& text, const Token& token, const std::string& replacement)
{
text.replace(token.begin, token.end - token.begin, replacement);
}
}
+290
View File
@@ -0,0 +1,290 @@
#include "breeze/Cli.hpp"
#include <algorithm>
#include <iostream>
#include "breeze/Config.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Process.hpp"
#include "breeze/Text.hpp"
#ifndef BREEZE_CLI_VERSION
#define BREEZE_CLI_VERSION "0.0.0"
#endif
namespace breeze::self
{
namespace sfs = std::filesystem;
std::string version()
{
return BREEZE_CLI_VERSION;
}
namespace
{
sfs::path root()
{
return breezeHome() / "cli";
}
std::string exeName()
{
return fs::hostPlatform() == "windows" ? "breeze.exe" : "breeze";
}
sfs::path binaryOf(const Version& v)
{
return root() / v.toString() / exeName();
}
sfs::path shim()
{
return breezeHome() / "bin" / exeName();
}
Version parseOrThrow(const std::string& text)
{
std::optional<Version> v = parseVersion(text);
if (!v)
throw Error("'" + text + "' is not a version (use 1.2.3)");
return *v;
}
bool isUrl(const std::string& s)
{
return text::startsWith(s, "http://") || text::startsWith(s, "https://");
}
// <source>/<version>/breeze-<platform>-<arch>[.exe], a URL or a directory.
void obtain(const std::string& source, const Version& v, const sfs::path& destination)
{
std::string file = "breeze-" + fs::hostPlatform() + "-" + fs::hostArch() +
(fs::hostPlatform() == "windows" ? ".exe" : "");
std::string base = source;
while (!base.empty() && base.back() == '/')
base.pop_back();
std::string where = base + "/" + v.toString() + "/" + file;
std::error_code ec;
sfs::create_directories(destination.parent_path(), ec);
if (isUrl(source))
{
if (!proc::findInPath("curl"))
throw Error("curl is needed to download CLI versions, and it is not installed");
proc::Result r = proc::run({"curl", "--fail", "--silent", "--show-error",
"--location", "--output", destination.string(), where});
if (r.exitCode != 0)
throw Error("cannot download '" + where + "'");
return;
}
if (text::startsWith(where, "file://"))
where = where.substr(7);
if (!sfs::is_regular_file(where, ec))
throw Error("there is no '" + where + "': CLI " + v.toString() +
" is not in the CLI source '" + source + "' for " + fs::hostPlatform() +
"-" + fs::hostArch());
sfs::copy_file(where, destination, sfs::copy_options::overwrite_existing, ec);
if (ec)
throw Error("cannot copy '" + where + "': " + ec.message());
}
}
std::vector<Version> installed()
{
std::vector<Version> out;
std::error_code ec;
if (!sfs::is_directory(root(), ec))
return out;
for (const sfs::directory_entry& e : sfs::directory_iterator(root(), ec))
{
if (!e.is_directory())
continue;
std::optional<Version> v = parseVersion(e.path().filename().string());
if (v && sfs::exists(binaryOf(*v)))
out.push_back(*v);
}
std::sort(out.begin(), out.end());
return out;
}
std::optional<Version> picked()
{
std::optional<std::string> text = fs::tryReadText(root() / "current");
if (!text)
return std::nullopt;
return parseVersion(text::trim(*text));
}
void install(const std::string& versionText)
{
Version v = parseOrThrow(versionText);
sfs::path binary = binaryOf(v);
bool already = sfs::exists(binary);
if (!already)
{
// Nothing is left behind when it fails.
struct Cleanup
{
explicit Cleanup(sfs::path d) : dir(std::move(d)) {}
Cleanup(const Cleanup&) = delete;
Cleanup& operator=(const Cleanup&) = delete;
Cleanup(Cleanup&&) = delete;
Cleanup& operator=(Cleanup&&) = delete;
sfs::path dir;
bool armed = true;
~Cleanup()
{
if (armed)
{
std::error_code ec;
sfs::remove_all(dir, ec);
}
}
} cleanup{binary.parent_path()};
if (std::optional<Version> own = parseVersion(version()); own && *own == v)
{
// This program is the version asked for: install a copy of it.
std::error_code ec;
sfs::create_directories(binary.parent_path(), ec);
sfs::copy_file(fs::executablePath(), binary, sfs::copy_options::overwrite_existing,
ec);
if (ec)
throw Error("cannot copy this program to '" + fs::generic(binary) +
"': " + ec.message());
}
else
{
GlobalConfig cfg = GlobalConfig::load();
std::optional<std::string> source = cfg.effective(config::CliSource);
if (!source)
throw Error("CLI " + v.toString() + " is not this program (" + version() +
") and no CLI source "
"is configured. Say where releases come from with:\n"
" breeze config cli-source --set <URL or directory>");
obtain(*source, v, binary);
}
fs::makeExecutable(binary);
// It has to be the version it says it is, and run at all.
proc::Options o;
o.capture = true;
proc::Result r = proc::run({binary.string(), "cli", "version"}, o);
if (r.exitCode != 0 || text::trim(r.output) != v.toString())
{
throw Error("what was installed for " + v.toString() +
" does not run, or says it is another version "
"(it printed '" +
text::trim(r.output) + "'); nothing was installed");
}
cleanup.armed = false;
}
// The copy to put on PATH, and the first version installed is the one in use.
if (!sfs::exists(shim()))
{
std::error_code ec;
sfs::create_directories(shim().parent_path(), ec);
sfs::copy_file(binary, shim(), sfs::copy_options::overwrite_existing, ec);
}
bool first = !picked().has_value();
if (first)
fs::writeText(root() / "current", v.toString() + "\n");
std::cout << (already ? "already installed: " : "installed ") << v.toString()
<< (first ? " (picked)" : "") << "\n";
const char* pathEnv = std::getenv("PATH");
std::string binDir = fs::generic(shim().parent_path());
if (pathEnv == nullptr ||
std::string(pathEnv).find(shim().parent_path().string()) == std::string::npos)
std::cout << "to use it from anywhere, put " << binDir << " on your PATH\n";
}
void pick(const std::string& versionText)
{
Version v = parseOrThrow(versionText);
if (!sfs::exists(binaryOf(v)))
{
std::vector<std::string> names;
for (const Version& i : installed())
names.push_back(i.toString());
throw Error("CLI " + v.toString() + " is not installed (`breeze cli install " +
v.toString() + "`). " +
(names.empty() ? "Nothing is installed."
: "Installed: " + text::join(names, ", ")));
}
std::optional<Version> before = picked();
fs::writeText(root() / "current", v.toString() + "\n");
std::cout << "picked " << v.toString();
if (before && !(*before == v))
std::cout << " (was " << before->toString() << ")";
std::cout << "\n";
}
void uninstall(const std::string& versionText)
{
Version v = parseOrThrow(versionText);
sfs::path dir = root() / v.toString();
if (!sfs::exists(dir))
throw Error("CLI " + v.toString() + " is not installed");
bool wasPicked = picked() && *picked() == v;
fs::removeTree(dir);
if (wasPicked)
{
std::error_code ec;
sfs::remove(root() / "current", ec);
}
std::cout << "uninstalled " << v.toString()
<< (wasPicked ? " (it was the picked version: none is picked now)" : "") << "\n";
}
void list()
{
std::vector<Version> all = installed();
std::optional<Version> p = picked();
std::optional<Version> own = parseVersion(version());
if (all.empty())
{
std::cout << "no versions installed (this program is " << version()
<< ": `breeze cli install " << version() << "`)\n";
return;
}
for (const Version& v : all)
{
std::cout << (p && *p == v ? "* " : " ") << v.toString();
if (own && *own == v)
std::cout << " (this program)";
std::cout << "\n";
}
}
void forwardToPicked(const std::vector<std::string>& args)
{
if (std::getenv("BREEZE_NO_FORWARD") != nullptr)
return;
if (args.empty() || args.front() == "cli")
return;
std::optional<Version> p = picked();
std::optional<Version> own = parseVersion(version());
if (!p || (own && *own == *p))
return;
sfs::path binary = binaryOf(*p);
if (!fs::isExecutable(binary))
{
std::cerr << "breeze: the picked version " << p->toString()
<< " is not installed any more; using " << version()
<< " (`breeze cli install " << p->toString()
<< "` or `breeze cli pick <version>`)\n";
return;
}
proc::setEnvironment("BREEZE_NO_FORWARD", "1");
std::vector<std::string> argv{binary.string()};
argv.insert(argv.end(), args.begin(), args.end());
proc::execReplace(argv);
}
}
+83
View File
@@ -0,0 +1,83 @@
#include "breeze/Config.hpp"
#include <cctype>
#include <cstdlib>
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
namespace breeze
{
std::filesystem::path breezeHome()
{
if (const char* p = std::getenv("BREEZE_HOME"); p != nullptr && *p != '\0')
return std::filesystem::path(p);
return fs::homeDir() / ".breeze";
}
namespace
{
std::filesystem::path configFile()
{
return breezeHome() / "config.json";
}
std::string envName(const std::string& key)
{
std::string name = "BREEZE_";
for (char c : key)
{
if (std::isupper(static_cast<unsigned char>(c)) && name.back() != '_')
name += '_';
name += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
return name;
}
}
GlobalConfig GlobalConfig::load()
{
GlobalConfig c;
if (std::optional<std::string> text = fs::tryReadText(configFile()))
{
nlohmann::json parsed = nlohmann::json::parse(*text, nullptr, false);
if (parsed.is_discarded() || !parsed.is_object())
throw Error("'" + fs::generic(configFile()) +
"' is not valid JSON; fix or delete it");
c.m_data = std::move(parsed);
}
return c;
}
std::optional<std::string> GlobalConfig::stored(const std::string& key) const
{
auto it = m_data.find(key);
if (it == m_data.end() || !it->is_string())
return std::nullopt;
return it->get<std::string>();
}
std::optional<std::string> GlobalConfig::effective(const std::string& key) const
{
if (const char* p = std::getenv(envName(key).c_str()); p != nullptr && *p != '\0')
return std::string(p);
return stored(key);
}
void GlobalConfig::set(const std::string& key, const std::string& value)
{
m_data[key] = value;
}
void GlobalConfig::erase(const std::string& key)
{
m_data.erase(key);
}
void GlobalConfig::save() const
{
fs::writeText(configFile(), m_data.dump(2) + "\n");
}
}
+211
View File
@@ -0,0 +1,211 @@
#include "breeze/Fs.hpp"
#include <cstdlib>
#include <algorithm>
#include <fstream>
#include <random>
#include <sstream>
#include <system_error>
#include "breeze/Error.hpp"
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#elif defined(__APPLE__)
#include <mach-o/dyld.h>
#include <unistd.h>
#else
#include <unistd.h>
#endif
#if !defined(_WIN32)
#include <sys/stat.h>
#endif
namespace breeze::fs
{
std::optional<std::string> tryReadText(const path& file)
{
std::ifstream in(file, std::ios::binary);
if (!in)
return std::nullopt;
std::ostringstream buffer;
buffer << in.rdbuf();
return buffer.str();
}
std::string readText(const path& file)
{
std::optional<std::string> text = tryReadText(file);
if (!text)
throw Error("cannot read '" + generic(file) + "'");
return *text;
}
void writeText(const path& file, const std::string& text)
{
std::error_code ec;
if (file.has_parent_path())
std::filesystem::create_directories(file.parent_path(), ec);
std::ofstream out(file, std::ios::binary | std::ios::trunc);
if (!out)
throw Error("cannot write '" + generic(file) + "'");
out << text;
if (!out)
throw Error("cannot write '" + generic(file) + "'");
}
void copyTree(const path& from, const path& to, const std::vector<std::string>& skipNames)
{
namespace sfs = std::filesystem;
std::error_code ec;
sfs::create_directories(to, ec);
for (const sfs::directory_entry& entry : sfs::directory_iterator(from))
{
std::string name = entry.path().filename().string();
if (std::find(skipNames.begin(), skipNames.end(), name) != skipNames.end())
continue;
path target = to / name;
if (entry.is_symlink())
{
sfs::copy_symlink(entry.path(), target, ec);
if (ec)
throw Error("cannot copy '" + generic(entry.path()) + "': " + ec.message());
}
else if (entry.is_directory())
{
copyTree(entry.path(), target, skipNames);
}
else
{
sfs::copy_file(entry.path(), target, sfs::copy_options::overwrite_existing, ec);
if (ec)
throw Error("cannot copy '" + generic(entry.path()) + "': " + ec.message());
}
}
}
void removeTree(const path& p)
{
std::error_code ec;
std::filesystem::remove_all(p, ec);
if (ec)
throw Error("cannot remove '" + generic(p) + "': " + ec.message());
}
path makeTempDir(const std::string& prefix)
{
std::random_device rd;
std::mt19937_64 gen(rd());
for (int attempt = 0; attempt < 100; ++attempt)
{
std::ostringstream name;
name << prefix << "-" << std::hex << gen();
path dir = std::filesystem::temp_directory_path() / name.str();
std::error_code ec;
if (std::filesystem::create_directory(dir, ec))
return dir;
}
throw Error("cannot create a temporary directory");
}
path homeDir()
{
#if defined(_WIN32)
if (const char* p = std::getenv("USERPROFILE"))
return path(p);
#endif
if (const char* p = std::getenv("HOME"))
return path(p);
throw Error("cannot tell where your home directory is (HOME is not set)");
}
path executablePath()
{
#if defined(_WIN32)
std::wstring buffer(32768, L'\0');
DWORD n = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
buffer.resize(n);
return path(buffer);
#elif defined(__APPLE__)
std::uint32_t size = 0;
_NSGetExecutablePath(nullptr, &size);
std::string buffer(size, '\0');
if (_NSGetExecutablePath(buffer.data(), &size) != 0)
throw Error("cannot tell where this program is");
buffer.resize(std::char_traits<char>::length(buffer.c_str()));
std::error_code ec;
path canonical = std::filesystem::canonical(buffer, ec);
return ec ? path(buffer) : canonical;
#else
std::error_code ec;
path p = std::filesystem::read_symlink("/proc/self/exe", ec);
if (ec)
throw Error("cannot tell where this program is: " + ec.message());
return p;
#endif
}
std::string hostPlatform()
{
#if defined(_WIN32)
return "windows";
#elif defined(__APPLE__)
return "macos";
#else
return "linux";
#endif
}
std::string hostArch()
{
#if defined(__x86_64__) || defined(_M_X64)
return "x64";
#elif defined(__aarch64__) || defined(_M_ARM64)
return "arm64";
#elif defined(__arm__) || defined(_M_ARM)
return "arm32";
#else
return "x86";
#endif
}
void makeExecutable(const path& file)
{
#if !defined(_WIN32)
std::error_code ec;
std::filesystem::permissions(file,
std::filesystem::perms::owner_exec |
std::filesystem::perms::group_exec |
std::filesystem::perms::others_exec,
std::filesystem::perm_options::add, ec);
if (ec)
throw Error("cannot make '" + generic(file) + "' executable: " + ec.message());
#else
(void)file;
#endif
}
bool isExecutable(const path& file)
{
std::error_code ec;
if (!std::filesystem::is_regular_file(file, ec))
return false;
#if defined(_WIN32)
return true;
#else
return ::access(file.c_str(), X_OK) == 0;
#endif
}
std::string generic(const path& p)
{
return p.generic_string();
}
}
+115
View File
@@ -0,0 +1,115 @@
#include "breeze/Git.hpp"
#include <set>
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Process.hpp"
#include "breeze/Text.hpp"
namespace breeze::git
{
namespace sfs = std::filesystem;
namespace
{
void requireGit()
{
if (!proc::findInPath("git"))
throw Error("git is needed for this, and it is not installed (or not on PATH)");
}
bool isUrl(const std::string& s)
{
return text::startsWith(s, "http://") || text::startsWith(s, "https://") ||
text::startsWith(s, "ssh://") || text::startsWith(s, "git://") ||
text::startsWith(s, "file://") || text::startsWith(s, "git@");
}
// A local repository is cloned through a file:// URL, which keeps git from ignoring
// --depth.
std::string cloneable(const std::string& repository)
{
if (isUrl(repository))
return repository;
std::error_code ec;
sfs::path abs = sfs::absolute(repository, ec);
return "file://" + abs.generic_string();
}
}
bool isRepository(const std::string& source)
{
if (isUrl(source))
return true;
std::error_code ec;
sfs::path p(source);
return sfs::is_directory(p / ".git", ec) ||
(sfs::is_regular_file(p / "HEAD", ec) && sfs::is_directory(p / "objects", ec));
}
std::vector<Tag> versionTags(const std::string& repository)
{
requireGit();
proc::Options options;
options.capture = true;
proc::Result r =
proc::run({"git", "ls-remote", "--tags", "--refs", cloneable(repository)}, options);
if (r.exitCode != 0)
throw Error("cannot read the tags of '" + repository + "' (git ls-remote failed)");
std::vector<Tag> tags;
std::set<std::string> seen;
for (const std::string& line : text::splitLines(r.output))
{
std::size_t pos = line.find("refs/tags/");
if (pos == std::string::npos)
continue;
std::string name = text::trim(line.substr(pos + 10));
std::optional<Version> v = parseVersion(name);
if (!v || !seen.insert(name).second)
continue;
tags.push_back({name, *v});
}
return tags;
}
void exportTree(const std::string& repository, const std::string& tag,
const sfs::path& destination)
{
requireGit();
sfs::path tmp = fs::makeTempDir("breeze-git");
sfs::path work = tmp / "repo";
std::vector<std::string> argv{"git", "clone", "--quiet", "--depth", "1"};
if (!tag.empty())
{
argv.push_back("--branch");
argv.push_back(tag);
}
argv.push_back(cloneable(repository));
argv.push_back(work.string());
proc::Options options;
options.capture = true;
options.mergeStderr = true;
proc::Result r = proc::run(argv, options);
if (r.exitCode != 0)
{
fs::removeTree(tmp);
throw Error("cannot fetch " +
(tag.empty() ? std::string("the default branch") : "'" + tag + "'") +
" of '" + repository + "':\n" + text::trim(r.output));
}
fs::removeTree(work / ".git");
std::error_code ec;
sfs::create_directories(destination.parent_path(), ec);
sfs::rename(work, destination, ec);
if (ec)
{
// Different file systems: copy instead.
fs::copyTree(work, destination);
}
fs::removeTree(tmp);
}
}
+580
View File
@@ -0,0 +1,580 @@
#include "breeze/Modules.hpp"
#include <algorithm>
#include <array>
#include <set>
#include "breeze/CMakeText.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Text.hpp"
namespace breeze
{
namespace sfs = std::filesystem;
std::string kindName(ModuleKind kind)
{
switch (kind)
{
case ModuleKind::Exec:
return "exec";
case ModuleKind::Lib:
return "lib";
case ModuleKind::Obj:
return "obj";
case ModuleKind::Test:
return "test";
case ModuleKind::Example:
return "example";
}
return "?";
}
namespace
{
struct MacroInfo
{
const char* name;
ModuleKind kind;
LibType libType;
};
const std::array<MacroInfo, 7> Macros = {{
{"add_lib_module", ModuleKind::Lib, LibType::Unspecified},
{"add_static_lib_module", ModuleKind::Lib, LibType::Static},
{"add_shared_lib_module", ModuleKind::Lib, LibType::Shared},
{"add_objects_module", ModuleKind::Obj, LibType::Unspecified},
{"add_exe_module", ModuleKind::Exec, LibType::Unspecified},
{"add_test_module", ModuleKind::Test, LibType::Unspecified},
{"add_example_module", ModuleKind::Example, LibType::Unspecified},
}};
const MacroInfo* macroInfo(const std::string& name)
{
std::string lowered = text::lower(name);
for (const MacroInfo& m : Macros)
{
if (lowered == m.name)
return &m;
}
return nullptr;
}
bool isModuleCall(const std::string& name)
{
return macroInfo(name) != nullptr;
}
bool isKeyword(const std::string& s)
{
return s == "OUTPUT_NAME" || s == "TYPE" || s == "DEPENDS" || s == "EXTRA_SOURCES" ||
s == "NO_DOCS";
}
Module toModule(const cmake::Call& call, const sfs::path& dir, ModuleKind /*category*/)
{
const MacroInfo* info = macroInfo(call.name);
Module m;
m.macro = text::lower(call.name);
m.dir = dir;
m.dirName = dir.filename().string();
m.kind = info->kind;
m.libType = info->libType;
std::string mode;
bool haveId = false;
for (const cmake::Token& t : call.args)
{
if (!t.quoted && isKeyword(t.text))
{
mode = t.text;
continue;
}
if (!haveId)
{
m.id = t.text;
haveId = true;
}
else if (mode == "OUTPUT_NAME")
{
m.outputName = t.text;
}
else if (mode == "TYPE")
{
std::string v = text::lower(t.text);
m.libType = v == "shared" ? LibType::Shared
: v == "static" ? LibType::Static
: m.libType;
}
else if (mode == "DEPENDS")
{
m.deps.push_back(t.text);
}
}
return m;
}
void scanDirectory(const sfs::path& container, ModuleKind category,
std::vector<Module>& out, bool skipContainers)
{
std::error_code ec;
if (!sfs::is_directory(container, ec))
return;
std::vector<sfs::path> dirs;
for (const sfs::directory_entry& e : sfs::directory_iterator(container, ec))
{
if (!e.is_directory())
continue;
std::string name = e.path().filename().string();
if (skipContainers && (name == "tests" || name == "examples") &&
!sfs::exists(e.path() / "CMakeLists.txt"))
continue;
if (sfs::is_regular_file(e.path() / "CMakeLists.txt", ec))
dirs.push_back(e.path());
}
std::sort(dirs.begin(), dirs.end());
for (const sfs::path& dir : dirs)
{
std::optional<std::string> text = fs::tryReadText(dir / "CMakeLists.txt");
if (!text)
continue;
for (const cmake::Call& call : cmake::findCalls(*text, isModuleCall))
{
if (call.args.empty())
continue;
out.push_back(toModule(call, dir, category));
}
}
}
sfs::path moduleDirectory(const Project& project, ModuleKind kind, const std::string& name)
{
sfs::path source = project.root / "source";
if (kind == ModuleKind::Test)
return source / "tests" / name;
if (kind == ModuleKind::Example)
return source / "examples" / name;
return source / name;
}
// <name in snake case> plus the kind's suffix, unless the name already ends that way
// (a module called UtilTest is util_test, not util_test_test).
std::string defaultId(ModuleKind kind, const std::string& name)
{
std::string base = text::snakeCase(name);
std::string suffix = "_" + kindName(kind);
if (kind == ModuleKind::Exec)
suffix = "_exe";
return text::endsWith(base, suffix) ? base : base + suffix;
}
std::string applyTemplate(std::string t, const std::string& name, const std::string& snake)
{
t = text::replaceAll(std::move(t), "@NAME@", name);
t = text::replaceAll(std::move(t), "@SNAKE@", snake);
return t;
}
const char* const MainCpp = R"(#include <iostream>
int main()
{
std::cout << "Hello from @NAME@\n";
return 0;
}
)";
const char* const LibHeader = R"(#pragma once
#include <string>
namespace @SNAKE@
{
/// A placeholder: replace it with the module's real interface.
std::string describe();
}
)";
const char* const LibSource = R"(#include "@SNAKE@/@NAME@.hpp"
namespace @SNAKE@
{
std::string describe()
{
return "@NAME@";
}
}
)";
const char* const TestMain =
R"(// A test is an executable that exits with 0 when everything holds; ctest runs it.
int main()
{
return 0;
}
)";
}
std::vector<Module> scanModules(const Project& project)
{
std::vector<Module> modules;
sfs::path source = project.root / "source";
scanDirectory(source, ModuleKind::Exec, modules, true);
scanDirectory(source / "tests", ModuleKind::Test, modules, false);
scanDirectory(source / "examples", ModuleKind::Example, modules, false);
return modules;
}
const Module* findModule(const std::vector<Module>& modules, const std::string& idOrDir)
{
for (const Module& m : modules)
{
if (m.id == idOrDir)
return &m;
}
for (const Module& m : modules)
{
if (m.dirName == idOrDir)
return &m;
}
// A directory name typed in another case, when only one module could mean it.
const Module* found = nullptr;
for (const Module& m : modules)
{
if (text::lower(m.dirName) == text::lower(idOrDir))
{
if (found != nullptr)
return nullptr;
found = &m;
}
}
return found;
}
namespace
{
const Module& require(const std::vector<Module>& modules, const std::string& idOrDir)
{
if (const Module* m = findModule(modules, idOrDir))
return *m;
throw Error("there is no module '" + idOrDir + "' (see `breeze ls`)");
}
}
Module addModule(const Project& project, const NewModule& request)
{
if (!text::isName(request.name))
throw Error("'" + request.name +
"' is not a valid module name: it starts with a letter and has only "
"letters, digits and '_' (it becomes a directory and a C++ namespace)");
if (request.kind == ModuleKind::Obj && !project.sdk.supportsObjects)
throw Error("this project's SDK (" + project.sdk.describe() +
") has no objects modules. Move to an SDK that "
"has them with " +
(project.sdk.layout == SdkLayout::Legacy ? "`breeze sdk migrate <version>`"
: "`breeze sdk upgrade`"));
if (request.kind == ModuleKind::Lib && request.libType == LibType::Unspecified)
throw Error("a library is either --static or --shared; say which");
std::string id = request.id ? *request.id : defaultId(request.kind, request.name);
if (!text::isTargetId(id))
throw Error("'" + id +
"' is not a valid module id (letters, digits, '_', '.', '+', '-')");
std::vector<Module> existing = scanModules(project);
for (const Module& m : existing)
{
if (m.id == id)
throw Error("the id '" + id + "' is already taken by the module in source/" +
m.dirName + (m.kind == ModuleKind::Test ? " (tests)" : ""));
}
sfs::path dir = moduleDirectory(project, request.kind, request.name);
if (sfs::exists(dir))
throw Error("'" + fs::generic(sfs::relative(dir, project.root)) + "' already exists");
std::vector<std::string> deps;
for (const std::string& d : request.deps)
{
if (d == id || d == request.name)
throw Error("a module can't depend on itself ('" + d + "')");
const Module* dep = findModule(existing, d);
if (dep != nullptr)
{
if (dep->kind == ModuleKind::Exec || dep->kind == ModuleKind::Test ||
dep->kind == ModuleKind::Example)
throw Error("'" + d + "' is " +
(dep->kind == ModuleKind::Exec || dep->kind == ModuleKind::Example
? "an "
: "a ") +
kindName(dep->kind) +
" module: only libraries and objects modules can be depended on");
deps.push_back(dep->id);
}
else if (text::isTargetId(d))
{
deps.push_back(d); // a target from elsewhere: a dependency, a system library
}
else
{
throw Error("'" + d + "' is not a module of this project or a valid target name");
}
}
std::string snake = text::snakeCase(request.name);
std::string call;
switch (request.kind)
{
case ModuleKind::Exec:
call = "add_exe_module(" + id + " OUTPUT_NAME " + cmake::quote(snake);
break;
case ModuleKind::Lib:
call = "add_lib_module(" + id + " TYPE " +
(request.libType == LibType::Shared ? "SHARED" : "STATIC") +
" OUTPUT_NAME " + cmake::quote(snake);
break;
case ModuleKind::Obj:
call = "add_objects_module(" + id + " OUTPUT_NAME " + cmake::quote(snake);
break;
case ModuleKind::Test:
call = "add_test_module(" + id;
break;
case ModuleKind::Example:
call = "add_example_module(" + id;
break;
}
if (!deps.empty())
call += " DEPENDS " + text::join(deps, " ");
call += ")\n";
const char* comment = nullptr;
switch (request.kind)
{
case ModuleKind::Exec:
comment = "# An executable: every .cpp in this directory, headers in include/.\n";
break;
case ModuleKind::Lib:
comment = "# A library: sources in src/, public headers in include/.\n";
break;
case ModuleKind::Obj:
comment = "# An objects module: compiled to object files that whatever names it in "
"DEPENDS takes in.\n";
break;
case ModuleKind::Test:
comment = "# A test: an executable ctest runs (built with BUILD_TESTS=ON).\n";
break;
case ModuleKind::Example:
comment = "# An example: an executable (built with BUILD_EXAMPLES=ON).\n";
break;
}
fs::writeText(dir / "CMakeLists.txt", std::string(comment) + call);
if (request.kind == ModuleKind::Lib || request.kind == ModuleKind::Obj)
{
fs::writeText(dir / "include" / snake / (request.name + ".hpp"),
applyTemplate(LibHeader, request.name, snake));
fs::writeText(dir / "src" / (request.name + ".cpp"),
applyTemplate(LibSource, request.name, snake));
}
else if (request.kind == ModuleKind::Test)
{
fs::writeText(dir / "main.cpp", TestMain);
}
else
{
fs::writeText(dir / "main.cpp", applyTemplate(MainCpp, request.name, snake));
}
std::vector<Module> now = scanModules(project);
const Module* created = findModule(now, id);
if (created == nullptr)
throw Error("internal error: the new module '" + id +
"' was not found after writing it");
return *created;
}
std::vector<std::string> dependentsOf(const std::vector<Module>& modules, const Module& module,
bool recursive)
{
std::vector<std::string> out;
std::set<std::string> seen;
std::vector<std::string> queue{module.id};
while (!queue.empty())
{
std::string current = queue.front();
queue.erase(queue.begin());
for (const Module& m : modules)
{
if (std::find(m.deps.begin(), m.deps.end(), current) == m.deps.end())
continue;
if (seen.insert(m.id).second)
{
out.push_back(m.id);
if (recursive)
queue.push_back(m.id);
}
}
if (!recursive)
break;
}
return out;
}
std::vector<std::string> dependenciesOf(const std::vector<Module>& modules,
const Module& module, bool recursive)
{
std::vector<std::string> out;
std::set<std::string> seen{module.id};
std::vector<std::string> queue{module.id};
while (!queue.empty())
{
std::string current = queue.front();
queue.erase(queue.begin());
const Module* m = findModule(modules, current);
if (m == nullptr)
continue;
for (const std::string& d : m->deps)
{
if (seen.insert(d).second)
{
out.push_back(d);
if (recursive)
queue.push_back(d);
}
}
if (!recursive)
break;
}
return out;
}
void deleteModule(const Project& project, const std::string& idOrDir)
{
std::vector<Module> modules = scanModules(project);
const Module& m = require(modules, idOrDir);
std::vector<std::string> users = dependentsOf(modules, m, false);
if (!users.empty())
throw Error("'" + m.id + "' can't be deleted: " + text::join(users, ", ") +
(users.size() == 1 ? " depends" : " depend") + " on it");
fs::removeTree(m.dir);
}
namespace
{
struct Edit
{
cmake::Token token;
std::string value;
};
// Applies replacements from the end of the file backwards so earlier offsets stay valid.
void rewrite(const sfs::path& file, std::string text, std::vector<Edit> edits)
{
std::sort(edits.begin(), edits.end(),
[](const Edit& a, const Edit& b) { return a.token.begin > b.token.begin; });
for (const Edit& e : edits)
cmake::replaceToken(text, e.token, e.value);
fs::writeText(file, text);
}
}
RenameResult renameModule(const Project& project, const std::string& idOrDir,
const std::optional<std::string>& newName,
const std::optional<std::string>& newId)
{
if (!newName && !newId)
throw Error("nothing to rename: give --name and/or --id");
std::vector<Module> modules = scanModules(project);
const Module& m = require(modules, idOrDir);
RenameResult result;
result.oldId = m.id;
result.newId = newId ? *newId : m.id;
result.oldDir = m.dirName;
result.newDir = newName ? *newName : m.dirName;
if (newName && !text::isName(*newName))
throw Error("'" + *newName + "' is not a valid module name");
if (newId && !text::isTargetId(*newId))
throw Error("'" + *newId + "' is not a valid module id");
if (result.newId == result.oldId && result.newDir == result.oldDir)
throw Error("the module already has that name and id");
if (result.newId != result.oldId)
{
for (const Module& other : modules)
{
if (other.id == result.newId)
throw Error("the id '" + result.newId + "' is already taken");
}
}
sfs::path newDirPath = m.dir.parent_path() / result.newDir;
if (result.newDir != result.oldDir && sfs::exists(newDirPath))
throw Error("'" + fs::generic(sfs::relative(newDirPath, project.root)) +
"' already exists");
// Everything is rewritten at the files' present locations first; the directory moves last.
std::set<sfs::path> files;
for (const Module& other : modules)
files.insert(other.dir / "CMakeLists.txt");
for (const sfs::path& file : files)
{
std::string text = fs::readText(file);
std::vector<Edit> edits;
bool isOwn = file == m.dir / "CMakeLists.txt";
bool touchedDependency = false;
for (const cmake::Call& call : cmake::findCalls(text, isModuleCall))
{
if (call.args.empty())
continue;
if (isOwn && result.newId != result.oldId && call.args.front().text == result.oldId)
edits.push_back({call.args.front(), result.newId});
std::string mode;
bool first = true;
for (const cmake::Token& t : call.args)
{
if (!t.quoted && isKeyword(t.text))
{
mode = t.text;
continue;
}
if (first)
{
first = false;
continue;
}
if (mode == "DEPENDS" && result.newId != result.oldId && t.text == result.oldId)
{
edits.push_back({t, result.newId});
touchedDependency = true;
}
}
}
if (edits.empty())
continue;
rewrite(file, text, std::move(edits));
if (touchedDependency)
{
std::string dirName = file.parent_path().filename().string();
if (!isOwn)
result.updatedDependents.push_back(dirName);
}
}
if (result.newDir != result.oldDir)
{
std::error_code ec;
sfs::rename(m.dir, newDirPath, ec);
if (ec)
throw Error("cannot rename '" + fs::generic(m.dir) + "': " + ec.message());
}
return result;
}
}
+169
View File
@@ -0,0 +1,169 @@
#include "breeze/Presets.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Process.hpp"
#include "breeze/Text.hpp"
namespace breeze
{
namespace
{
Preset makePreset(const std::string& name, const std::string& description)
{
Preset p;
p.name = name;
p.description = description;
std::size_t first = name.find('-');
std::size_t second = first == std::string::npos ? first : name.find('-', first + 1);
if (second != std::string::npos)
{
p.platform = name.substr(0, first);
p.arch = name.substr(first + 1, second - first - 1);
p.compiler = name.substr(second + 1);
}
return p;
}
}
std::vector<Preset> listPresets(const Project& project)
{
std::error_code ec;
if (!std::filesystem::exists(project.root / "CMakePresets.json", ec) &&
!std::filesystem::exists(project.root / "CMakeUserPresets.json", ec))
return {}; // an SDK from before presets
proc::Options options;
options.cwd = project.root;
options.capture = true;
proc::Result r = proc::run({"cmake", "--list-presets"}, options);
if (r.exitCode != 0)
throw Error("`cmake --list-presets` failed in '" + fs::generic(project.root) +
"' -- is CMake installed, and is CMakePresets.json valid?");
std::vector<Preset> presets;
for (const std::string& raw : text::splitLines(r.output))
{
std::string line = text::trim(raw);
if (line.size() < 3 || line[0] != '"')
continue;
std::size_t close = line.find('"', 1);
if (close == std::string::npos)
continue;
std::string name = line.substr(1, close - 1);
std::string description;
if (std::size_t dash = line.find('-', close); dash != std::string::npos)
description = text::trim(line.substr(dash + 1));
presets.push_back(makePreset(name, description));
}
return presets;
}
namespace
{
bool same(const std::string& a, const std::string& b)
{
return text::normalized(a) == text::normalized(b);
}
std::string describeChoices(const std::vector<Preset>& presets)
{
std::string out;
for (const Preset& p : presets)
out += " " + p.name + "\n";
return out;
}
}
std::optional<std::string> resolvePreset(const Project& project, const Defaults& defaults,
const PresetQuery& query, std::string* note)
{
std::vector<Preset> presets = listPresets(project);
if (presets.empty())
{
if (query.preset || query.platform || query.arch || query.compiler)
throw Error("this project's SDK (" + project.sdk.describe() +
") has no CMake presets, so there is nothing to choose between. "
"Move to a newer SDK with `breeze sdk migrate <version>`");
return std::nullopt;
}
auto byName = [&](const std::string& name) -> const Preset*
{
for (const Preset& p : presets)
{
if (same(p.name, name))
return &p;
}
return nullptr;
};
if (query.preset)
{
if (const Preset* p = byName(*query.preset))
return p->name;
throw Error("'" + *query.preset +
"' is not a preset this project has for this machine. "
"Choose one of:\n" +
describeChoices(presets));
}
bool anyGiven = query.platform || query.arch || query.compiler;
std::optional<std::string> dPlatform = defaults.build("platform");
std::optional<std::string> dArch = defaults.build("arch");
std::optional<std::string> dCompiler = defaults.build("compiler");
if (!anyGiven)
{
if (std::optional<std::string> dp = defaults.build("preset"))
{
if (const Preset* p = byName(*dp))
return p->name;
throw Error(
"the project's default preset '" + *dp +
"' does not exist for this machine. "
"Choose another with `breeze defaults build preset --set <name>`, from:\n" +
describeChoices(presets));
}
if (!dPlatform && !dArch && !dCompiler)
return std::nullopt;
}
std::string platform = query.platform ? *query.platform
: dPlatform ? *dPlatform
: fs::hostPlatform();
std::string arch = query.arch ? *query.arch : dArch ? *dArch : fs::hostArch();
std::optional<std::string> compiler = query.compiler ? query.compiler : dCompiler;
std::vector<Preset> matches;
for (const Preset& p : presets)
{
if (same(p.platform, platform) && same(p.arch, arch) &&
(!compiler || same(p.compiler, *compiler)))
matches.push_back(p);
}
if (matches.empty())
{
std::vector<Preset> sameTarget;
for (const Preset& p : presets)
{
if (same(p.platform, platform) && same(p.arch, arch))
sameTarget.push_back(p);
}
throw Error("this project has no preset for " + platform + "-" + arch +
(compiler ? "-" + *compiler : std::string()) + " on this machine. " +
(sameTarget.empty() ? "Available:\n" + describeChoices(presets)
: "Presets for " + platform + "-" + arch + ":\n" +
describeChoices(sameTarget)));
}
if (matches.size() > 1 && note != nullptr)
{
*note = "no compiler given: using '" + matches.front().name + "' (the others are: ";
for (std::size_t i = 1; i < matches.size(); ++i)
*note += (i > 1 ? ", " : "") + matches[i].name;
*note += "). Pick one with -C or `breeze defaults build compiler --set <name>`.";
}
return matches.front().name;
}
}
+353
View File
@@ -0,0 +1,353 @@
#include "breeze/Process.hpp"
#include <cstdio>
#include <cstdlib>
#include <array>
#include <iostream>
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Text.hpp"
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <io.h>
#include <windows.h>
#else
#include <fcntl.h>
#include <sys/wait.h>
#include <unistd.h>
#endif
namespace breeze::proc
{
#if defined(_WIN32)
namespace
{
std::wstring widen(const std::string& s)
{
if (s.empty())
return {};
int n =
MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), nullptr, 0);
std::wstring w(static_cast<std::size_t>(n), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), w.data(), n);
return w;
}
// The Microsoft C runtime's rules for one argument: quote when it has spaces or quotes,
// double the backslashes that precede a quote.
std::wstring quoteArg(const std::string& arg)
{
std::wstring w = widen(arg);
if (!w.empty() && w.find_first_of(L" \t\n\v\"") == std::wstring::npos)
return w;
std::wstring out = L"\"";
std::size_t backslashes = 0;
for (wchar_t c : w)
{
if (c == L'\\')
{
++backslashes;
continue;
}
if (c == L'"')
out.append(backslashes * 2 + 1, L'\\');
else
out.append(backslashes, L'\\');
backslashes = 0;
out += c;
}
out.append(backslashes * 2, L'\\');
out += L'"';
return out;
}
Result runCommandLine(std::wstring commandLine, const Options& options)
{
std::vector<std::pair<std::wstring, std::wstring>> saved;
for (const auto& [key, value] : options.env)
{
std::wstring wk = widen(key);
wchar_t previous[32768];
DWORD n = GetEnvironmentVariableW(wk.c_str(), previous, 32768);
saved.emplace_back(wk,
n > 0 ? std::wstring(previous, n) : std::wstring(L"\x01unset"));
SetEnvironmentVariableW(wk.c_str(), widen(value).c_str());
}
SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE};
HANDLE readEnd = nullptr;
HANDLE writeEnd = nullptr;
if (options.capture)
{
CreatePipe(&readEnd, &writeEnd, &sa, 0);
SetHandleInformation(readEnd, HANDLE_FLAG_INHERIT, 0);
}
HANDLE nul = nullptr;
if (options.silent)
nul = CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_WRITE, &sa, OPEN_EXISTING, 0,
nullptr);
STARTUPINFOW si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
si.hStdOutput = options.capture
? writeEnd
: (options.silent ? nul : GetStdHandle(STD_OUTPUT_HANDLE));
si.hStdError = options.capture && options.mergeStderr
? writeEnd
: (options.silent ? nul : GetStdHandle(STD_ERROR_HANDLE));
PROCESS_INFORMATION pi{};
std::wstring cwd = options.cwd.empty() ? std::wstring() : options.cwd.wstring();
BOOL ok = CreateProcessW(nullptr, commandLine.data(), nullptr, nullptr, TRUE, 0,
nullptr, cwd.empty() ? nullptr : cwd.c_str(), &si, &pi);
if (writeEnd)
CloseHandle(writeEnd);
if (nul)
CloseHandle(nul);
Result result;
if (!ok)
{
if (readEnd)
CloseHandle(readEnd);
result.exitCode = 127;
}
else
{
if (readEnd)
{
std::array<char, 4096> buffer{};
DWORD got = 0;
while (ReadFile(readEnd, buffer.data(), static_cast<DWORD>(buffer.size()), &got,
nullptr) &&
got > 0)
result.output.append(buffer.data(), got);
CloseHandle(readEnd);
}
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD code = 0;
GetExitCodeProcess(pi.hProcess, &code);
result.exitCode = static_cast<int>(code);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}
for (const auto& [key, value] : saved)
SetEnvironmentVariableW(key.c_str(),
value == L"\x01unset" ? nullptr : value.c_str());
return result;
}
}
Result run(const std::vector<std::string>& argv, const Options& options)
{
if (argv.empty())
throw Error("internal error: nothing to run");
std::wstring line;
for (std::size_t i = 0; i < argv.size(); ++i)
{
if (i != 0)
line += L' ';
line += quoteArg(argv[i]);
}
return runCommandLine(std::move(line), options);
}
Result runScript(const std::filesystem::path& script, const std::vector<std::string>& args,
const Options& options)
{
// cmd.exe strips the outer quotes of what follows /c, so the whole command is wrapped once
// more.
std::wstring inner = quoteArg(script.string());
for (const std::string& a : args)
inner += L' ' + quoteArg(a);
return runCommandLine(L"cmd.exe /d /s /c \"" + inner + L"\"", options);
}
void execReplace(const std::vector<std::string>& argv)
{
Result r = run(argv);
if (r.exitCode == 127)
throw Error("cannot start '" + argv.front() + "'");
std::exit(r.exitCode);
}
std::optional<std::filesystem::path> findInPath(const std::string& program)
{
const char* pathEnv = std::getenv("PATH");
if (pathEnv == nullptr)
return std::nullopt;
static const std::vector<std::string> extensions = {"", ".exe", ".bat", ".cmd", ".com"};
for (const std::string& dir : text::split(pathEnv, ';'))
{
for (const std::string& ext : extensions)
{
std::filesystem::path candidate = std::filesystem::path(dir) / (program + ext);
if (std::filesystem::is_regular_file(candidate))
return candidate;
}
}
return std::nullopt;
}
void setEnvironment(const std::string& name, const std::string& value)
{
_putenv_s(name.c_str(), value.c_str());
}
bool stdinIsTerminal()
{
return _isatty(_fileno(stdin)) != 0;
}
bool stdoutIsTerminal()
{
return _isatty(_fileno(stdout)) != 0;
}
#else
Result run(const std::vector<std::string>& argv, const Options& options)
{
if (argv.empty())
throw Error("internal error: nothing to run");
std::cout.flush();
std::cerr.flush();
std::fflush(nullptr);
std::array<int, 2> pipeFds = {-1, -1};
if (options.capture && ::pipe(pipeFds.data()) != 0)
throw Error("cannot create a pipe");
pid_t pid = ::fork();
if (pid < 0)
throw Error("cannot create a process");
if (pid == 0)
{
if (!options.cwd.empty() && ::chdir(options.cwd.c_str()) != 0)
{
std::fprintf(stderr, "breeze: cannot enter '%s'\n", options.cwd.c_str());
::_exit(127);
}
for (const auto& [key, value] : options.env)
::setenv(key.c_str(), value.c_str(), 1);
if (options.capture)
{
::dup2(pipeFds[1], STDOUT_FILENO);
if (options.mergeStderr)
::dup2(pipeFds[1], STDERR_FILENO);
::close(pipeFds[0]);
::close(pipeFds[1]);
}
else if (options.silent)
{
int nul = ::open("/dev/null", O_WRONLY);
::dup2(nul, STDOUT_FILENO);
::dup2(nul, STDERR_FILENO);
::close(nul);
}
std::vector<char*> args;
for (const std::string& a : argv)
args.push_back(const_cast<char*>(a.c_str()));
args.push_back(nullptr);
::execvp(args[0], args.data());
std::fprintf(stderr, "breeze: cannot run '%s'\n", args[0]);
::_exit(127);
}
Result result;
if (options.capture)
{
::close(pipeFds[1]);
std::array<char, 4096> buffer{};
ssize_t got = 0;
while ((got = ::read(pipeFds[0], buffer.data(), buffer.size())) > 0)
result.output.append(buffer.data(), static_cast<std::size_t>(got));
::close(pipeFds[0]);
}
int status = 0;
while (::waitpid(pid, &status, 0) < 0)
{
if (errno != EINTR)
throw Error("cannot wait for '" + argv.front() + "'");
}
if (WIFEXITED(status))
result.exitCode = WEXITSTATUS(status);
else if (WIFSIGNALED(status))
result.exitCode = 128 + WTERMSIG(status);
return result;
}
Result runScript(const std::filesystem::path& script, const std::vector<std::string>& args,
const Options& options)
{
std::vector<std::string> argv;
// A project unpacked from a zip often has scripts that lost their executable bit; the SDK's
// scripts are bash, so that is how they are run then.
if (!fs::isExecutable(script))
argv.push_back("bash");
argv.push_back(script.string());
argv.insert(argv.end(), args.begin(), args.end());
return run(argv, options);
}
void execReplace(const std::vector<std::string>& argv)
{
if (argv.empty())
throw Error("internal error: nothing to run");
std::cout.flush();
std::cerr.flush();
std::vector<char*> args;
for (const std::string& a : argv)
args.push_back(const_cast<char*>(a.c_str()));
args.push_back(nullptr);
::execv(args[0], args.data());
throw Error("cannot start '" + argv.front() + "'");
}
std::optional<std::filesystem::path> findInPath(const std::string& program)
{
if (program.find('/') != std::string::npos)
return fs::isExecutable(program) ? std::optional<std::filesystem::path>(program)
: std::nullopt;
const char* pathEnv = std::getenv("PATH");
if (pathEnv == nullptr)
return std::nullopt;
for (const std::string& dir : text::split(pathEnv, ':'))
{
std::filesystem::path candidate =
(dir.empty() ? std::filesystem::path(".") : std::filesystem::path(dir)) / program;
if (fs::isExecutable(candidate))
return candidate;
}
return std::nullopt;
}
void setEnvironment(const std::string& name, const std::string& value)
{
::setenv(name.c_str(), value.c_str(), 1);
}
bool stdinIsTerminal()
{
return ::isatty(STDIN_FILENO) != 0;
}
bool stdoutIsTerminal()
{
return ::isatty(STDOUT_FILENO) != 0;
}
#endif
}
+338
View File
@@ -0,0 +1,338 @@
#include "breeze/Project.hpp"
#include <algorithm>
#include "breeze/CMakeText.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Text.hpp"
namespace breeze
{
namespace sfs = std::filesystem;
std::string Sdk::describe() const
{
if (layout == SdkLayout::Legacy)
return "legacy (flat layout, unversioned)";
if (version)
return version->toString();
return "unversioned (breeze/ layout)";
}
sfs::path Project::metaDir() const
{
return root / ".breeze";
}
sfs::path Project::defaultsFile() const
{
return metaDir() / "defaults.json";
}
sfs::path Project::toolsDir() const
{
return metaDir() / "tools";
}
sfs::path Project::script(const std::string& name) const
{
return sdk.scriptsDir / (name + (fs::hostPlatform() == "windows" ? ".bat" : ".sh"));
}
std::optional<Sdk> detectSdk(const sfs::path& root)
{
std::error_code ec;
if (!sfs::is_regular_file(root / "CMakeLists.txt", ec))
return std::nullopt;
Sdk sdk;
if (sfs::is_regular_file(root / "breeze" / "cmake" / "Breeze.cmake", ec) &&
sfs::is_regular_file(root / "project" / "Configuration.cmake", ec))
{
sdk.layout = SdkLayout::Breeze;
sdk.dir = root / "breeze";
sdk.scriptsDir = sdk.dir / "scripts";
sdk.cmakeDir = sdk.dir / "cmake";
sdk.settingsFile = root / "project" / "Configuration.cmake";
if (std::optional<std::string> v = fs::tryReadText(sdk.dir / "VERSION"))
sdk.version = parseVersion(*v);
}
else if (sfs::is_regular_file(root / "Configuration.cmake", ec) &&
sfs::is_regular_file(root / "cmake" / "BuildHelpers.cmake", ec))
{
sdk.layout = SdkLayout::Legacy;
sdk.dir = root;
sdk.scriptsDir = root / "scripts";
sdk.cmakeDir = root / "cmake";
sdk.settingsFile = root / "Configuration.cmake";
}
else
{
return std::nullopt;
}
if (std::optional<std::string> helpers =
fs::tryReadText(sdk.cmakeDir / "BuildHelpers.cmake"))
sdk.supportsObjects = helpers->find("macro(add_objects_module") != std::string::npos;
return sdk;
}
std::optional<Project> findProject(const sfs::path& start)
{
std::error_code ec;
sfs::path dir = sfs::weakly_canonical(start, ec);
if (ec)
dir = start;
while (true)
{
if (std::optional<Sdk> sdk = detectSdk(dir))
return Project{dir, *sdk};
if (!dir.has_parent_path() || dir.parent_path() == dir)
return std::nullopt;
dir = dir.parent_path();
}
}
Project requireProject(const sfs::path& start)
{
if (std::optional<Project> p = findProject(start))
return *p;
throw Error(
"this is not inside a breeze project (no CMakeLists.txt with a breeze SDK found in '" +
fs::generic(start) + "' or above). Create one with: breeze project new <name>");
}
namespace
{
const char* variableOf(Setting which)
{
switch (which)
{
case Setting::Name:
return "PROJECT_TITLE";
case Setting::Version:
return "PROJECT_VERSION_NUMBER";
case Setting::Id:
return "PROJECT_CODE_NAME";
}
return "";
}
bool isCmakeVersion(const std::string& v)
{
std::vector<std::string> parts = text::split(v, '.');
if (parts.empty() || parts.size() > 4)
return false;
return std::all_of(parts.begin(), parts.end(),
[](const std::string& p)
{
return !p.empty() &&
std::all_of(p.begin(), p.end(),
[](char c) { return c >= '0' && c <= '9'; });
});
}
void validate(Setting which, const std::string& value)
{
switch (which)
{
case Setting::Name:
if (!text::isName(value, true))
throw Error(
"'" + value +
"' is not a valid project name: it starts with a letter and has "
"only letters, digits, '_' and '-'");
break;
case Setting::Version:
if (!isCmakeVersion(value))
throw Error("'" + value +
"' is not a valid project version: use numbers separated by "
"dots, like 1.2.0 (CMake's project(VERSION))");
break;
case Setting::Id:
if (!text::isName(value) || std::any_of(value.begin(), value.end(), [](char c)
{ return c >= 'A' && c <= 'Z'; }))
throw Error(
"'" + value +
"' is not a valid project id: lower-case letters, digits and '_', "
"starting with a letter");
break;
}
}
// Where a setting is set: the settings file, except in older legacy projects that kept the
// id in CMakeLists.txt.
sfs::path settingFile(const Project& project, Setting which)
{
std::optional<std::string> text = fs::tryReadText(project.sdk.settingsFile);
if (text && cmake::findSet(*text, variableOf(which)))
return project.sdk.settingsFile;
if (project.sdk.layout == SdkLayout::Legacy)
{
std::optional<std::string> cmakeLists =
fs::tryReadText(project.root / "CMakeLists.txt");
if (cmakeLists && cmake::findSet(*cmakeLists, variableOf(which)))
return project.root / "CMakeLists.txt";
}
return project.sdk.settingsFile;
}
// The name and the version of a legacy project live in the project() call of
// CMakeLists.txt.
struct LegacyProjectCall
{
cmake::Call call;
std::string text;
};
LegacyProjectCall legacyProjectCall(const Project& p)
{
std::string text = fs::readText(p.root / "CMakeLists.txt");
std::vector<cmake::Call> calls = cmake::findCalls(
text, [](const std::string& n) { return text::lower(n) == "project"; });
if (calls.empty() || calls.front().args.empty())
throw Error("CMakeLists.txt has no project() call to read the project's name and "
"version from");
return {calls.front(), std::move(text)};
}
std::size_t versionArgIndex(const cmake::Call& call)
{
for (std::size_t i = 0; i + 1 < call.args.size(); ++i)
{
if (call.args[i].text == "VERSION")
return i + 1;
}
return call.args.size();
}
}
std::string readSetting(const Project& project, Setting which)
{
if (project.sdk.layout == SdkLayout::Legacy && which != Setting::Id)
{
LegacyProjectCall lp = legacyProjectCall(project);
if (which == Setting::Name)
return lp.call.args[0].text;
std::size_t idx = versionArgIndex(lp.call);
if (idx >= lp.call.args.size())
throw Error("the project() call in CMakeLists.txt has no VERSION");
return lp.call.args[idx].text;
}
sfs::path file = settingFile(project, which);
std::string text = fs::readText(file);
std::optional<cmake::Call> set = cmake::findSet(text, variableOf(which));
if (!set || set->args.size() < 2)
throw Error(std::string("'") + fs::generic(file) + "' does not set " +
variableOf(which));
return set->args[1].text;
}
void writeSetting(const Project& project, Setting which, const std::string& value)
{
validate(which, value);
if (project.sdk.layout == SdkLayout::Legacy && which != Setting::Id)
{
LegacyProjectCall lp = legacyProjectCall(project);
const cmake::Token* token = nullptr;
if (which == Setting::Name)
{
token = &lp.call.args[0];
}
else
{
std::size_t idx = versionArgIndex(lp.call);
if (idx >= lp.call.args.size())
throw Error("the project() call in CMakeLists.txt has no VERSION to change");
token = &lp.call.args[idx];
}
std::string text = lp.text;
cmake::replaceToken(text, *token, value);
fs::writeText(project.root / "CMakeLists.txt", text);
return;
}
sfs::path file = settingFile(project, which);
std::string text = fs::readText(file);
std::optional<cmake::Call> set = cmake::findSet(text, variableOf(which));
if (!set || set->args.size() < 2)
throw Error(std::string("'") + fs::generic(file) + "' does not set " +
variableOf(which));
cmake::replaceToken(text, set->args[1], cmake::quote(value));
fs::writeText(file, text);
}
Defaults Defaults::load(const Project& project)
{
Defaults d;
if (std::optional<std::string> text = fs::tryReadText(project.defaultsFile()))
{
nlohmann::json parsed = nlohmann::json::parse(*text, nullptr, false);
if (parsed.is_discarded() || !parsed.is_object())
throw Error("'" + fs::generic(project.defaultsFile()) +
"' is not valid JSON; fix or delete it");
d.m_data = std::move(parsed);
}
return d;
}
std::optional<std::string> Defaults::build(const std::string& key) const
{
auto b = m_data.find("build");
if (b == m_data.end() || !b->is_object())
return std::nullopt;
auto it = b->find(key);
if (it == b->end() || !it->is_string())
return std::nullopt;
return it->get<std::string>();
}
void Defaults::setBuild(const std::string& key, const std::string& value)
{
m_data["build"][key] = value;
}
void Defaults::clearBuild(const std::string& key)
{
if (m_data.contains("build"))
{
m_data["build"].erase(key);
if (m_data["build"].empty())
m_data.erase("build");
}
}
std::optional<std::string> Defaults::run() const
{
auto it = m_data.find("run");
if (it == m_data.end() || !it->is_string())
return std::nullopt;
return it->get<std::string>();
}
void Defaults::setRun(const std::string& id)
{
m_data["run"] = id;
}
void Defaults::clearRun()
{
m_data.erase("run");
}
void Defaults::save(const Project& project) const
{
if (m_data.empty())
{
std::error_code ec;
sfs::remove(project.defaultsFile(), ec);
return;
}
fs::writeText(project.defaultsFile(), m_data.dump(2) + "\n");
}
}
+616
View File
@@ -0,0 +1,616 @@
#include "breeze/Sdks.hpp"
#include <cstdint>
#include <algorithm>
#include <array>
#include <iostream>
#include <sstream>
#include "breeze/CMakeText.hpp"
#include "breeze/Config.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Git.hpp"
#include "breeze/Modules.hpp"
#include "breeze/Process.hpp"
#include "breeze/Text.hpp"
namespace breeze::sdk
{
namespace sfs = std::filesystem;
namespace
{
// What a snapshot never hands to a project.
const std::vector<std::string> Skip = {".git", "out", ".cache", "compile_commands.json"};
// Root files the tools look for there, so they stay at the root and belong to the project.
const std::array<const char*, 4> DotFiles = {".clang-format", ".clang-tidy",
".editorconfig", ".gitattributes"};
std::string hashOf(const std::string& s)
{
std::uint64_t h = 1469598103934665603ULL; // FNV-1a
for (unsigned char c : s)
{
h ^= c;
h *= 1099511628211ULL;
}
std::ostringstream out;
out << std::hex << h;
return out.str();
}
bool isBreezeSnapshot(const sfs::path& snap)
{
std::error_code ec;
return sfs::is_regular_file(snap / "breeze" / "cmake" / "Breeze.cmake", ec) &&
sfs::is_regular_file(snap / "project" / "Configuration.cmake", ec) &&
sfs::is_regular_file(snap / "CMakeLists.txt", ec);
}
const Source::Entry* findEntry(const std::vector<Source::Entry>& all, const Version& v)
{
for (const Source::Entry& e : all)
{
if (e.version == v)
return &e;
}
return nullptr;
}
// The newest release (a pre-release only when there is no release), optionally of one major
// version.
const Source::Entry* newest(const std::vector<Source::Entry>& all, std::optional<int> major)
{
const Source::Entry* best = nullptr;
for (int pass = 0; pass < 2 && best == nullptr; ++pass)
{
for (const Source::Entry& e : all)
{
if (major && e.version.major != *major)
continue;
if (pass == 0 && !e.version.pre.empty())
continue;
if (best == nullptr || best->version < e.version)
best = &e;
}
}
return best;
}
std::string list(const std::vector<Source::Entry>& all)
{
std::vector<std::string> names;
for (const Source::Entry& e : all)
names.push_back(e.version.toString());
return text::join(names, ", ");
}
Source::Entry requireSnapshot(const Source& source, const Source::Entry& entry,
sfs::path& snapshot)
{
snapshot = source.snapshot(entry);
if (!isBreezeSnapshot(snapshot))
throw Error("SDK " + entry.version.toString() + " (" + source.location() +
") has no breeze/ folder with project/Configuration.cmake: it is not a "
"template this "
"CLI can install");
return entry;
}
// Replaces the first value of set(<var> ...) in `text` by `raw` (already in CMake syntax).
bool setRaw(std::string& text, const std::string& var, const std::string& raw)
{
std::optional<cmake::Call> set = cmake::findSet(text, var);
if (!set || set->args.size() < 2)
return false;
cmake::replaceToken(text, set->args[1], raw);
return true;
}
// Settings the snapshot defines and the project's file does not yet: appended, as written.
std::vector<std::string> mergeMissingSettings(const sfs::path& projectFile,
const sfs::path& snapshotFile)
{
std::string project = fs::readText(projectFile);
std::string snapshot = fs::readText(snapshotFile);
std::vector<std::string> added;
for (const cmake::Call& call : cmake::findCalls(snapshot, [](const std::string& n)
{ return text::lower(n) == "set"; }))
{
if (call.args.empty())
continue;
const std::string& var = call.args[0].text;
if (cmake::findSet(project, var))
continue;
if (!project.empty() && project.back() != '\n')
project += '\n';
project += "\n# Added by `breeze sdk`: new in this SDK version.\n" +
snapshot.substr(call.begin, call.end - call.begin) + "\n";
added.push_back(var);
}
if (!added.empty())
fs::writeText(projectFile, project);
return added;
}
std::vector<std::string> differingDotFiles(const sfs::path& root, const sfs::path& snapshot)
{
std::vector<std::string> differing;
for (const char* name : DotFiles)
{
std::optional<std::string> mine = fs::tryReadText(root / name);
std::optional<std::string> theirs = fs::tryReadText(snapshot / name);
if (mine && theirs && *mine != *theirs)
differing.push_back(name);
}
return differing;
}
void moveAside(const sfs::path& from, const sfs::path& to)
{
std::error_code ec;
sfs::create_directories(to.parent_path(), ec);
fs::removeTree(to);
sfs::rename(from, to, ec);
if (ec)
{
if (sfs::is_directory(from))
fs::copyTree(from, to);
else
sfs::copy_file(from, to, sfs::copy_options::overwrite_existing, ec);
fs::removeTree(from);
}
}
void keepBackupsOutOfGit(const Project& project)
{
sfs::path ignore = project.metaDir() / ".gitignore";
if (!sfs::exists(ignore))
fs::writeText(ignore, "backup/\n");
}
std::string fromLabel(const Project& project)
{
return project.sdk.version ? project.sdk.version->toString() : "unversioned";
}
// ----------------------------------------------------------- moving a breeze/ project
// between versions
void applyBreeze(const Project& project, const sfs::path& snapshot,
const Source::Entry& entry)
{
sfs::path backup = project.metaDir() / "backup" / ("breeze-" + fromLabel(project));
moveAside(project.root / "breeze", backup);
keepBackupsOutOfGit(project);
fs::copyTree(snapshot / "breeze", project.root / "breeze");
std::vector<std::string> added = mergeMissingSettings(
project.sdk.settingsFile, snapshot / "project" / "Configuration.cmake");
std::vector<std::string> dotFiles = differingDotFiles(project.root, snapshot);
std::cout << "SDK " << fromLabel(project) << " -> " << entry.version.toString() << "\n";
std::cout << " breeze/ replaced (the old one is in "
<< fs::generic(sfs::relative(backup, project.root)) << ")\n";
for (const std::string& a : added)
std::cout << " added the setting " << a << " to project/Configuration.cmake\n";
for (const std::string& d : dotFiles)
std::cout << " " << d
<< " differs from the new SDK's; compare them if you want its changes\n";
std::cout
<< " delete out/build/ if the next build complains about a stale configuration\n";
}
// ----------------------------------------------------------- moving a legacy (flat)
// project to breeze/
struct OldProject
{
std::string name;
std::string version;
std::string description;
std::vector<std::string> languages;
std::string cxxStandard;
};
OldProject readOldProject(const std::string& cmakeLists)
{
std::vector<cmake::Call> calls = cmake::findCalls(
cmakeLists, [](const std::string& n) { return text::lower(n) == "project"; });
if (calls.empty() || calls.front().args.empty())
throw Error("the project's CMakeLists.txt has no project() call to read the name "
"and version from");
OldProject old;
old.name = calls.front().args.front().text;
std::string mode;
for (std::size_t i = 1; i < calls.front().args.size(); ++i)
{
const cmake::Token& t = calls.front().args[i];
if (!t.quoted && (t.text == "VERSION" || t.text == "DESCRIPTION" ||
t.text == "LANGUAGES" || t.text == "HOMEPAGE_URL"))
{
mode = t.text;
continue;
}
if (mode == "VERSION")
old.version = t.text;
else if (mode == "DESCRIPTION")
old.description = t.text;
else if (mode == "LANGUAGES")
old.languages.push_back(t.text);
}
if (std::optional<cmake::Call> std = cmake::findSet(cmakeLists, "CMAKE_CXX_STANDARD");
std && std->args.size() > 1)
old.cxxStandard = std->args[1].text;
return old;
}
void migrateLegacy(const Project& project, const sfs::path& snapshot,
const Source::Entry& entry)
{
const sfs::path& root = project.root;
std::string oldCmake = fs::readText(root / "CMakeLists.txt");
std::string oldConfig = fs::readText(root / "Configuration.cmake");
OldProject old = readOldProject(oldCmake);
// The new settings: the snapshot's file with what this project said put in.
std::string settings = fs::readText(snapshot / "project" / "Configuration.cmake");
setRaw(settings, "PROJECT_TITLE", cmake::quote(old.name));
if (!old.version.empty())
setRaw(settings, "PROJECT_VERSION_NUMBER", cmake::quote(old.version));
if (!old.description.empty())
setRaw(settings, "PROJECT_SUMMARY", cmake::quote(old.description));
if (!old.languages.empty())
setRaw(settings, "PROJECT_LANGUAGES", text::join(old.languages, " "));
if (!old.cxxStandard.empty())
setRaw(settings, "PROJECT_CXX_STANDARD", old.cxxStandard);
// Every other setting the project had changed, by name: collected first, applied after,
// because each edit moves the offsets of what follows.
std::vector<std::pair<std::string, std::string>> changes;
for (const cmake::Call& call : cmake::findCalls(settings, [](const std::string& n)
{ return text::lower(n) == "set"; }))
{
if (call.args.size() < 2)
continue;
const std::string var = call.args[0].text;
if (var.rfind("PROJECT_", 0) == 0 && var != "PROJECT_CODE_NAME")
continue;
for (const std::string* source : {&oldConfig, &oldCmake})
{
std::optional<cmake::Call> was = cmake::findSet(*source, var);
if (!was || was->args.size() < 2)
continue;
std::string raw =
source->substr(was->args[1].begin, was->args[1].end - was->args[1].begin);
std::string now =
settings.substr(call.args[1].begin, call.args[1].end - call.args[1].begin);
if (raw != now)
changes.emplace_back(var, raw);
break;
}
}
std::vector<std::string> carried;
for (const auto& [var, raw] : changes)
{
setRaw(settings, var, raw);
carried.push_back(var);
}
// What the project had of the template's goes aside, nothing is thrown away.
sfs::path backup =
project.metaDir() / "backup" / ("legacy-to-" + entry.version.toString());
fs::removeTree(backup);
// Only what the template owned: the project's README.md and any other documents stay.
for (const char* name : {"CMakeLists.txt", "CMakePresets.json", "Configuration.cmake",
"cmake", "scripts", ".githooks", "StyleGuide.md"})
{
sfs::path p = root / name;
if (sfs::exists(p))
moveAside(p, backup / name);
}
keepBackupsOutOfGit(project);
fs::copyTree(snapshot / "breeze", root / "breeze");
sfs::copy_file(snapshot / "CMakeLists.txt", root / "CMakeLists.txt",
sfs::copy_options::overwrite_existing);
sfs::copy_file(snapshot / "CMakePresets.json", root / "CMakePresets.json",
sfs::copy_options::overwrite_existing);
fs::writeText(root / "project" / "Configuration.cmake", settings);
for (const char* name : DotFiles)
{
if (!sfs::exists(root / name) && sfs::exists(snapshot / name))
sfs::copy_file(snapshot / name, root / name);
}
std::cout << "legacy layout -> SDK " << entry.version.toString()
<< " (breeze/ layout)\n";
std::cout << " breeze/ and project/Configuration.cmake created; the new root "
"CMakeLists.txt and "
"CMakePresets.json are the SDK's\n";
std::cout << " the old CMakeLists.txt, Configuration.cmake, cmake/, scripts/ and "
".githooks/ are in "
<< fs::generic(sfs::relative(backup, root)) << "\n";
std::cout << " carried over: project name '" << old.name << "'"
<< (old.version.empty() ? "" : ", version " + old.version)
<< (carried.empty() ? "" : ", " + text::join(carried, ", ")) << "\n";
// The git hook directory moved.
if (sfs::exists(root / ".git"))
{
proc::Options o;
o.cwd = root;
o.capture = true;
proc::Result r = proc::run({"git", "config", "core.hooksPath"}, o);
if (r.exitCode == 0 && text::trim(r.output) == ".githooks")
{
proc::Options q;
q.cwd = root;
q.silent = true;
proc::run({"git", "config", "core.hooksPath", "breeze/githooks"}, q);
std::cout << " git hooks now come from breeze/githooks\n";
}
}
// Build directories made by the old scripts point at files that are gone.
if (sfs::exists(root / "out" / "build"))
{
fs::removeTree(root / "out" / "build");
std::cout << " removed out/build/ (it was configured by the old layout)\n";
}
std::cout << "next: breeze setup, then breeze build\n";
}
}
// ----------------------------------------------------------------------- Source
Source Source::configured()
{
GlobalConfig cfg = GlobalConfig::load();
std::optional<std::string> location = cfg.effective(config::SdkSource);
if (!location)
throw Error("no SDK source is configured. Say where SDK versions come from with:\n"
" breeze config sdk-source --set <git repository (URL or path) | "
"directory of versions>");
return Source(*location);
}
Source::Source(std::string location) : m_location(std::move(location))
{
if (git::isRepository(m_location))
m_isRepository = true;
else if (!sfs::is_directory(m_location))
throw Error("the SDK source '" + m_location +
"' is neither a git repository nor a directory");
}
std::vector<Source::Entry> Source::versions() const
{
std::vector<Entry> out;
if (m_isRepository)
{
for (const git::Tag& t : git::versionTags(m_location))
out.push_back({t.version, t.name});
}
else
{
for (const sfs::directory_entry& e : sfs::directory_iterator(m_location))
{
if (!e.is_directory())
continue;
if (std::optional<Version> v = parseVersion(e.path().filename().string()))
out.push_back({*v, e.path().filename().string()});
}
}
std::sort(out.begin(), out.end(),
[](const Entry& a, const Entry& b) { return a.version < b.version; });
return out;
}
sfs::path Source::snapshot(const Entry& entry) const
{
if (!m_isRepository)
return sfs::path(m_location) / entry.ref;
sfs::path cache = breezeHome() / "sdk" / hashOf(m_location) / entry.version.toString();
if (sfs::exists(cache / "breeze"))
return cache;
std::cout << "fetching SDK " << entry.version.toString() << " from " << m_location << "\n";
fs::removeTree(cache);
git::exportTree(m_location, entry.ref, cache);
return cache;
}
Source::Entry Source::resolve(const std::optional<std::string>& wanted) const
{
std::vector<Entry> all = versions();
if (all.empty())
throw Error("the SDK source '" + m_location + "' has no versions (tags like v1.0.0)");
if (!wanted || text::lower(*wanted) == "latest")
return *newest(all, std::nullopt);
std::optional<Version> v = parseVersion(*wanted);
if (!v)
throw Error("'" + *wanted + "' is not a version (use 1.2.3, or latest)");
if (const Entry* e = findEntry(all, *v))
return *e;
throw Error("there is no SDK " + v->toString() + " in '" + m_location +
"'. Available: " + list(all));
}
// ----------------------------------------------------------------------- commands
namespace
{
std::string moduleNameFor(const std::string& projectName)
{
std::string out;
bool upper = true;
for (char c : projectName)
{
if (c == '-' || c == '_')
{
upper = true;
continue;
}
out += upper ? static_cast<char>(std::toupper(static_cast<unsigned char>(c))) : c;
upper = false;
}
return out;
}
std::string idFor(const std::string& projectName)
{
return text::replaceAll(text::lower(projectName), "-", "_");
}
}
void createProject(const std::string& name, const std::optional<std::string>& id,
const std::optional<std::string>& sdkVersion)
{
if (!text::isName(name, true))
throw Error(
"'" + name +
"' is not a valid project name: it starts with a letter and has only letters, "
"digits, '_' and '-'");
std::string projectId = id ? *id : idFor(name);
sfs::path dest = sfs::current_path() / name;
std::error_code ec;
if (sfs::exists(dest, ec) && !sfs::is_empty(dest, ec))
throw Error("'" + name + "' already exists and is not empty");
Source source = Source::configured();
Source::Entry entry = source.resolve(sdkVersion);
sfs::path snapshot;
requireSnapshot(source, entry, snapshot);
fs::copyTree(snapshot, dest, Skip);
// A new project starts without the template's demonstration code.
for (const char* dir : {"source", "vendor"})
{
fs::removeTree(dest / dir);
}
fs::writeText(dest / "source" / ".gitkeep", "");
std::optional<Sdk> detected = detectSdk(dest);
if (!detected)
throw Error("internal error: the project made from SDK " + entry.version.toString() +
" is not recognised as one");
Project project{dest, *detected};
writeSetting(project, Setting::Name, name);
writeSetting(project, Setting::Id, projectId);
writeSetting(project, Setting::Version, "0.1.0");
{
std::string settings = fs::readText(project.sdk.settingsFile);
setRaw(settings, "PROJECT_SUMMARY", cmake::quote(name));
fs::writeText(project.sdk.settingsFile, settings);
}
fs::writeText(
dest / "README.md",
"# " + name +
"\n\nThe project's own README: say what it is here. Doxygen uses this file as "
"the documentation's main page.\n\nBuilt with the breeze SDK "
"(breeze/):\n\n```sh\nbreeze setup "
"# once, to check the tools\nbreeze build\nbreeze run\n```\n");
NewModule first;
first.kind = ModuleKind::Exec;
first.name = moduleNameFor(name);
addModule(project, first);
fs::removeTree(dest / "source" / ".gitkeep");
std::cout << "created " << name << " with SDK " << entry.version.toString() << "\n";
std::cout << " an executable module '" << first.name << "' to start from\n";
std::cout << "next:\n cd " << name << "\n breeze setup\n breeze build\n breeze run\n";
}
void update(const Project& project)
{
Source source = Source::configured();
std::vector<Source::Entry> all = source.versions();
std::cout << "this project's SDK: " << project.sdk.describe() << "\n";
std::cout << "source: " << source.location() << "\n";
if (all.empty())
{
std::cout << "the source has no versions\n";
return;
}
std::cout << "available: " << list(all) << "\n";
const Source::Entry* latest = newest(all, std::nullopt);
if (project.sdk.layout == SdkLayout::Legacy)
{
std::cout << "this project has the legacy layout: `breeze sdk migrate "
<< latest->version.toString() << "` moves it to the breeze/ layout\n";
return;
}
if (!project.sdk.version)
{
std::cout << "this SDK has no version file: `breeze sdk upgrade` moves to "
<< latest->version.toString() << "\n";
return;
}
const Version& current = *project.sdk.version;
const Source::Entry* compatible = newest(all, current.major);
bool behind = compatible != nullptr && current < compatible->version;
if (behind)
std::cout << "update available: " << compatible->version.toString()
<< " (`breeze sdk upgrade`)\n";
if (latest->version.major > current.major)
std::cout << "a newer major version exists: " << latest->version.toString()
<< " (`breeze sdk migrate " << latest->version.toString() << "`)\n";
if (!behind && latest->version.major <= current.major)
std::cout << "up to date\n";
}
void upgrade(const Project& project)
{
if (project.sdk.layout == SdkLayout::Legacy)
throw Error("this project has the legacy layout, which has no upgrades: `breeze sdk "
"migrate <version>` "
"moves it to the breeze/ layout (see `breeze sdk update`)");
Source source = Source::configured();
std::vector<Source::Entry> all = source.versions();
if (all.empty())
throw Error("the SDK source '" + source.location() + "' has no versions");
const Source::Entry* target = project.sdk.version ? newest(all, project.sdk.version->major)
: newest(all, std::nullopt);
if (project.sdk.version && (target == nullptr || !(*project.sdk.version < target->version)))
{
std::cout << "SDK " << project.sdk.describe()
<< " is the newest of its major version\n";
const Source::Entry* latest = newest(all, std::nullopt);
if (latest->version.major > project.sdk.version->major)
std::cout << "a newer major version exists: `breeze sdk migrate "
<< latest->version.toString() << "`\n";
return;
}
sfs::path snapshot;
requireSnapshot(source, *target, snapshot);
applyBreeze(project, snapshot, *target);
}
void migrate(const Project& project, const std::string& version)
{
Source source = Source::configured();
Source::Entry entry = source.resolve(version);
sfs::path snapshot;
requireSnapshot(source, entry, snapshot);
if (project.sdk.layout == SdkLayout::Legacy)
{
migrateLegacy(project, snapshot, entry);
return;
}
if (project.sdk.version && *project.sdk.version == entry.version)
{
std::cout << "this project is already on SDK " << entry.version.toString() << "\n";
return;
}
if (project.sdk.version && entry.version < *project.sdk.version)
std::cout << "note: this is an older SDK than the project's (" << fromLabel(project)
<< ")\n";
applyBreeze(project, snapshot, entry);
}
}
+168
View File
@@ -0,0 +1,168 @@
#include "breeze/Text.hpp"
#include <cctype>
#include <algorithm>
namespace breeze::text
{
std::string trim(std::string_view s)
{
std::size_t b = 0;
std::size_t e = s.size();
while (b < e && std::isspace(static_cast<unsigned char>(s[b])))
++b;
while (e > b && std::isspace(static_cast<unsigned char>(s[e - 1])))
--e;
return std::string(s.substr(b, e - b));
}
std::vector<std::string> split(std::string_view s, char delimiter)
{
std::vector<std::string> out;
std::size_t start = 0;
while (true)
{
std::size_t pos = s.find(delimiter, start);
if (pos == std::string_view::npos)
{
out.emplace_back(s.substr(start));
return out;
}
out.emplace_back(s.substr(start, pos - start));
start = pos + 1;
}
}
std::vector<std::string> splitLines(std::string_view s)
{
std::vector<std::string> lines = split(s, '\n');
for (std::string& line : lines)
{
if (!line.empty() && line.back() == '\r')
line.pop_back();
}
if (!lines.empty() && lines.back().empty())
lines.pop_back();
return lines;
}
std::string join(const std::vector<std::string>& parts, std::string_view separator)
{
std::string out;
for (std::size_t i = 0; i < parts.size(); ++i)
{
if (i != 0)
out += separator;
out += parts[i];
}
return out;
}
std::string lower(std::string_view s)
{
std::string out(s);
std::transform(out.begin(), out.end(), out.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return out;
}
bool startsWith(std::string_view s, std::string_view prefix)
{
return s.size() >= prefix.size() && s.substr(0, prefix.size()) == prefix;
}
bool endsWith(std::string_view s, std::string_view suffix)
{
return s.size() >= suffix.size() && s.substr(s.size() - suffix.size()) == suffix;
}
std::string replaceAll(std::string s, std::string_view from, std::string_view to)
{
if (from.empty())
return s;
std::size_t pos = 0;
while ((pos = s.find(from, pos)) != std::string::npos)
{
s.replace(pos, from.size(), to);
pos += to.size();
}
return s;
}
std::string snakeCase(std::string_view s)
{
std::string out;
for (std::size_t i = 0; i < s.size(); ++i)
{
unsigned char c = static_cast<unsigned char>(s[i]);
if (c == '-' || c == ' ' || c == '.')
c = '_';
if (std::isupper(c))
{
bool prevLowerOrDigit =
i > 0 && (std::islower(static_cast<unsigned char>(s[i - 1])) ||
std::isdigit(static_cast<unsigned char>(s[i - 1])));
bool acronymEnds = i > 0 && std::isupper(static_cast<unsigned char>(s[i - 1])) &&
i + 1 < s.size() &&
std::islower(static_cast<unsigned char>(s[i + 1]));
if ((prevLowerOrDigit || acronymEnds) && !out.empty() && out.back() != '_')
out += '_';
out += static_cast<char>(std::tolower(c));
}
else
{
if (c == '_' && (out.empty() || out.back() == '_'))
continue;
out += static_cast<char>(c);
}
}
while (!out.empty() && out.back() == '_')
out.pop_back();
return out;
}
std::string normalized(std::string_view s)
{
std::string out;
for (char c : s)
{
unsigned char u = static_cast<unsigned char>(c);
if (std::isalnum(u))
out += static_cast<char>(std::tolower(u));
}
return out;
}
bool isName(std::string_view s, bool allowDash)
{
if (s.empty() || !std::isalpha(static_cast<unsigned char>(s[0])))
return false;
return std::all_of(s.begin(), s.end(),
[allowDash](char c)
{
unsigned char u = static_cast<unsigned char>(c);
return std::isalnum(u) || c == '_' || (allowDash && c == '-');
});
}
bool isTargetId(std::string_view s)
{
if (s.empty() || !(std::isalpha(static_cast<unsigned char>(s[0])) || s[0] == '_'))
return false;
return std::all_of(s.begin(), s.end(),
[](char c)
{
unsigned char u = static_cast<unsigned char>(c);
return std::isalnum(u) || c == '_' || c == '.' || c == '+' ||
c == '-' || c == ':';
});
}
std::string orNone(std::string_view s)
{
return s.empty() ? std::string("<none>") : std::string(s);
}
}
+422
View File
@@ -0,0 +1,422 @@
#include "breeze/Tools.hpp"
#include <algorithm>
#include <array>
#include <iostream>
#include <nlohmann/json.hpp>
#include "breeze/Config.hpp"
#include "breeze/Error.hpp"
#include "breeze/Fs.hpp"
#include "breeze/Git.hpp"
#include "breeze/Process.hpp"
#include "breeze/Text.hpp"
#include "breeze/Version.hpp"
namespace breeze::tools
{
namespace sfs = std::filesystem;
using nlohmann::json;
bool isReservedName(const std::string& word)
{
static const std::array<const char*, 6> Reserved = {"install", "uninstall", "update",
"upgrade", "migrate", "ls"};
return std::any_of(Reserved.begin(), Reserved.end(),
[&](const char* r) { return word == r; });
}
namespace
{
sfs::path registryFile(const Project& project)
{
return project.metaDir() / "tools.json";
}
json loadRegistry(const Project& project)
{
json registry = json::object();
if (std::optional<std::string> text = fs::tryReadText(registryFile(project)))
{
registry = json::parse(*text, nullptr, false);
if (registry.is_discarded() || !registry.is_object())
throw Error("'" + fs::generic(registryFile(project)) +
"' is not valid JSON; fix or delete it");
}
if (!registry.contains("tools") || !registry["tools"].is_object())
registry["tools"] = json::object();
return registry;
}
void saveRegistry(const Project& project, const json& registry)
{
if (registry["tools"].empty())
{
std::error_code ec;
sfs::remove(registryFile(project), ec);
return;
}
fs::writeText(registryFile(project), registry.dump(2) + "\n");
}
struct Manifest
{
std::string id;
std::string version;
std::string description;
std::string entry; // relative to the tool's directory, for this OS
};
Manifest readManifest(const sfs::path& dir)
{
std::optional<std::string> text = fs::tryReadText(dir / "breeze-tool.json");
if (!text)
throw Error("this is not a breeze tool: it has no breeze-tool.json at its root");
json j = json::parse(*text, nullptr, false);
if (j.is_discarded() || !j.is_object())
throw Error("breeze-tool.json is not valid JSON");
Manifest m;
m.id = j.value("id", "");
m.version = j.value("version", "");
m.description = j.value("description", "");
if (!text::isName(m.id, true))
throw Error("breeze-tool.json needs an \"id\" (letters, digits, '_', '-'; starting "
"with a letter)");
if (isReservedName(m.id))
throw Error("'" + m.id + "' can't be a tool id: `breeze tool " + m.id +
"` is a command of the CLI");
if (j.contains("entry") && j["entry"].is_string())
{
m.entry = j["entry"].get<std::string>();
}
else if (j.contains("entry") && j["entry"].is_object())
{
m.entry =
j["entry"].value(fs::hostPlatform() == "windows" ? "windows" : "posix", "");
}
if (m.entry.empty())
throw Error("breeze-tool.json needs an \"entry\" for this OS (a path, or { "
"\"posix\": ..., \"windows\": ... })");
if (!sfs::is_regular_file(dir / m.entry))
throw Error("the tool's entry '" + m.entry + "' is not in the repository");
return m;
}
struct Fetched
{
sfs::path dir; // a temporary directory holding the tool's files
Manifest manifest;
std::string version;
};
enum class Pick
{
Newest,
SameMajor,
Exact
};
// Gets the tool from `repo`: the version wanted, if the repository has version tags; else
// its files as they are.
Fetched fetch(const std::string& repo, Pick pick, const std::optional<Version>& basis)
{
sfs::path tmp = fs::makeTempDir("breeze-tool");
sfs::path dir = tmp / "tool";
std::string version;
try
{
if (git::isRepository(repo))
{
std::vector<git::Tag> tags = git::versionTags(repo);
const git::Tag* chosen = nullptr;
for (int pass = 0; pass < 2 && chosen == nullptr; ++pass)
{
for (const git::Tag& t : tags)
{
if (pick == Pick::Exact && basis && !(t.version == *basis))
continue;
if (pick == Pick::SameMajor && basis && t.version.major != basis->major)
continue;
if (pass == 0 && pick != Pick::Exact && !t.version.pre.empty())
continue;
if (chosen == nullptr || chosen->version < t.version)
chosen = &t;
}
}
if (pick == Pick::Exact && chosen == nullptr)
{
std::vector<std::string> names;
for (const git::Tag& t : tags)
names.push_back(t.version.toString());
std::sort(names.begin(), names.end());
throw Error(
"there is no version " + basis->toString() + " of the tool in '" +
repo + "'. Available: " +
(names.empty() ? std::string("none") : text::join(names, ", ")));
}
git::exportTree(repo, chosen ? chosen->name : "", dir);
if (chosen)
version = chosen->version.toString();
}
else if (sfs::is_directory(repo))
{
fs::copyTree(repo, dir, {".git"});
}
else
{
throw Error("'" + repo + "' is neither a git repository nor a directory");
}
Manifest manifest = readManifest(dir);
if (version.empty())
version = manifest.version.empty() ? "0.0.0" : manifest.version;
return {dir, manifest, version};
}
catch (...)
{
fs::removeTree(tmp);
throw;
}
}
void discard(const Fetched& f)
{
fs::removeTree(f.dir.parent_path());
}
// Puts fetched files in the project and records them.
void place(const Project& project, const Fetched& f, const std::string& repo)
{
sfs::path target = project.toolsDir() / f.manifest.id;
fs::removeTree(target);
std::error_code ec;
sfs::create_directories(target.parent_path(), ec);
fs::copyTree(f.dir, target);
fs::makeExecutable(target / f.manifest.entry);
json registry = loadRegistry(project);
registry["tools"][f.manifest.id] = {{"repo", repo}, {"version", f.version}};
saveRegistry(project, registry);
}
Tool toolFrom(const Project& project, const std::string& id, const json& entry)
{
Tool t;
t.id = id;
t.repo = entry.value("repo", "");
t.version = entry.value("version", "");
t.dir = project.toolsDir() / id;
if (std::optional<std::string> text = fs::tryReadText(t.dir / "breeze-tool.json"))
{
json j = json::parse(*text, nullptr, false);
if (j.is_object())
t.description = j.value("description", "");
}
return t;
}
Tool requireTool(const Project& project, const std::string& id)
{
json registry = loadRegistry(project);
if (!registry["tools"].contains(id))
throw Error("the tool '" + id +
"' is not installed in this project (see `breeze tool ls`)");
return toolFrom(project, id, registry["tools"][id]);
}
Version versionOf(const Tool& t)
{
std::optional<Version> v = parseVersion(t.version);
return v ? *v : Version{};
}
}
std::vector<Tool> installed(const Project& project)
{
std::vector<Tool> out;
json registry = loadRegistry(project);
for (auto it = registry["tools"].begin(); it != registry["tools"].end(); ++it)
out.push_back(toolFrom(project, it.key(), it.value()));
return out;
}
void install(const Project& project, const std::string& repo)
{
Fetched f = fetch(repo, Pick::Newest, std::nullopt);
try
{
json registry = loadRegistry(project);
if (registry["tools"].contains(f.manifest.id))
throw Error("the tool '" + f.manifest.id +
"' is already installed (`breeze tool upgrade " + f.manifest.id + "`)");
place(project, f, repo);
}
catch (...)
{
discard(f);
throw;
}
discard(f);
std::cout << "installed " << f.manifest.id << " " << f.version << "\n";
if (!f.manifest.description.empty())
std::cout << " " << f.manifest.description << "\n";
std::cout << "run it with: breeze tool " << f.manifest.id << " ...\n";
}
void uninstall(const Project& project, const std::string& id)
{
Tool t = requireTool(project, id);
fs::removeTree(t.dir);
json registry = loadRegistry(project);
registry["tools"].erase(id);
saveRegistry(project, registry);
std::error_code ec;
sfs::remove(project.toolsDir(),
ec); // only when empty; so is .breeze/ then, if nothing else is in it
sfs::remove(project.metaDir(), ec);
std::cout << "uninstalled " << id << "\n";
}
void update(const Project& project, const std::string& id)
{
Tool t = requireTool(project, id);
if (!git::isRepository(t.repo))
{
std::cout << id << " " << t.version
<< " comes from a directory, which has no versions to look for\n";
return;
}
Version current = versionOf(t);
std::vector<git::Tag> tags = git::versionTags(t.repo);
const git::Tag* sameMajor = nullptr;
const git::Tag* newest = nullptr;
for (const git::Tag& tag : tags)
{
if (!tag.version.pre.empty())
continue;
if (newest == nullptr || newest->version < tag.version)
newest = &tag;
if (tag.version.major == current.major &&
(sameMajor == nullptr || sameMajor->version < tag.version))
sameMajor = &tag;
}
std::cout << id << " " << t.version << " (" << t.repo << ")\n";
bool behind = sameMajor != nullptr && current < sameMajor->version;
if (behind)
std::cout << "update available: " << sameMajor->version.toString()
<< " (`breeze tool upgrade " << id << "`)\n";
if (newest != nullptr && newest->version.major > current.major)
std::cout << "a newer major version exists: " << newest->version.toString()
<< " (`breeze tool migrate " << id << " " << newest->version.toString()
<< "`)\n";
if (!behind && (newest == nullptr || newest->version.major <= current.major))
std::cout << "up to date\n";
}
void upgrade(const Project& project, const std::string& id)
{
Tool t = requireTool(project, id);
Version current = versionOf(t);
Fetched f = fetch(t.repo, Pick::SameMajor, current);
if (!(current < *parseVersion(f.version)) && git::isRepository(t.repo))
{
discard(f);
std::cout << id << " " << t.version << " is the newest of its major version\n";
return;
}
try
{
if (f.manifest.id != id)
throw Error("the repository now says its tool is '" + f.manifest.id + "', not '" +
id + "'");
place(project, f, t.repo);
}
catch (...)
{
discard(f);
throw;
}
discard(f);
std::cout << id << " " << t.version << " -> " << f.version << "\n";
}
void migrate(const Project& project, const std::string& id, const std::string& version)
{
Tool t = requireTool(project, id);
std::optional<Version> wanted = parseVersion(version);
if (!wanted)
throw Error("'" + version + "' is not a version (use 1.2.3)");
if (!git::isRepository(t.repo))
throw Error("'" + id + "' comes from a directory, which has only one version");
Fetched f = fetch(t.repo, Pick::Exact, wanted);
try
{
if (f.manifest.id != id)
throw Error("that version says its tool is '" + f.manifest.id + "', not '" + id +
"'");
place(project, f, t.repo);
}
catch (...)
{
discard(f);
throw;
}
discard(f);
std::cout << id << " " << t.version << " -> " << f.version << "\n";
}
void list(const Project& project)
{
std::vector<Tool> all = installed(project);
if (all.empty())
{
std::cout << "no tools installed (`breeze tool install <repository>`)\n";
return;
}
std::size_t idWidth = 2;
std::size_t versionWidth = 7;
for (const Tool& t : all)
{
idWidth = std::max(idWidth, t.id.size());
versionWidth = std::max(versionWidth, t.version.size());
}
for (const Tool& t : all)
{
std::cout << t.id << std::string(idWidth - t.id.size() + 2, ' ') << t.version
<< std::string(versionWidth - t.version.size() + 2, ' ') << t.repo << "\n";
if (!t.description.empty())
std::cout << " " << t.description << "\n";
}
}
std::optional<int> run(const Project& project, const std::string& id,
const std::vector<std::string>& args)
{
json registry = loadRegistry(project);
if (!registry["tools"].contains(id))
return std::nullopt;
Tool t = toolFrom(project, id, registry["tools"][id]);
Manifest manifest = readManifest(t.dir);
sfs::path entry = t.dir / manifest.entry;
proc::Options options;
options.cwd = project.root;
GlobalConfig cfg = GlobalConfig::load();
options.env = {
{"BREEZE_PROJECT_ROOT", project.root.string()},
{"BREEZE_SDK_DIR", project.sdk.dir.string()},
{"BREEZE_SDK_VERSION", project.sdk.version ? project.sdk.version->toString() : ""},
{"BREEZE_TOOL_ID", id},
{"BREEZE_TOOL_DIR", t.dir.string()},
{"BREEZE_CLI", fs::executablePath().string()},
{"BREEZE_HOME", breezeHome().string()},
{"BREEZE_EDITOR", cfg.effective(config::Editor).value_or("")}};
std::string ext = text::lower(entry.extension().string());
if (fs::hostPlatform() == "windows" && (ext == ".bat" || ext == ".cmd"))
return proc::runScript(entry, args, options).exitCode;
std::vector<std::string> argv{entry.string()};
argv.insert(argv.end(), args.begin(), args.end());
return proc::run(argv, options).exitCode;
}
}
+100
View File
@@ -0,0 +1,100 @@
#include "breeze/Version.hpp"
#include <algorithm>
#include <array>
#include <charconv>
#include "breeze/Text.hpp"
namespace breeze
{
std::string Version::toString() const
{
std::string s =
std::to_string(major) + "." + std::to_string(minor) + "." + std::to_string(patch);
if (!pre.empty())
s += "-" + pre;
return s;
}
namespace
{
bool parseNumber(std::string_view s, int& out)
{
if (s.empty())
return false;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out);
return ec == std::errc() && ptr == s.data() + s.size() && out >= 0;
}
}
std::optional<Version> parseVersion(std::string_view text)
{
std::string t = text::trim(text);
if (!t.empty() && (t[0] == 'v' || t[0] == 'V'))
t.erase(0, 1);
if (std::size_t plus = t.find('+'); plus != std::string::npos)
t.erase(plus);
Version v;
if (std::size_t dash = t.find('-'); dash != std::string::npos)
{
v.pre = t.substr(dash + 1);
t.erase(dash);
if (v.pre.empty())
return std::nullopt;
}
std::vector<std::string> parts = text::split(t, '.');
if (parts.empty() || parts.size() > 3)
return std::nullopt;
std::array<int*, 3> fields = {&v.major, &v.minor, &v.patch};
for (std::size_t i = 0; i < parts.size(); ++i)
{
if (!parseNumber(parts[i], *fields[i]))
return std::nullopt;
}
return v;
}
int compare(const Version& a, const Version& b)
{
if (a.major != b.major)
return a.major < b.major ? -1 : 1;
if (a.minor != b.minor)
return a.minor < b.minor ? -1 : 1;
if (a.patch != b.patch)
return a.patch < b.patch ? -1 : 1;
if (a.pre.empty() != b.pre.empty())
return a.pre.empty() ? 1 : -1; // a release is newer than its own pre-release
if (a.pre != b.pre)
return a.pre < b.pre ? -1 : 1;
return 0;
}
bool operator<(const Version& a, const Version& b)
{
return compare(a, b) < 0;
}
bool operator==(const Version& a, const Version& b)
{
return compare(a, b) == 0;
}
std::optional<Version> greatest(const std::vector<Version>& versions,
std::optional<int> sameMajorAs)
{
std::optional<Version> best;
for (const Version& v : versions)
{
if (sameMajorAs && v.major != *sameMajorAs)
continue;
if (!best || *best < v)
best = v;
}
return best;
}
}