Add project files
This commit is contained in:
commit
f2fe6b8855
114 files changed
+20525
No files matched your search
@@ -0,0 +1,20 @@
|
||||
# Everything the breeze CLI does that is not reading a command line: finding
|
||||
# and reading projects and their SDK, running the SDK's scripts, editing modules,
|
||||
# managing CLI versions, SDKs and tools. source/Breeze registers the commands
|
||||
# (with CLIForge) and calls into this; source/tests/CoreTests tests it.
|
||||
#
|
||||
# nlohmann/json: an installed copy (3.11 or newer) if there is one, else the
|
||||
# release tarball, pinned by hash.
|
||||
add_dependency(nlohmann_json
|
||||
URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz
|
||||
URL_HASH SHA256=d6c65aca6b1ed68e7a182f4757257b107ae403032760ed6ef121c9d55e81757d
|
||||
FIND_PACKAGE_ARGS 3.11 NAMES nlohmann_json)
|
||||
|
||||
add_lib_module(breeze_core TYPE STATIC OUTPUT_NAME "breeze_core")
|
||||
|
||||
# PUBLIC: Config.hpp and Project.hpp have nlohmann::json members, so whoever includes them needs
|
||||
# its headers (DEPENDS would link it PRIVATE).
|
||||
target_link_libraries(breeze_core PUBLIC nlohmann_json::nlohmann_json)
|
||||
|
||||
# The version of the program, from the project's own (project/Configuration.cmake).
|
||||
target_compile_definitions(breeze_core PRIVATE BREEZE_CLI_VERSION="${PROJECT_VERSION}")
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace breeze::cli
|
||||
{
|
||||
|
||||
/// CLIForge reads every token that starts with '-' as an option, so `--cargs -DBUILD_TESTS=ON`
|
||||
/// would try to find an option called -D. The values of the pass-through options (--cargs,
|
||||
/// --bargs and run's --args/-x) are rewritten to the `--cargs=-DBUILD_TESTS=ON` form CLIForge
|
||||
/// does accept, each taking tokens up to the next option of the command.
|
||||
std::vector<std::string> normalizeArguments(const std::vector<std::string>& args);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "breeze/Presets.hpp"
|
||||
#include "breeze/Project.hpp"
|
||||
|
||||
/// Building, installing, running and cleaning a project -- by driving the SDK's own scripts, so the
|
||||
/// environment they set up (the pinned Ninja, the MSVC environment on Windows) is always the one
|
||||
/// used. Where a request needs something the scripts don't offer (extra `cmake --build` arguments,
|
||||
/// skipping the configure), the CLI runs `cmake --build` itself against the build the scripts made.
|
||||
namespace breeze
|
||||
{
|
||||
|
||||
struct BuildRequest
|
||||
{
|
||||
PresetQuery preset;
|
||||
/// Release, Debug, ReleaseDebugSymbols or MinSizeRel (any case).
|
||||
std::optional<std::string> mode;
|
||||
std::optional<int> jobs;
|
||||
std::vector<std::string> configureArgs;
|
||||
std::vector<std::string> buildArgs;
|
||||
bool noConfigure = false;
|
||||
bool noBuild = false;
|
||||
};
|
||||
|
||||
/// The CMake build type for a mode name; throws Error for an unknown one.
|
||||
std::string buildTypeFor(const std::string& mode);
|
||||
|
||||
/// Values of the project's settings (BUILD_OUTPUT_DIR, DOCS_OUTPUT_DIR, ...) as the SDK
|
||||
/// computes them. Falls back to the SDK's defaults for a legacy layout that can't be asked.
|
||||
std::map<std::string, std::string> readConfigVariables(const Project& project,
|
||||
const std::vector<std::string>& names);
|
||||
|
||||
/// The build directory for the preset (or the one built last), as the SDK defines it.
|
||||
std::filesystem::path buildDirFor(const Project& project,
|
||||
const std::optional<std::string>& preset);
|
||||
|
||||
/// Each returns the exit code of the command that did the work.
|
||||
int build(const Project& project, const BuildRequest& request);
|
||||
int install(const Project& project, const BuildRequest& request);
|
||||
int docs(const Project& project);
|
||||
int run(const Project& project, const std::optional<std::string>& id, const PresetQuery& where,
|
||||
const std::vector<std::string>& programArgs);
|
||||
int setup(const Project& project, bool required, bool all);
|
||||
|
||||
/// Removes the build directories (unless keepBuild) and the documentation (if alsoDocs).
|
||||
/// Returns the paths removed, relative to the project.
|
||||
std::vector<std::string> clean(const Project& project, bool keepBuild, bool alsoDocs);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
/// Just enough of CMake's syntax to find and edit a command call in a CMakeLists.txt or a settings
|
||||
/// file without disturbing anything else in it: comments, layout and line endings stay as they
|
||||
/// were.
|
||||
namespace breeze::cmake
|
||||
{
|
||||
|
||||
/// One argument of a call. begin/end are byte offsets of the argument as written (quotes
|
||||
/// included); text is its value without the quotes.
|
||||
struct Token
|
||||
{
|
||||
std::size_t begin = 0;
|
||||
std::size_t end = 0;
|
||||
std::string text;
|
||||
bool quoted = false;
|
||||
};
|
||||
|
||||
/// A command call: name(args...). begin/end span the whole call, "name(" through ")".
|
||||
struct Call
|
||||
{
|
||||
std::string name;
|
||||
std::size_t begin = 0;
|
||||
std::size_t end = 0;
|
||||
std::vector<Token> args;
|
||||
};
|
||||
|
||||
/// Every call in `text`, in order, whose name `wanted` accepts. Comments, quoted strings and
|
||||
/// the arguments of other calls are never mistaken for calls.
|
||||
std::vector<Call> findCalls(const std::string& text,
|
||||
const std::function<bool(const std::string&)>& wanted);
|
||||
|
||||
/// The first set(<variable> ...) call.
|
||||
std::optional<Call> findSet(const std::string& text, const std::string& variable);
|
||||
|
||||
/// `value` as a quoted CMake argument.
|
||||
std::string quote(const std::string& value);
|
||||
|
||||
/// Replaces the token's text in `text` (offsets of later tokens are stale afterwards).
|
||||
void replaceToken(std::string& text, const Token& token, const std::string& replacement);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "breeze/Version.hpp"
|
||||
|
||||
/// The CLI managing itself: which versions of it are installed, which one is picked, and running
|
||||
/// the picked one. Versions live in <breeze home>/cli/<version>/; `cli/current` names the picked
|
||||
/// one, and <breeze home>/bin/breeze is the copy to put on PATH. Whatever `breeze` the shell finds
|
||||
/// hands the command to the picked version, so installing or picking never means changing PATH
|
||||
/// again.
|
||||
namespace breeze::self
|
||||
{
|
||||
|
||||
/// The version of this program.
|
||||
std::string version();
|
||||
|
||||
/// Installed versions, oldest first.
|
||||
std::vector<Version> installed();
|
||||
|
||||
/// The version `cli pick` chose, if any.
|
||||
std::optional<Version> picked();
|
||||
|
||||
/// Installs a version: this very program if that is its version, else a release of the
|
||||
/// configured CLI source. The first version installed is picked.
|
||||
void install(const std::string& version);
|
||||
|
||||
void pick(const std::string& version);
|
||||
|
||||
/// Removes a version; un-picks it if it was the picked one.
|
||||
void uninstall(const std::string& version);
|
||||
|
||||
/// Prints the installed versions, marking the picked one and the running one.
|
||||
void list();
|
||||
|
||||
/// If another version is picked, installed and runnable, runs it with `args` and does not
|
||||
/// return. `cli ...` commands always run in the version that was started.
|
||||
void forwardToPicked(const std::vector<std::string>& args);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
/// The CLI's own, global state: where it lives and what the user configured.
|
||||
namespace breeze
|
||||
{
|
||||
|
||||
/// $BREEZE_HOME, or ~/.breeze. Holds config.json, the installed CLI versions (cli/), the SDK
|
||||
/// snapshots (sdk/) and cached indexes.
|
||||
std::filesystem::path breezeHome();
|
||||
|
||||
/// The keys of config.json. Each can also come from the environment (BREEZE_EDITOR,
|
||||
/// BREEZE_SDK_SOURCE, BREEZE_CLI_SOURCE), which wins.
|
||||
namespace config
|
||||
{
|
||||
inline constexpr const char* Editor = "editor";
|
||||
inline constexpr const char* SdkSource = "sdkSource";
|
||||
inline constexpr const char* CliSource = "cliSource";
|
||||
}
|
||||
|
||||
class GlobalConfig
|
||||
{
|
||||
public:
|
||||
/// Reads config.json (an absent file is an empty configuration).
|
||||
static GlobalConfig load();
|
||||
|
||||
/// The environment's value for the key if set, else the file's.
|
||||
[[nodiscard]] std::optional<std::string> effective(const std::string& key) const;
|
||||
|
||||
/// The file's value only.
|
||||
[[nodiscard]] std::optional<std::string> stored(const std::string& key) const;
|
||||
|
||||
void set(const std::string& key, const std::string& value);
|
||||
void erase(const std::string& key);
|
||||
void save() const;
|
||||
|
||||
private:
|
||||
nlohmann::json m_data = nlohmann::json::object();
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace breeze
|
||||
{
|
||||
|
||||
/// Every failure the CLI reports to the user: a message and the process exit code to end with.
|
||||
class Error : public std::runtime_error
|
||||
{
|
||||
public:
|
||||
explicit Error(const std::string& message, int exitCode = 1)
|
||||
: std::runtime_error(message), m_exitCode(exitCode)
|
||||
{
|
||||
}
|
||||
|
||||
[[nodiscard]] int exitCode() const noexcept { return m_exitCode; }
|
||||
|
||||
private:
|
||||
int m_exitCode;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
/// File system and host helpers. Paths are std::filesystem::path throughout; text files are read
|
||||
/// and written as bytes, so the line endings a file has are the ones it keeps.
|
||||
namespace breeze::fs
|
||||
{
|
||||
|
||||
using std::filesystem::path;
|
||||
|
||||
/// Throws Error when the file can't be read.
|
||||
std::string readText(const path& file);
|
||||
std::optional<std::string> tryReadText(const path& file);
|
||||
|
||||
/// Creates the parent directories, then writes the file (replacing it).
|
||||
void writeText(const path& file, const std::string& text);
|
||||
|
||||
/// Copies `from` into `to` (merging into what is there, overwriting files). Entries whose name
|
||||
/// is in `skipNames` are left out at every level.
|
||||
void copyTree(const path& from, const path& to, const std::vector<std::string>& skipNames = {});
|
||||
|
||||
/// Removes the file or directory tree if it exists. Throws Error on failure.
|
||||
void removeTree(const path& p);
|
||||
|
||||
/// A new, empty directory under the system's temp directory.
|
||||
path makeTempDir(const std::string& prefix);
|
||||
|
||||
/// The user's home directory (HOME, or USERPROFILE on Windows).
|
||||
path homeDir();
|
||||
|
||||
/// Where the running program's executable is.
|
||||
path executablePath();
|
||||
|
||||
/// "linux", "macos" or "windows".
|
||||
std::string hostPlatform();
|
||||
|
||||
/// "x64", "arm64", "arm32" or "x86".
|
||||
std::string hostArch();
|
||||
|
||||
/// chmod +x on POSIX; nothing on Windows.
|
||||
void makeExecutable(const path& file);
|
||||
|
||||
/// True for a regular file that can be run.
|
||||
bool isExecutable(const path& file);
|
||||
|
||||
/// The path with forward slashes, for messages that read the same on every OS.
|
||||
std::string generic(const path& p);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "breeze/Version.hpp"
|
||||
|
||||
/// The little of git the CLI needs: listing the version tags of a repository and exporting one
|
||||
/// tag's files. Repositories are URLs or local paths; git itself must be installed.
|
||||
namespace breeze::git
|
||||
{
|
||||
|
||||
struct Tag
|
||||
{
|
||||
/// The tag as written in the repository, e.g. "v1.2.0".
|
||||
std::string name;
|
||||
Version version;
|
||||
};
|
||||
|
||||
/// Is `source` something git can clone (a URL, or a local repository) rather than a plain
|
||||
/// directory?
|
||||
bool isRepository(const std::string& source);
|
||||
|
||||
/// The tags of the repository that are versions, in no particular order. Throws Error when the
|
||||
/// repository can't be reached.
|
||||
std::vector<Tag> versionTags(const std::string& repository);
|
||||
|
||||
/// Exports the files of `tag` (the default branch's tip when empty) into `destination`, without
|
||||
/// the repository's own history.
|
||||
void exportTree(const std::string& repository, const std::string& tag,
|
||||
const std::filesystem::path& destination);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "breeze/Project.hpp"
|
||||
|
||||
/// A project's modules: the directories under source/ (and source/tests/, source/examples/) that
|
||||
/// each register one CMake target with one of the SDK's add_*_module macros. They are read from the
|
||||
/// CMakeLists.txt files themselves, so what the CLI shows is what CMake will do, and the files it
|
||||
/// writes are the ones a person would have written.
|
||||
namespace breeze
|
||||
{
|
||||
|
||||
enum class ModuleKind
|
||||
{
|
||||
Exec,
|
||||
Lib,
|
||||
Obj,
|
||||
Test,
|
||||
Example
|
||||
};
|
||||
|
||||
enum class LibType
|
||||
{
|
||||
Unspecified,
|
||||
Static,
|
||||
Shared
|
||||
};
|
||||
|
||||
/// "exec", "lib", "obj", "test" or "example".
|
||||
std::string kindName(ModuleKind kind);
|
||||
|
||||
struct Module
|
||||
{
|
||||
/// The CMake target: what DEPENDS, `breeze run` and `breeze deps` call it.
|
||||
std::string id;
|
||||
/// The directory's name.
|
||||
std::string dirName;
|
||||
std::filesystem::path dir;
|
||||
ModuleKind kind = ModuleKind::Exec;
|
||||
LibType libType = LibType::Unspecified;
|
||||
std::string outputName;
|
||||
std::vector<std::string> deps;
|
||||
/// The macro that registered it, e.g. add_lib_module.
|
||||
std::string macro;
|
||||
};
|
||||
|
||||
/// Every module, main ones first (by directory name), then tests, then examples.
|
||||
std::vector<Module> scanModules(const Project& project);
|
||||
|
||||
/// By id, else by directory name. Null when there is no such module.
|
||||
const Module* findModule(const std::vector<Module>& modules, const std::string& idOrDir);
|
||||
|
||||
struct NewModule
|
||||
{
|
||||
ModuleKind kind = ModuleKind::Exec;
|
||||
std::string name;
|
||||
std::optional<std::string> id;
|
||||
std::vector<std::string> deps;
|
||||
LibType libType = LibType::Unspecified;
|
||||
};
|
||||
|
||||
/// Creates the module's directory, CMakeLists.txt and starter files. Throws Error on anything
|
||||
/// it would have to guess about or overwrite.
|
||||
Module addModule(const Project& project, const NewModule& request);
|
||||
|
||||
/// Removes the module's directory. Refuses while another module still depends on it.
|
||||
void deleteModule(const Project& project, const std::string& idOrDir);
|
||||
|
||||
struct RenameResult
|
||||
{
|
||||
std::string oldId;
|
||||
std::string newId;
|
||||
std::string oldDir;
|
||||
std::string newDir;
|
||||
/// Modules whose CMakeLists.txt had a DEPENDS entry updated.
|
||||
std::vector<std::string> updatedDependents;
|
||||
};
|
||||
|
||||
/// Renames the directory and/or the id, and rewrites DEPENDS entries elsewhere to the new id.
|
||||
RenameResult renameModule(const Project& project, const std::string& idOrDir,
|
||||
const std::optional<std::string>& newName,
|
||||
const std::optional<std::string>& newId);
|
||||
|
||||
/// What `module` depends on, without repeats, in the order met (nearest first). Entries that
|
||||
/// are not modules of the project (fmt::fmt, pthread) are included as they were written.
|
||||
std::vector<std::string> dependenciesOf(const std::vector<Module>& modules,
|
||||
const Module& module, bool recursive);
|
||||
|
||||
/// The modules that depend on `module`, directly or (recursive) through others.
|
||||
std::vector<std::string> dependentsOf(const std::vector<Module>& modules, const Module& module,
|
||||
bool recursive);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "breeze/Project.hpp"
|
||||
|
||||
/// The project's CMake presets, which are what a platform, an architecture and a compiler mean:
|
||||
/// every SDK names its configure presets "<platform>-<arch>-<compiler>" (linux-x64-gcc,
|
||||
/// windows-x64-clang-cl).
|
||||
namespace breeze
|
||||
{
|
||||
|
||||
struct Preset
|
||||
{
|
||||
std::string name;
|
||||
std::string description;
|
||||
std::string platform; // linux, macos, windows
|
||||
std::string arch; // x64, x86, arm32, arm64
|
||||
std::string compiler; // the rest of the name: gcc, clang, msvc, clang-cl, mingw64, ...
|
||||
};
|
||||
|
||||
/// The configure presets usable on this machine (what `cmake --list-presets` shows).
|
||||
std::vector<Preset> listPresets(const Project& project);
|
||||
|
||||
/// What was asked for; every field is optional and compared ignoring case and punctuation
|
||||
/// ("ClangCl" finds clang-cl).
|
||||
struct PresetQuery
|
||||
{
|
||||
std::optional<std::string> preset;
|
||||
std::optional<std::string> platform;
|
||||
std::optional<std::string> arch;
|
||||
std::optional<std::string> compiler;
|
||||
};
|
||||
|
||||
/// The preset a command should use: the named preset; else one made from the platform,
|
||||
/// architecture and compiler given, the project's defaults and then this machine for whatever
|
||||
/// is missing; else the project's default preset; else none (the SDK's own default). Throws an
|
||||
/// Error that lists the choices when what was asked for doesn't exist. `note` gets a line to
|
||||
/// show the user when the choice involved a guess.
|
||||
std::optional<std::string> resolvePreset(const Project& project, const Defaults& defaults,
|
||||
const PresetQuery& query, std::string* note = nullptr);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
/// Running other programs. Arguments are passed as a vector, never through a shell, so nothing
|
||||
/// needs quoting by the caller.
|
||||
namespace breeze::proc
|
||||
{
|
||||
|
||||
struct Options
|
||||
{
|
||||
/// Working directory; empty keeps the current one.
|
||||
std::filesystem::path cwd;
|
||||
/// Variables set (or replaced) for the child only.
|
||||
std::vector<std::pair<std::string, std::string>> env;
|
||||
/// Collect the child's standard output into Result::output instead of showing it.
|
||||
bool capture = false;
|
||||
/// With capture, standard error is collected too (merged into the same string).
|
||||
bool mergeStderr = false;
|
||||
/// Throw the child's output away.
|
||||
bool silent = false;
|
||||
};
|
||||
|
||||
struct Result
|
||||
{
|
||||
int exitCode = 0;
|
||||
std::string output;
|
||||
};
|
||||
|
||||
/// Runs argv[0] with the rest as arguments (looked up on PATH when it has no directory part)
|
||||
/// and waits. A program that can't be started is exit code 127. Throws Error only for failures
|
||||
/// of this function itself (nothing to run, the system refusing to create a process).
|
||||
Result run(const std::vector<std::string>& argv, const Options& options = {});
|
||||
|
||||
/// Runs a .bat/.cmd script on Windows through cmd.exe, or the script itself elsewhere.
|
||||
Result runScript(const std::filesystem::path& script, const std::vector<std::string>& args,
|
||||
const Options& options = {});
|
||||
|
||||
/// Replaces this process with argv (POSIX), or runs it and exits with its status (Windows).
|
||||
/// Does not return. Throws Error when it can't be started.
|
||||
[[noreturn]] void execReplace(const std::vector<std::string>& argv);
|
||||
|
||||
/// The first match for `program` on PATH, if any.
|
||||
std::optional<std::filesystem::path> findInPath(const std::string& program);
|
||||
|
||||
/// Sets an environment variable for this process and the programs it starts from now on.
|
||||
void setEnvironment(const std::string& name, const std::string& value);
|
||||
|
||||
bool stdinIsTerminal();
|
||||
bool stdoutIsTerminal();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "breeze/Version.hpp"
|
||||
|
||||
/// Finding a breeze project, telling which SDK it has, and reading and writing the project's own
|
||||
/// settings.
|
||||
///
|
||||
/// The CLI is global and projects carry their SDK (the template's breeze/ folder), so each project
|
||||
/// may be on a different SDK version -- or on the layout before breeze/ existed, with scripts/ and
|
||||
/// cmake/ at the root. Everything here asks the project's Sdk where things are instead of assuming.
|
||||
namespace breeze
|
||||
{
|
||||
|
||||
enum class SdkLayout
|
||||
{
|
||||
/// breeze/ (the SDK) and project/ (the project's settings).
|
||||
Breeze,
|
||||
/// The flat layout that came before: scripts/, cmake/ and Configuration.cmake at the root.
|
||||
Legacy
|
||||
};
|
||||
|
||||
struct Sdk
|
||||
{
|
||||
SdkLayout layout = SdkLayout::Breeze;
|
||||
|
||||
/// breeze/VERSION. Empty for the legacy layout and for a breeze/ that predates the file.
|
||||
std::optional<Version> version;
|
||||
|
||||
/// <root>/breeze, or <root> in the legacy layout.
|
||||
std::filesystem::path dir;
|
||||
std::filesystem::path scriptsDir;
|
||||
std::filesystem::path cmakeDir;
|
||||
|
||||
/// project/Configuration.cmake, or Configuration.cmake in the legacy layout.
|
||||
std::filesystem::path settingsFile;
|
||||
|
||||
/// Does this SDK have add_objects_module?
|
||||
bool supportsObjects = false;
|
||||
|
||||
/// "1.0.0", "unversioned (breeze/ layout)" or "legacy (flat layout, unversioned)".
|
||||
[[nodiscard]] std::string describe() const;
|
||||
};
|
||||
|
||||
struct Project
|
||||
{
|
||||
std::filesystem::path root;
|
||||
Sdk sdk;
|
||||
|
||||
/// <root>/.breeze: what the CLI keeps in the project (defaults, installed tools).
|
||||
[[nodiscard]] std::filesystem::path metaDir() const;
|
||||
[[nodiscard]] std::filesystem::path defaultsFile() const;
|
||||
[[nodiscard]] std::filesystem::path toolsDir() const;
|
||||
|
||||
/// The SDK's script `name` for this OS: scripts/<name>.sh, or .bat on Windows.
|
||||
[[nodiscard]] std::filesystem::path script(const std::string& name) const;
|
||||
};
|
||||
|
||||
/// Does `root` hold a breeze project, and with which SDK?
|
||||
std::optional<Sdk> detectSdk(const std::filesystem::path& root);
|
||||
|
||||
/// The nearest project at or above `start`.
|
||||
std::optional<Project> findProject(const std::filesystem::path& start);
|
||||
|
||||
/// findProject, or an Error explaining that the command needs a project.
|
||||
Project requireProject(const std::filesystem::path& start = std::filesystem::current_path());
|
||||
|
||||
enum class Setting
|
||||
{
|
||||
Name, // CMake's project() name
|
||||
Version, // the project's own version, major.minor[.patch[.tweak]]
|
||||
Id // the machine-friendly identifier (PROJECT_CODE_NAME)
|
||||
};
|
||||
|
||||
std::string readSetting(const Project& project, Setting which);
|
||||
|
||||
/// Validates the value, then changes just that value in the file, leaving everything else as it
|
||||
/// is.
|
||||
void writeSetting(const Project& project, Setting which, const std::string& value);
|
||||
|
||||
/// The CLI's per-project defaults, in .breeze/defaults.json (meant to be committed).
|
||||
class Defaults
|
||||
{
|
||||
public:
|
||||
static Defaults load(const Project& project);
|
||||
|
||||
/// key: platform, arch, compiler, preset or mode.
|
||||
[[nodiscard]] std::optional<std::string> build(const std::string& key) const;
|
||||
void setBuild(const std::string& key, const std::string& value);
|
||||
void clearBuild(const std::string& key);
|
||||
|
||||
/// The module id `breeze run` runs when it is given none.
|
||||
[[nodiscard]] std::optional<std::string> run() const;
|
||||
void setRun(const std::string& id);
|
||||
void clearRun();
|
||||
|
||||
void save(const Project& project) const;
|
||||
|
||||
private:
|
||||
nlohmann::json m_data = nlohmann::json::object();
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "breeze/Project.hpp"
|
||||
#include "breeze/Version.hpp"
|
||||
|
||||
/// SDK versions, where they come from and what moving a project between them means.
|
||||
///
|
||||
/// An SDK version is a snapshot of the whole template: breeze/ (the SDK proper, replaced as a whole
|
||||
/// on a change), plus the files the project owns and starts from (project/Configuration.cmake, the
|
||||
/// root CMakeLists.txt, CMakePresets.json, the dot files). Versions come from a *source*, either a
|
||||
/// git repository whose tags are versions (v1.2.0, 1.2.0) or a directory with one subdirectory per
|
||||
/// version.
|
||||
namespace breeze::sdk
|
||||
{
|
||||
|
||||
class Source
|
||||
{
|
||||
public:
|
||||
struct Entry
|
||||
{
|
||||
Version version;
|
||||
/// The tag, or the directory name, it was found under.
|
||||
std::string ref;
|
||||
};
|
||||
|
||||
/// The configured source (`breeze config sdk-source`, or BREEZE_SDK_SOURCE); throws Error
|
||||
/// when there is none.
|
||||
static Source configured();
|
||||
|
||||
explicit Source(std::string location);
|
||||
|
||||
[[nodiscard]] const std::string& location() const { return m_location; }
|
||||
|
||||
/// All versions, oldest first.
|
||||
[[nodiscard]] std::vector<Entry> versions() const;
|
||||
|
||||
/// The files of one version, fetched into the CLI's cache on first use.
|
||||
[[nodiscard]] std::filesystem::path snapshot(const Entry& entry) const;
|
||||
|
||||
/// "latest" (or nothing), or a version number: the matching entry. Throws Error, listing
|
||||
/// what exists, when there is no such version.
|
||||
[[nodiscard]] Entry resolve(const std::optional<std::string>& wanted) const;
|
||||
|
||||
private:
|
||||
std::string m_location;
|
||||
bool m_isRepository = false;
|
||||
};
|
||||
|
||||
/// Creates ./<name> from an SDK version: a project with that SDK, the settings set to the new
|
||||
/// project's name and id, no demo modules, and one executable module named after the project.
|
||||
void createProject(const std::string& name, const std::optional<std::string>& id,
|
||||
const std::optional<std::string>& sdkVersion);
|
||||
|
||||
/// Reports which SDK versions exist and whether the project is behind.
|
||||
void update(const Project& project);
|
||||
|
||||
/// Moves the project to the newest version with the same major as its own.
|
||||
void upgrade(const Project& project);
|
||||
|
||||
/// Moves the project to exactly this version, whichever way and across majors, and a legacy
|
||||
/// (flat) project to the breeze/ layout.
|
||||
void migrate(const Project& project, const std::string& version);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
/// Small string helpers; nothing here knows about breeze.
|
||||
namespace breeze::text
|
||||
{
|
||||
|
||||
std::string trim(std::string_view s);
|
||||
std::vector<std::string> split(std::string_view s, char delimiter);
|
||||
std::vector<std::string> splitLines(std::string_view s);
|
||||
std::string join(const std::vector<std::string>& parts, std::string_view separator);
|
||||
std::string lower(std::string_view s);
|
||||
bool startsWith(std::string_view s, std::string_view prefix);
|
||||
bool endsWith(std::string_view s, std::string_view suffix);
|
||||
std::string replaceAll(std::string s, std::string_view from, std::string_view to);
|
||||
|
||||
/// "CoreFarewell" -> "core_farewell", "HTTPServer" -> "http_server", "my-app" -> "my_app".
|
||||
std::string snakeCase(std::string_view s);
|
||||
|
||||
/// Lower-case letters and digits only: "Clang-CL" -> "clangcl". For comparing names loosely.
|
||||
std::string normalized(std::string_view s);
|
||||
|
||||
/// A module or project name: a letter, then letters, digits, '_' (and '-' when allowDash).
|
||||
bool isName(std::string_view s, bool allowDash = false);
|
||||
|
||||
/// A CMake target id as this project writes them: letters, digits, '_', '.', '+', '-' and "::".
|
||||
bool isTargetId(std::string_view s);
|
||||
|
||||
/// "x" for a version-less message, with a '<none>' stand-in for an empty value.
|
||||
std::string orNone(std::string_view s);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "breeze/Project.hpp"
|
||||
|
||||
/// Tools: extensions a project installs from a repository. A tool is a git repository (or, while it
|
||||
/// is being written, a directory) with a `breeze-tool.json` at its root:
|
||||
///
|
||||
/// { "id": "resembed", "version": "1.0.0", "description": "...", "entry": "bin/resembed.sh" }
|
||||
///
|
||||
/// `entry` may instead be { "posix": "...", "windows": "..." }. Installed, a tool is copied into
|
||||
/// <project>/.breeze/tools/<id>/ and recorded in .breeze/tools.json (meant to be committed). From
|
||||
/// then on `breeze tool <id> <arguments...>` runs its entry with those arguments, in the project's
|
||||
/// directory, with the project described in environment variables (BREEZE_PROJECT_ROOT,
|
||||
/// BREEZE_SDK_DIR, BREEZE_SDK_VERSION, BREEZE_TOOL_ID, BREEZE_TOOL_DIR, BREEZE_CLI, BREEZE_EDITOR).
|
||||
/// What commands a tool offers, and what they do, is entirely up to the tool.
|
||||
namespace breeze::tools
|
||||
{
|
||||
|
||||
struct Tool
|
||||
{
|
||||
std::string id;
|
||||
std::string version;
|
||||
std::string repo;
|
||||
std::string description;
|
||||
std::filesystem::path dir;
|
||||
};
|
||||
|
||||
/// The words after `breeze tool` that belong to the CLI itself and can't be tool ids.
|
||||
bool isReservedName(const std::string& word);
|
||||
|
||||
std::vector<Tool> installed(const Project& project);
|
||||
|
||||
/// Installs the newest version of the tool in `repo`.
|
||||
void install(const Project& project, const std::string& repo);
|
||||
void uninstall(const Project& project, const std::string& id);
|
||||
|
||||
/// Reports whether newer versions of the tool exist.
|
||||
void update(const Project& project, const std::string& id);
|
||||
|
||||
/// Moves the tool to its newest version with the same major version.
|
||||
void upgrade(const Project& project, const std::string& id);
|
||||
|
||||
/// Moves the tool to exactly this version.
|
||||
void migrate(const Project& project, const std::string& id, const std::string& version);
|
||||
|
||||
void list(const Project& project);
|
||||
|
||||
/// Runs the tool's entry with `args`; the entry's exit code, or nullopt when no such tool is
|
||||
/// installed.
|
||||
std::optional<int> run(const Project& project, const std::string& id,
|
||||
const std::vector<std::string>& args);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace breeze
|
||||
{
|
||||
|
||||
/// A semantic version: major.minor.patch with an optional pre-release tag.
|
||||
/// A pre-release sorts before the release it leads up to (1.0.0-rc1 < 1.0.0).
|
||||
struct Version
|
||||
{
|
||||
int major = 0;
|
||||
int minor = 0;
|
||||
int patch = 0;
|
||||
std::string pre;
|
||||
|
||||
[[nodiscard]] std::string toString() const;
|
||||
};
|
||||
|
||||
/// Accepts "1.2.3", "v1.2.3", "1.2.3-rc1", "1.2" (patch 0) and "1" (minor and patch 0);
|
||||
/// anything else, including build metadata after '+', is dropped or rejected.
|
||||
std::optional<Version> parseVersion(std::string_view text);
|
||||
|
||||
int compare(const Version& a, const Version& b);
|
||||
bool operator<(const Version& a, const Version& b);
|
||||
bool operator==(const Version& a, const Version& b);
|
||||
|
||||
/// The greatest of `versions`; with `sameMajorAs`, only of those with that major version.
|
||||
std::optional<Version> greatest(const std::vector<Version>& versions,
|
||||
std::optional<int> sameMajorAs = std::nullopt);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#include "breeze/Arguments.hpp"
|
||||
|
||||
#include <set>
|
||||
|
||||
namespace breeze::cli
|
||||
{
|
||||
|
||||
std::vector<std::string> normalizeArguments(const std::vector<std::string>& args)
|
||||
{
|
||||
if (args.empty())
|
||||
return args;
|
||||
const std::string& command = args.front();
|
||||
if (command != "build" && command != "install" && command != "run")
|
||||
return args;
|
||||
|
||||
// The options of these commands that take other programs' arguments.
|
||||
static const std::set<std::string> Passthrough = {"--cargs", "--bargs", "--args", "-x"};
|
||||
// Every option these commands have: where a pass-through list ends.
|
||||
static const std::set<std::string> Known = {
|
||||
"--platform", "-P", "--arch", "-A", "--compiler", "-C",
|
||||
"--mode", "-M", "--jobs", "-j", "--preset", "-p",
|
||||
"--cargs", "--bargs", "--args", "-x", "--no-configure", "--no-build",
|
||||
"--help", "-h"};
|
||||
|
||||
std::vector<std::string> out;
|
||||
for (std::size_t i = 0; i < args.size(); ++i)
|
||||
{
|
||||
const std::string& token = args[i];
|
||||
if (i == 0 || Passthrough.count(token) == 0)
|
||||
{
|
||||
out.push_back(token);
|
||||
continue;
|
||||
}
|
||||
// `--cargs=...` written by the user is already in the right form and not in
|
||||
// `Passthrough`.
|
||||
std::string longName = token == "-x" ? "--args" : token;
|
||||
while (i + 1 < args.size() && Known.count(args[i + 1]) == 0)
|
||||
{
|
||||
out.push_back(longName + "=" + args[i + 1]);
|
||||
++i;
|
||||
}
|
||||
if (out.empty() || out.back().rfind(longName + "=", 0) != 0)
|
||||
out.push_back(token); // no values: let CLIForge report that
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
#include "breeze/Build.hpp"
|
||||
|
||||
#include <iostream>
|
||||
|
||||
#include "breeze/Error.hpp"
|
||||
#include "breeze/Fs.hpp"
|
||||
#include "breeze/Modules.hpp"
|
||||
#include "breeze/Process.hpp"
|
||||
#include "breeze/Text.hpp"
|
||||
|
||||
namespace breeze
|
||||
{
|
||||
|
||||
namespace sfs = std::filesystem;
|
||||
|
||||
std::string buildTypeFor(const std::string& mode)
|
||||
{
|
||||
std::string m = text::normalized(mode);
|
||||
if (m == "release")
|
||||
return "Release";
|
||||
if (m == "debug")
|
||||
return "Debug";
|
||||
if (m == "releasedebugsymbols" || m == "relwithdebinfo")
|
||||
return "RelWithDebInfo";
|
||||
if (m == "minsizerel")
|
||||
return "MinSizeRel";
|
||||
throw Error("'" + mode +
|
||||
"' is not a build mode: use Release, Debug, ReleaseDebugSymbols or MinSizeRel");
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
bool hasFile(const sfs::path& p)
|
||||
{
|
||||
std::error_code ec;
|
||||
return sfs::is_regular_file(p, ec);
|
||||
}
|
||||
|
||||
proc::Options inProject(const Project& project)
|
||||
{
|
||||
proc::Options o;
|
||||
o.cwd = project.root;
|
||||
return o;
|
||||
}
|
||||
|
||||
sfs::path requireScript(const Project& project, const std::string& name)
|
||||
{
|
||||
sfs::path script = project.script(name);
|
||||
if (!hasFile(script))
|
||||
throw Error("this project's SDK (" + project.sdk.describe() + ") has no '" +
|
||||
fs::generic(sfs::relative(script, project.root)) + "'");
|
||||
return script;
|
||||
}
|
||||
|
||||
std::optional<std::string> modeOf(const BuildRequest& request, const Defaults& defaults)
|
||||
{
|
||||
std::optional<std::string> mode = request.mode ? request.mode : defaults.build("mode");
|
||||
if (!mode)
|
||||
return std::nullopt;
|
||||
return buildTypeFor(*mode);
|
||||
}
|
||||
|
||||
std::optional<std::string> presetOf(const Project& project, const Defaults& defaults,
|
||||
const PresetQuery& query)
|
||||
{
|
||||
std::string note;
|
||||
std::optional<std::string> preset = resolvePreset(project, defaults, query, ¬e);
|
||||
if (!note.empty())
|
||||
std::cerr << "breeze: " << note << "\n";
|
||||
return preset;
|
||||
}
|
||||
|
||||
bool isUnder(const sfs::path& root, const sfs::path& p)
|
||||
{
|
||||
std::error_code ec;
|
||||
sfs::path r = sfs::weakly_canonical(root, ec);
|
||||
sfs::path c = sfs::weakly_canonical(p, ec);
|
||||
auto rel = c.lexically_relative(r);
|
||||
return !rel.empty() && *rel.begin() != "..";
|
||||
}
|
||||
}
|
||||
|
||||
std::map<std::string, std::string> readConfigVariables(const Project& project,
|
||||
const std::vector<std::string>& names)
|
||||
{
|
||||
std::map<std::string, std::string> values;
|
||||
sfs::path printConfig = project.sdk.cmakeDir / "PrintConfig.cmake";
|
||||
if (hasFile(printConfig))
|
||||
{
|
||||
proc::Options o = inProject(project);
|
||||
o.capture = true;
|
||||
proc::Result r = proc::run(
|
||||
{"cmake", "-DPRINT_VARS=" + text::join(names, ";"), "-P", printConfig.string()}, o);
|
||||
if (r.exitCode == 0)
|
||||
{
|
||||
for (const std::string& line : text::splitLines(r.output))
|
||||
{
|
||||
if (!text::startsWith(line, "-- "))
|
||||
continue;
|
||||
std::size_t eq = line.find('=');
|
||||
if (eq == std::string::npos)
|
||||
continue;
|
||||
values[line.substr(3, eq - 3)] = line.substr(eq + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Anything the SDK couldn't tell: the layout every SDK so far has used.
|
||||
for (const std::string& n : names)
|
||||
{
|
||||
if (values.count(n) != 0 && !values[n].empty())
|
||||
continue;
|
||||
if (n == "BUILD_OUTPUT_DIR")
|
||||
values[n] = fs::generic(project.root / "out" / "build");
|
||||
else if (n == "DOCS_OUTPUT_DIR")
|
||||
values[n] = fs::generic(project.root / "out" / "docs");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
sfs::path buildDirFor(const Project& project, const std::optional<std::string>& preset)
|
||||
{
|
||||
sfs::path buildDirScript = project.sdk.cmakeDir / "BuildDir.cmake";
|
||||
if (hasFile(buildDirScript))
|
||||
{
|
||||
proc::Options o = inProject(project);
|
||||
o.capture = true;
|
||||
std::vector<std::string> argv{"cmake"};
|
||||
if (preset)
|
||||
argv.push_back("-DPMN_NAME=" + *preset);
|
||||
argv.push_back("-P");
|
||||
argv.push_back(buildDirScript.string());
|
||||
proc::Result r = proc::run(argv, o);
|
||||
for (const std::string& line : text::splitLines(r.output))
|
||||
{
|
||||
if (text::startsWith(line, "-- BUILD_DIR="))
|
||||
return sfs::path(line.substr(13));
|
||||
if (text::startsWith(line, "-- ERROR="))
|
||||
throw Error(line.substr(9));
|
||||
}
|
||||
throw Error("could not tell which build directory to use (the SDK's BuildDir.cmake "
|
||||
"said nothing)");
|
||||
}
|
||||
std::map<std::string, std::string> vars =
|
||||
readConfigVariables(project, {"BUILD_OUTPUT_DIR"});
|
||||
return sfs::path(vars["BUILD_OUTPUT_DIR"]) / (preset ? *preset : "default");
|
||||
}
|
||||
|
||||
int build(const Project& project, const BuildRequest& request)
|
||||
{
|
||||
Defaults defaults = Defaults::load(project);
|
||||
std::optional<std::string> preset = presetOf(project, defaults, request.preset);
|
||||
std::optional<std::string> type = modeOf(request, defaults);
|
||||
|
||||
proc::Options options = inProject(project);
|
||||
if (request.jobs)
|
||||
options.env.emplace_back("CMAKE_BUILD_PARALLEL_LEVEL", std::to_string(*request.jobs));
|
||||
|
||||
if (request.buildArgs.empty() && !request.noConfigure)
|
||||
{
|
||||
// The ordinary path: the SDK's script configures and builds.
|
||||
std::vector<std::string> args;
|
||||
if (preset)
|
||||
{
|
||||
args.push_back("--preset");
|
||||
args.push_back(*preset);
|
||||
}
|
||||
if (type)
|
||||
args.push_back(*type);
|
||||
args.insert(args.end(), request.configureArgs.begin(), request.configureArgs.end());
|
||||
return proc::runScript(requireScript(project, "build"), args, options).exitCode;
|
||||
}
|
||||
|
||||
// Extra build arguments, or no configure: build the configured directory directly.
|
||||
sfs::path dir = buildDirFor(project, preset);
|
||||
if (!hasFile(dir / "CMakeCache.txt"))
|
||||
throw Error("'" + fs::generic(dir) +
|
||||
"' is not configured yet. Run `breeze build` once without " +
|
||||
(request.noConfigure ? "--no-configure" : "--bargs") + " first");
|
||||
if (request.noConfigure && !request.configureArgs.empty())
|
||||
std::cerr << "breeze: --cargs are ignored with --no-configure\n";
|
||||
|
||||
std::vector<std::string> argv{"cmake", "--build", dir.string()};
|
||||
if (type)
|
||||
{
|
||||
argv.push_back("--config");
|
||||
argv.push_back(*type);
|
||||
}
|
||||
if (request.jobs)
|
||||
{
|
||||
argv.push_back("-j");
|
||||
argv.push_back(std::to_string(*request.jobs));
|
||||
}
|
||||
argv.insert(argv.end(), request.buildArgs.begin(), request.buildArgs.end());
|
||||
return proc::run(argv, options).exitCode;
|
||||
}
|
||||
|
||||
int install(const Project& project, const BuildRequest& request)
|
||||
{
|
||||
if (!request.noBuild)
|
||||
{
|
||||
int rc = build(project, request);
|
||||
if (rc != 0)
|
||||
return rc;
|
||||
}
|
||||
Defaults defaults = Defaults::load(project);
|
||||
std::optional<std::string> preset = presetOf(project, defaults, request.preset);
|
||||
std::optional<std::string> type = modeOf(request, defaults);
|
||||
std::vector<std::string> args;
|
||||
if (preset)
|
||||
{
|
||||
args.push_back("--preset");
|
||||
args.push_back(*preset);
|
||||
}
|
||||
if (type)
|
||||
args.push_back(*type);
|
||||
return proc::runScript(requireScript(project, "install"), args, inProject(project))
|
||||
.exitCode;
|
||||
}
|
||||
|
||||
int docs(const Project& project)
|
||||
{
|
||||
Defaults defaults = Defaults::load(project);
|
||||
std::vector<std::string> args;
|
||||
if (std::optional<std::string> preset = defaults.build("preset"))
|
||||
{
|
||||
args.push_back("--preset");
|
||||
args.push_back(*preset);
|
||||
}
|
||||
int rc = proc::runScript(requireScript(project, "docs"), args, inProject(project)).exitCode;
|
||||
if (rc != 0 && rc != 127)
|
||||
std::cerr << "breeze: the documentation needs a build first: run `breeze build`\n";
|
||||
return rc;
|
||||
}
|
||||
|
||||
int run(const Project& project, const std::optional<std::string>& id, const PresetQuery& where,
|
||||
const std::vector<std::string>& programArgs)
|
||||
{
|
||||
Defaults defaults = Defaults::load(project);
|
||||
std::string target;
|
||||
if (id)
|
||||
{
|
||||
target = *id;
|
||||
// A directory name works too, when the module is there.
|
||||
std::vector<Module> modules = scanModules(project);
|
||||
if (const Module* m = findModule(modules, *id))
|
||||
target = m->id;
|
||||
}
|
||||
else if (std::optional<std::string> d = defaults.run())
|
||||
{
|
||||
target = *d;
|
||||
}
|
||||
else
|
||||
{
|
||||
std::vector<Module> modules = scanModules(project);
|
||||
std::vector<std::string> execs;
|
||||
for (const Module& m : modules)
|
||||
{
|
||||
if (m.kind == ModuleKind::Exec)
|
||||
execs.push_back(m.id);
|
||||
}
|
||||
if (execs.size() != 1)
|
||||
throw Error(std::string(execs.empty() ? "this project has no executable module"
|
||||
: "which executable? This project has " +
|
||||
text::join(execs, ", ")) +
|
||||
". Give an id, or set a default with `breeze defaults run --set <id>`");
|
||||
target = execs.front();
|
||||
}
|
||||
|
||||
std::optional<std::string> preset = presetOf(project, defaults, where);
|
||||
std::vector<std::string> args;
|
||||
if (preset)
|
||||
{
|
||||
args.push_back("--preset");
|
||||
args.push_back(*preset);
|
||||
}
|
||||
args.push_back(target);
|
||||
if (!programArgs.empty())
|
||||
{
|
||||
args.push_back("--");
|
||||
args.insert(args.end(), programArgs.begin(), programArgs.end());
|
||||
}
|
||||
return proc::runScript(requireScript(project, "run"), args, inProject(project)).exitCode;
|
||||
}
|
||||
|
||||
int setup(const Project& project, bool required, bool all)
|
||||
{
|
||||
std::vector<std::string> args;
|
||||
if (all)
|
||||
args.push_back("--all");
|
||||
else if (required)
|
||||
args.push_back("--yes");
|
||||
return proc::runScript(requireScript(project, "setup"), args, inProject(project)).exitCode;
|
||||
}
|
||||
|
||||
std::vector<std::string> clean(const Project& project, bool keepBuild, bool alsoDocs)
|
||||
{
|
||||
std::vector<std::string> names;
|
||||
if (!keepBuild)
|
||||
names.push_back("BUILD_OUTPUT_DIR");
|
||||
if (alsoDocs)
|
||||
names.push_back("DOCS_OUTPUT_DIR");
|
||||
std::vector<std::string> removed;
|
||||
if (names.empty())
|
||||
return removed;
|
||||
std::map<std::string, std::string> vars = readConfigVariables(project, names);
|
||||
for (const std::string& n : names)
|
||||
{
|
||||
sfs::path dir = vars[n];
|
||||
if (!isUnder(project.root, dir))
|
||||
throw Error("refusing to remove '" + fs::generic(dir) +
|
||||
"': it is outside the project");
|
||||
std::error_code ec;
|
||||
if (!sfs::exists(dir, ec))
|
||||
continue;
|
||||
fs::removeTree(dir);
|
||||
removed.push_back(fs::generic(sfs::relative(dir, project.root)));
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
#include "breeze/CMakeText.hpp"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
#include "breeze/Text.hpp"
|
||||
|
||||
namespace breeze::cmake
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
bool isIdentStart(char c)
|
||||
{
|
||||
return std::isalpha(static_cast<unsigned char>(c)) || c == '_';
|
||||
}
|
||||
|
||||
bool isIdentChar(char c)
|
||||
{
|
||||
return std::isalnum(static_cast<unsigned char>(c)) || c == '_';
|
||||
}
|
||||
|
||||
// Skips whitespace and comments from pos; returns the new position.
|
||||
std::size_t skipBlank(const std::string& t, std::size_t pos)
|
||||
{
|
||||
while (pos < t.size())
|
||||
{
|
||||
char c = t[pos];
|
||||
if (std::isspace(static_cast<unsigned char>(c)))
|
||||
{
|
||||
++pos;
|
||||
}
|
||||
else if (c == '#')
|
||||
{
|
||||
// bracket comment: #[[ ... ]] or #[=[ ... ]=]
|
||||
std::size_t eq = pos + 1;
|
||||
if (eq < t.size() && t[eq] == '[')
|
||||
{
|
||||
std::size_t level = 0;
|
||||
std::size_t p = eq + 1;
|
||||
while (p < t.size() && t[p] == '=')
|
||||
{
|
||||
++level;
|
||||
++p;
|
||||
}
|
||||
if (p < t.size() && t[p] == '[')
|
||||
{
|
||||
std::string close = "]" + std::string(level, '=') + "]";
|
||||
std::size_t endPos = t.find(close, p + 1);
|
||||
pos = endPos == std::string::npos ? t.size() : endPos + close.size();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
while (pos < t.size() && t[pos] != '\n')
|
||||
++pos;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
// Parses the arguments of a call whose '(' is at `open`; returns the position just after
|
||||
// the matching ')'.
|
||||
std::size_t parseArgs(const std::string& t, std::size_t open, std::vector<Token>& args)
|
||||
{
|
||||
std::size_t pos = open + 1;
|
||||
int depth = 1;
|
||||
while (pos < t.size())
|
||||
{
|
||||
pos = skipBlank(t, pos);
|
||||
if (pos >= t.size())
|
||||
break;
|
||||
char c = t[pos];
|
||||
if (c == ')')
|
||||
{
|
||||
if (--depth == 0)
|
||||
return pos + 1;
|
||||
++pos;
|
||||
continue;
|
||||
}
|
||||
if (c == '(')
|
||||
{
|
||||
++depth;
|
||||
++pos;
|
||||
continue;
|
||||
}
|
||||
Token tok;
|
||||
tok.begin = pos;
|
||||
if (c == '"')
|
||||
{
|
||||
tok.quoted = true;
|
||||
++pos;
|
||||
while (pos < t.size() && t[pos] != '"')
|
||||
{
|
||||
if (t[pos] == '\\' && pos + 1 < t.size())
|
||||
++pos;
|
||||
tok.text += t[pos++];
|
||||
}
|
||||
if (pos < t.size())
|
||||
++pos; // closing quote
|
||||
}
|
||||
else if (c == '[' && pos + 1 < t.size() && (t[pos + 1] == '[' || t[pos + 1] == '='))
|
||||
{
|
||||
std::size_t p = pos + 1;
|
||||
std::size_t level = 0;
|
||||
while (p < t.size() && t[p] == '=')
|
||||
{
|
||||
++level;
|
||||
++p;
|
||||
}
|
||||
if (p < t.size() && t[p] == '[')
|
||||
{
|
||||
std::string close = "]" + std::string(level, '=') + "]";
|
||||
std::size_t endPos = t.find(close, p + 1);
|
||||
std::size_t contentEnd = endPos == std::string::npos ? t.size() : endPos;
|
||||
tok.quoted = true;
|
||||
tok.text = t.substr(p + 1, contentEnd - p - 1);
|
||||
pos = endPos == std::string::npos ? t.size() : endPos + close.size();
|
||||
}
|
||||
else
|
||||
{
|
||||
tok.text = "[";
|
||||
++pos;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
while (pos < t.size() && !std::isspace(static_cast<unsigned char>(t[pos])) &&
|
||||
t[pos] != ')' && t[pos] != '(' && t[pos] != '#' && t[pos] != '"')
|
||||
tok.text += t[pos++];
|
||||
if (tok.text.empty())
|
||||
{
|
||||
++pos; // a stray character: step over it rather than loop
|
||||
continue;
|
||||
}
|
||||
}
|
||||
tok.end = pos;
|
||||
args.push_back(std::move(tok));
|
||||
}
|
||||
return t.size();
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Call> findCalls(const std::string& text,
|
||||
const std::function<bool(const std::string&)>& wanted)
|
||||
{
|
||||
std::vector<Call> calls;
|
||||
std::size_t pos = 0;
|
||||
while (pos < text.size())
|
||||
{
|
||||
pos = skipBlank(text, pos);
|
||||
if (pos >= text.size())
|
||||
break;
|
||||
if (!isIdentStart(text[pos]))
|
||||
{
|
||||
++pos;
|
||||
continue;
|
||||
}
|
||||
std::size_t nameBegin = pos;
|
||||
while (pos < text.size() && isIdentChar(text[pos]))
|
||||
++pos;
|
||||
std::string name = text.substr(nameBegin, pos - nameBegin);
|
||||
std::size_t open = pos;
|
||||
while (open < text.size() && (text[open] == ' ' || text[open] == '\t'))
|
||||
++open;
|
||||
if (open >= text.size() || text[open] != '(')
|
||||
continue;
|
||||
Call call;
|
||||
call.name = std::move(name);
|
||||
call.begin = nameBegin;
|
||||
call.end = parseArgs(text, open, call.args);
|
||||
pos = call.end;
|
||||
if (wanted(call.name))
|
||||
calls.push_back(std::move(call));
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
std::optional<Call> findSet(const std::string& text, const std::string& variable)
|
||||
{
|
||||
for (Call& call :
|
||||
findCalls(text, [](const std::string& n) { return text::lower(n) == "set"; }))
|
||||
{
|
||||
if (!call.args.empty() && call.args[0].text == variable)
|
||||
return std::move(call);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string quote(const std::string& value)
|
||||
{
|
||||
std::string out = "\"";
|
||||
for (char c : value)
|
||||
{
|
||||
if (c == '"' || c == '\\')
|
||||
out += '\\';
|
||||
out += c;
|
||||
}
|
||||
out += '"';
|
||||
return out;
|
||||
}
|
||||
|
||||
void replaceToken(std::string& text, const Token& token, const std::string& replacement)
|
||||
{
|
||||
text.replace(token.begin, token.end - token.begin, replacement);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
#include "breeze/Cli.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
|
||||
#include "breeze/Config.hpp"
|
||||
#include "breeze/Error.hpp"
|
||||
#include "breeze/Fs.hpp"
|
||||
#include "breeze/Process.hpp"
|
||||
#include "breeze/Text.hpp"
|
||||
|
||||
#ifndef BREEZE_CLI_VERSION
|
||||
#define BREEZE_CLI_VERSION "0.0.0"
|
||||
#endif
|
||||
|
||||
namespace breeze::self
|
||||
{
|
||||
|
||||
namespace sfs = std::filesystem;
|
||||
|
||||
std::string version()
|
||||
{
|
||||
return BREEZE_CLI_VERSION;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
sfs::path root()
|
||||
{
|
||||
return breezeHome() / "cli";
|
||||
}
|
||||
|
||||
std::string exeName()
|
||||
{
|
||||
return fs::hostPlatform() == "windows" ? "breeze.exe" : "breeze";
|
||||
}
|
||||
|
||||
sfs::path binaryOf(const Version& v)
|
||||
{
|
||||
return root() / v.toString() / exeName();
|
||||
}
|
||||
|
||||
sfs::path shim()
|
||||
{
|
||||
return breezeHome() / "bin" / exeName();
|
||||
}
|
||||
|
||||
Version parseOrThrow(const std::string& text)
|
||||
{
|
||||
std::optional<Version> v = parseVersion(text);
|
||||
if (!v)
|
||||
throw Error("'" + text + "' is not a version (use 1.2.3)");
|
||||
return *v;
|
||||
}
|
||||
|
||||
bool isUrl(const std::string& s)
|
||||
{
|
||||
return text::startsWith(s, "http://") || text::startsWith(s, "https://");
|
||||
}
|
||||
|
||||
// <source>/<version>/breeze-<platform>-<arch>[.exe], a URL or a directory.
|
||||
void obtain(const std::string& source, const Version& v, const sfs::path& destination)
|
||||
{
|
||||
std::string file = "breeze-" + fs::hostPlatform() + "-" + fs::hostArch() +
|
||||
(fs::hostPlatform() == "windows" ? ".exe" : "");
|
||||
std::string base = source;
|
||||
while (!base.empty() && base.back() == '/')
|
||||
base.pop_back();
|
||||
std::string where = base + "/" + v.toString() + "/" + file;
|
||||
|
||||
std::error_code ec;
|
||||
sfs::create_directories(destination.parent_path(), ec);
|
||||
if (isUrl(source))
|
||||
{
|
||||
if (!proc::findInPath("curl"))
|
||||
throw Error("curl is needed to download CLI versions, and it is not installed");
|
||||
proc::Result r = proc::run({"curl", "--fail", "--silent", "--show-error",
|
||||
"--location", "--output", destination.string(), where});
|
||||
if (r.exitCode != 0)
|
||||
throw Error("cannot download '" + where + "'");
|
||||
return;
|
||||
}
|
||||
if (text::startsWith(where, "file://"))
|
||||
where = where.substr(7);
|
||||
if (!sfs::is_regular_file(where, ec))
|
||||
throw Error("there is no '" + where + "': CLI " + v.toString() +
|
||||
" is not in the CLI source '" + source + "' for " + fs::hostPlatform() +
|
||||
"-" + fs::hostArch());
|
||||
sfs::copy_file(where, destination, sfs::copy_options::overwrite_existing, ec);
|
||||
if (ec)
|
||||
throw Error("cannot copy '" + where + "': " + ec.message());
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Version> installed()
|
||||
{
|
||||
std::vector<Version> out;
|
||||
std::error_code ec;
|
||||
if (!sfs::is_directory(root(), ec))
|
||||
return out;
|
||||
for (const sfs::directory_entry& e : sfs::directory_iterator(root(), ec))
|
||||
{
|
||||
if (!e.is_directory())
|
||||
continue;
|
||||
std::optional<Version> v = parseVersion(e.path().filename().string());
|
||||
if (v && sfs::exists(binaryOf(*v)))
|
||||
out.push_back(*v);
|
||||
}
|
||||
std::sort(out.begin(), out.end());
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<Version> picked()
|
||||
{
|
||||
std::optional<std::string> text = fs::tryReadText(root() / "current");
|
||||
if (!text)
|
||||
return std::nullopt;
|
||||
return parseVersion(text::trim(*text));
|
||||
}
|
||||
|
||||
void install(const std::string& versionText)
|
||||
{
|
||||
Version v = parseOrThrow(versionText);
|
||||
sfs::path binary = binaryOf(v);
|
||||
bool already = sfs::exists(binary);
|
||||
|
||||
if (!already)
|
||||
{
|
||||
// Nothing is left behind when it fails.
|
||||
struct Cleanup
|
||||
{
|
||||
explicit Cleanup(sfs::path d) : dir(std::move(d)) {}
|
||||
Cleanup(const Cleanup&) = delete;
|
||||
Cleanup& operator=(const Cleanup&) = delete;
|
||||
Cleanup(Cleanup&&) = delete;
|
||||
Cleanup& operator=(Cleanup&&) = delete;
|
||||
sfs::path dir;
|
||||
bool armed = true;
|
||||
~Cleanup()
|
||||
{
|
||||
if (armed)
|
||||
{
|
||||
std::error_code ec;
|
||||
sfs::remove_all(dir, ec);
|
||||
}
|
||||
}
|
||||
} cleanup{binary.parent_path()};
|
||||
|
||||
if (std::optional<Version> own = parseVersion(version()); own && *own == v)
|
||||
{
|
||||
// This program is the version asked for: install a copy of it.
|
||||
std::error_code ec;
|
||||
sfs::create_directories(binary.parent_path(), ec);
|
||||
sfs::copy_file(fs::executablePath(), binary, sfs::copy_options::overwrite_existing,
|
||||
ec);
|
||||
if (ec)
|
||||
throw Error("cannot copy this program to '" + fs::generic(binary) +
|
||||
"': " + ec.message());
|
||||
}
|
||||
else
|
||||
{
|
||||
GlobalConfig cfg = GlobalConfig::load();
|
||||
std::optional<std::string> source = cfg.effective(config::CliSource);
|
||||
if (!source)
|
||||
throw Error("CLI " + v.toString() + " is not this program (" + version() +
|
||||
") and no CLI source "
|
||||
"is configured. Say where releases come from with:\n"
|
||||
" breeze config cli-source --set <URL or directory>");
|
||||
obtain(*source, v, binary);
|
||||
}
|
||||
fs::makeExecutable(binary);
|
||||
|
||||
// It has to be the version it says it is, and run at all.
|
||||
proc::Options o;
|
||||
o.capture = true;
|
||||
proc::Result r = proc::run({binary.string(), "cli", "version"}, o);
|
||||
if (r.exitCode != 0 || text::trim(r.output) != v.toString())
|
||||
{
|
||||
throw Error("what was installed for " + v.toString() +
|
||||
" does not run, or says it is another version "
|
||||
"(it printed '" +
|
||||
text::trim(r.output) + "'); nothing was installed");
|
||||
}
|
||||
cleanup.armed = false;
|
||||
}
|
||||
|
||||
// The copy to put on PATH, and the first version installed is the one in use.
|
||||
if (!sfs::exists(shim()))
|
||||
{
|
||||
std::error_code ec;
|
||||
sfs::create_directories(shim().parent_path(), ec);
|
||||
sfs::copy_file(binary, shim(), sfs::copy_options::overwrite_existing, ec);
|
||||
}
|
||||
bool first = !picked().has_value();
|
||||
if (first)
|
||||
fs::writeText(root() / "current", v.toString() + "\n");
|
||||
|
||||
std::cout << (already ? "already installed: " : "installed ") << v.toString()
|
||||
<< (first ? " (picked)" : "") << "\n";
|
||||
const char* pathEnv = std::getenv("PATH");
|
||||
std::string binDir = fs::generic(shim().parent_path());
|
||||
if (pathEnv == nullptr ||
|
||||
std::string(pathEnv).find(shim().parent_path().string()) == std::string::npos)
|
||||
std::cout << "to use it from anywhere, put " << binDir << " on your PATH\n";
|
||||
}
|
||||
|
||||
void pick(const std::string& versionText)
|
||||
{
|
||||
Version v = parseOrThrow(versionText);
|
||||
if (!sfs::exists(binaryOf(v)))
|
||||
{
|
||||
std::vector<std::string> names;
|
||||
for (const Version& i : installed())
|
||||
names.push_back(i.toString());
|
||||
throw Error("CLI " + v.toString() + " is not installed (`breeze cli install " +
|
||||
v.toString() + "`). " +
|
||||
(names.empty() ? "Nothing is installed."
|
||||
: "Installed: " + text::join(names, ", ")));
|
||||
}
|
||||
std::optional<Version> before = picked();
|
||||
fs::writeText(root() / "current", v.toString() + "\n");
|
||||
std::cout << "picked " << v.toString();
|
||||
if (before && !(*before == v))
|
||||
std::cout << " (was " << before->toString() << ")";
|
||||
std::cout << "\n";
|
||||
}
|
||||
|
||||
void uninstall(const std::string& versionText)
|
||||
{
|
||||
Version v = parseOrThrow(versionText);
|
||||
sfs::path dir = root() / v.toString();
|
||||
if (!sfs::exists(dir))
|
||||
throw Error("CLI " + v.toString() + " is not installed");
|
||||
bool wasPicked = picked() && *picked() == v;
|
||||
fs::removeTree(dir);
|
||||
if (wasPicked)
|
||||
{
|
||||
std::error_code ec;
|
||||
sfs::remove(root() / "current", ec);
|
||||
}
|
||||
std::cout << "uninstalled " << v.toString()
|
||||
<< (wasPicked ? " (it was the picked version: none is picked now)" : "") << "\n";
|
||||
}
|
||||
|
||||
void list()
|
||||
{
|
||||
std::vector<Version> all = installed();
|
||||
std::optional<Version> p = picked();
|
||||
std::optional<Version> own = parseVersion(version());
|
||||
if (all.empty())
|
||||
{
|
||||
std::cout << "no versions installed (this program is " << version()
|
||||
<< ": `breeze cli install " << version() << "`)\n";
|
||||
return;
|
||||
}
|
||||
for (const Version& v : all)
|
||||
{
|
||||
std::cout << (p && *p == v ? "* " : " ") << v.toString();
|
||||
if (own && *own == v)
|
||||
std::cout << " (this program)";
|
||||
std::cout << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
void forwardToPicked(const std::vector<std::string>& args)
|
||||
{
|
||||
if (std::getenv("BREEZE_NO_FORWARD") != nullptr)
|
||||
return;
|
||||
if (args.empty() || args.front() == "cli")
|
||||
return;
|
||||
std::optional<Version> p = picked();
|
||||
std::optional<Version> own = parseVersion(version());
|
||||
if (!p || (own && *own == *p))
|
||||
return;
|
||||
sfs::path binary = binaryOf(*p);
|
||||
if (!fs::isExecutable(binary))
|
||||
{
|
||||
std::cerr << "breeze: the picked version " << p->toString()
|
||||
<< " is not installed any more; using " << version()
|
||||
<< " (`breeze cli install " << p->toString()
|
||||
<< "` or `breeze cli pick <version>`)\n";
|
||||
return;
|
||||
}
|
||||
proc::setEnvironment("BREEZE_NO_FORWARD", "1");
|
||||
std::vector<std::string> argv{binary.string()};
|
||||
argv.insert(argv.end(), args.begin(), args.end());
|
||||
proc::execReplace(argv);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "breeze/Config.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "breeze/Error.hpp"
|
||||
#include "breeze/Fs.hpp"
|
||||
|
||||
namespace breeze
|
||||
{
|
||||
|
||||
std::filesystem::path breezeHome()
|
||||
{
|
||||
if (const char* p = std::getenv("BREEZE_HOME"); p != nullptr && *p != '\0')
|
||||
return std::filesystem::path(p);
|
||||
return fs::homeDir() / ".breeze";
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
std::filesystem::path configFile()
|
||||
{
|
||||
return breezeHome() / "config.json";
|
||||
}
|
||||
|
||||
std::string envName(const std::string& key)
|
||||
{
|
||||
std::string name = "BREEZE_";
|
||||
for (char c : key)
|
||||
{
|
||||
if (std::isupper(static_cast<unsigned char>(c)) && name.back() != '_')
|
||||
name += '_';
|
||||
name += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
GlobalConfig GlobalConfig::load()
|
||||
{
|
||||
GlobalConfig c;
|
||||
if (std::optional<std::string> text = fs::tryReadText(configFile()))
|
||||
{
|
||||
nlohmann::json parsed = nlohmann::json::parse(*text, nullptr, false);
|
||||
if (parsed.is_discarded() || !parsed.is_object())
|
||||
throw Error("'" + fs::generic(configFile()) +
|
||||
"' is not valid JSON; fix or delete it");
|
||||
c.m_data = std::move(parsed);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
std::optional<std::string> GlobalConfig::stored(const std::string& key) const
|
||||
{
|
||||
auto it = m_data.find(key);
|
||||
if (it == m_data.end() || !it->is_string())
|
||||
return std::nullopt;
|
||||
return it->get<std::string>();
|
||||
}
|
||||
|
||||
std::optional<std::string> GlobalConfig::effective(const std::string& key) const
|
||||
{
|
||||
if (const char* p = std::getenv(envName(key).c_str()); p != nullptr && *p != '\0')
|
||||
return std::string(p);
|
||||
return stored(key);
|
||||
}
|
||||
|
||||
void GlobalConfig::set(const std::string& key, const std::string& value)
|
||||
{
|
||||
m_data[key] = value;
|
||||
}
|
||||
|
||||
void GlobalConfig::erase(const std::string& key)
|
||||
{
|
||||
m_data.erase(key);
|
||||
}
|
||||
|
||||
void GlobalConfig::save() const
|
||||
{
|
||||
fs::writeText(configFile(), m_data.dump(2) + "\n");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
#include "breeze/Fs.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <system_error>
|
||||
|
||||
#include "breeze/Error.hpp"
|
||||
|
||||
#if defined(_WIN32)
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#elif defined(__APPLE__)
|
||||
#include <mach-o/dyld.h>
|
||||
#include <unistd.h>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
#if !defined(_WIN32)
|
||||
#include <sys/stat.h>
|
||||
#endif
|
||||
|
||||
namespace breeze::fs
|
||||
{
|
||||
|
||||
std::optional<std::string> tryReadText(const path& file)
|
||||
{
|
||||
std::ifstream in(file, std::ios::binary);
|
||||
if (!in)
|
||||
return std::nullopt;
|
||||
std::ostringstream buffer;
|
||||
buffer << in.rdbuf();
|
||||
return buffer.str();
|
||||
}
|
||||
|
||||
std::string readText(const path& file)
|
||||
{
|
||||
std::optional<std::string> text = tryReadText(file);
|
||||
if (!text)
|
||||
throw Error("cannot read '" + generic(file) + "'");
|
||||
return *text;
|
||||
}
|
||||
|
||||
void writeText(const path& file, const std::string& text)
|
||||
{
|
||||
std::error_code ec;
|
||||
if (file.has_parent_path())
|
||||
std::filesystem::create_directories(file.parent_path(), ec);
|
||||
std::ofstream out(file, std::ios::binary | std::ios::trunc);
|
||||
if (!out)
|
||||
throw Error("cannot write '" + generic(file) + "'");
|
||||
out << text;
|
||||
if (!out)
|
||||
throw Error("cannot write '" + generic(file) + "'");
|
||||
}
|
||||
|
||||
void copyTree(const path& from, const path& to, const std::vector<std::string>& skipNames)
|
||||
{
|
||||
namespace sfs = std::filesystem;
|
||||
std::error_code ec;
|
||||
sfs::create_directories(to, ec);
|
||||
for (const sfs::directory_entry& entry : sfs::directory_iterator(from))
|
||||
{
|
||||
std::string name = entry.path().filename().string();
|
||||
if (std::find(skipNames.begin(), skipNames.end(), name) != skipNames.end())
|
||||
continue;
|
||||
path target = to / name;
|
||||
if (entry.is_symlink())
|
||||
{
|
||||
sfs::copy_symlink(entry.path(), target, ec);
|
||||
if (ec)
|
||||
throw Error("cannot copy '" + generic(entry.path()) + "': " + ec.message());
|
||||
}
|
||||
else if (entry.is_directory())
|
||||
{
|
||||
copyTree(entry.path(), target, skipNames);
|
||||
}
|
||||
else
|
||||
{
|
||||
sfs::copy_file(entry.path(), target, sfs::copy_options::overwrite_existing, ec);
|
||||
if (ec)
|
||||
throw Error("cannot copy '" + generic(entry.path()) + "': " + ec.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void removeTree(const path& p)
|
||||
{
|
||||
std::error_code ec;
|
||||
std::filesystem::remove_all(p, ec);
|
||||
if (ec)
|
||||
throw Error("cannot remove '" + generic(p) + "': " + ec.message());
|
||||
}
|
||||
|
||||
path makeTempDir(const std::string& prefix)
|
||||
{
|
||||
std::random_device rd;
|
||||
std::mt19937_64 gen(rd());
|
||||
for (int attempt = 0; attempt < 100; ++attempt)
|
||||
{
|
||||
std::ostringstream name;
|
||||
name << prefix << "-" << std::hex << gen();
|
||||
path dir = std::filesystem::temp_directory_path() / name.str();
|
||||
std::error_code ec;
|
||||
if (std::filesystem::create_directory(dir, ec))
|
||||
return dir;
|
||||
}
|
||||
throw Error("cannot create a temporary directory");
|
||||
}
|
||||
|
||||
path homeDir()
|
||||
{
|
||||
#if defined(_WIN32)
|
||||
if (const char* p = std::getenv("USERPROFILE"))
|
||||
return path(p);
|
||||
#endif
|
||||
if (const char* p = std::getenv("HOME"))
|
||||
return path(p);
|
||||
throw Error("cannot tell where your home directory is (HOME is not set)");
|
||||
}
|
||||
|
||||
path executablePath()
|
||||
{
|
||||
#if defined(_WIN32)
|
||||
std::wstring buffer(32768, L'\0');
|
||||
DWORD n = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
|
||||
buffer.resize(n);
|
||||
return path(buffer);
|
||||
#elif defined(__APPLE__)
|
||||
std::uint32_t size = 0;
|
||||
_NSGetExecutablePath(nullptr, &size);
|
||||
std::string buffer(size, '\0');
|
||||
if (_NSGetExecutablePath(buffer.data(), &size) != 0)
|
||||
throw Error("cannot tell where this program is");
|
||||
buffer.resize(std::char_traits<char>::length(buffer.c_str()));
|
||||
std::error_code ec;
|
||||
path canonical = std::filesystem::canonical(buffer, ec);
|
||||
return ec ? path(buffer) : canonical;
|
||||
#else
|
||||
std::error_code ec;
|
||||
path p = std::filesystem::read_symlink("/proc/self/exe", ec);
|
||||
if (ec)
|
||||
throw Error("cannot tell where this program is: " + ec.message());
|
||||
return p;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string hostPlatform()
|
||||
{
|
||||
#if defined(_WIN32)
|
||||
return "windows";
|
||||
#elif defined(__APPLE__)
|
||||
return "macos";
|
||||
#else
|
||||
return "linux";
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string hostArch()
|
||||
{
|
||||
#if defined(__x86_64__) || defined(_M_X64)
|
||||
return "x64";
|
||||
#elif defined(__aarch64__) || defined(_M_ARM64)
|
||||
return "arm64";
|
||||
#elif defined(__arm__) || defined(_M_ARM)
|
||||
return "arm32";
|
||||
#else
|
||||
return "x86";
|
||||
#endif
|
||||
}
|
||||
|
||||
void makeExecutable(const path& file)
|
||||
{
|
||||
#if !defined(_WIN32)
|
||||
std::error_code ec;
|
||||
std::filesystem::permissions(file,
|
||||
std::filesystem::perms::owner_exec |
|
||||
std::filesystem::perms::group_exec |
|
||||
std::filesystem::perms::others_exec,
|
||||
std::filesystem::perm_options::add, ec);
|
||||
if (ec)
|
||||
throw Error("cannot make '" + generic(file) + "' executable: " + ec.message());
|
||||
#else
|
||||
(void)file;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool isExecutable(const path& file)
|
||||
{
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::is_regular_file(file, ec))
|
||||
return false;
|
||||
#if defined(_WIN32)
|
||||
return true;
|
||||
#else
|
||||
return ::access(file.c_str(), X_OK) == 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string generic(const path& p)
|
||||
{
|
||||
return p.generic_string();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "breeze/Git.hpp"
|
||||
|
||||
#include <set>
|
||||
|
||||
#include "breeze/Error.hpp"
|
||||
#include "breeze/Fs.hpp"
|
||||
#include "breeze/Process.hpp"
|
||||
#include "breeze/Text.hpp"
|
||||
|
||||
namespace breeze::git
|
||||
{
|
||||
|
||||
namespace sfs = std::filesystem;
|
||||
|
||||
namespace
|
||||
{
|
||||
void requireGit()
|
||||
{
|
||||
if (!proc::findInPath("git"))
|
||||
throw Error("git is needed for this, and it is not installed (or not on PATH)");
|
||||
}
|
||||
|
||||
bool isUrl(const std::string& s)
|
||||
{
|
||||
return text::startsWith(s, "http://") || text::startsWith(s, "https://") ||
|
||||
text::startsWith(s, "ssh://") || text::startsWith(s, "git://") ||
|
||||
text::startsWith(s, "file://") || text::startsWith(s, "git@");
|
||||
}
|
||||
|
||||
// A local repository is cloned through a file:// URL, which keeps git from ignoring
|
||||
// --depth.
|
||||
std::string cloneable(const std::string& repository)
|
||||
{
|
||||
if (isUrl(repository))
|
||||
return repository;
|
||||
std::error_code ec;
|
||||
sfs::path abs = sfs::absolute(repository, ec);
|
||||
return "file://" + abs.generic_string();
|
||||
}
|
||||
}
|
||||
|
||||
bool isRepository(const std::string& source)
|
||||
{
|
||||
if (isUrl(source))
|
||||
return true;
|
||||
std::error_code ec;
|
||||
sfs::path p(source);
|
||||
return sfs::is_directory(p / ".git", ec) ||
|
||||
(sfs::is_regular_file(p / "HEAD", ec) && sfs::is_directory(p / "objects", ec));
|
||||
}
|
||||
|
||||
std::vector<Tag> versionTags(const std::string& repository)
|
||||
{
|
||||
requireGit();
|
||||
proc::Options options;
|
||||
options.capture = true;
|
||||
proc::Result r =
|
||||
proc::run({"git", "ls-remote", "--tags", "--refs", cloneable(repository)}, options);
|
||||
if (r.exitCode != 0)
|
||||
throw Error("cannot read the tags of '" + repository + "' (git ls-remote failed)");
|
||||
|
||||
std::vector<Tag> tags;
|
||||
std::set<std::string> seen;
|
||||
for (const std::string& line : text::splitLines(r.output))
|
||||
{
|
||||
std::size_t pos = line.find("refs/tags/");
|
||||
if (pos == std::string::npos)
|
||||
continue;
|
||||
std::string name = text::trim(line.substr(pos + 10));
|
||||
std::optional<Version> v = parseVersion(name);
|
||||
if (!v || !seen.insert(name).second)
|
||||
continue;
|
||||
tags.push_back({name, *v});
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
void exportTree(const std::string& repository, const std::string& tag,
|
||||
const sfs::path& destination)
|
||||
{
|
||||
requireGit();
|
||||
sfs::path tmp = fs::makeTempDir("breeze-git");
|
||||
sfs::path work = tmp / "repo";
|
||||
std::vector<std::string> argv{"git", "clone", "--quiet", "--depth", "1"};
|
||||
if (!tag.empty())
|
||||
{
|
||||
argv.push_back("--branch");
|
||||
argv.push_back(tag);
|
||||
}
|
||||
argv.push_back(cloneable(repository));
|
||||
argv.push_back(work.string());
|
||||
proc::Options options;
|
||||
options.capture = true;
|
||||
options.mergeStderr = true;
|
||||
proc::Result r = proc::run(argv, options);
|
||||
if (r.exitCode != 0)
|
||||
{
|
||||
fs::removeTree(tmp);
|
||||
throw Error("cannot fetch " +
|
||||
(tag.empty() ? std::string("the default branch") : "'" + tag + "'") +
|
||||
" of '" + repository + "':\n" + text::trim(r.output));
|
||||
}
|
||||
fs::removeTree(work / ".git");
|
||||
std::error_code ec;
|
||||
sfs::create_directories(destination.parent_path(), ec);
|
||||
sfs::rename(work, destination, ec);
|
||||
if (ec)
|
||||
{
|
||||
// Different file systems: copy instead.
|
||||
fs::copyTree(work, destination);
|
||||
}
|
||||
fs::removeTree(tmp);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
#include "breeze/Modules.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <set>
|
||||
|
||||
#include "breeze/CMakeText.hpp"
|
||||
#include "breeze/Error.hpp"
|
||||
#include "breeze/Fs.hpp"
|
||||
#include "breeze/Text.hpp"
|
||||
|
||||
namespace breeze
|
||||
{
|
||||
|
||||
namespace sfs = std::filesystem;
|
||||
|
||||
std::string kindName(ModuleKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case ModuleKind::Exec:
|
||||
return "exec";
|
||||
case ModuleKind::Lib:
|
||||
return "lib";
|
||||
case ModuleKind::Obj:
|
||||
return "obj";
|
||||
case ModuleKind::Test:
|
||||
return "test";
|
||||
case ModuleKind::Example:
|
||||
return "example";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
struct MacroInfo
|
||||
{
|
||||
const char* name;
|
||||
ModuleKind kind;
|
||||
LibType libType;
|
||||
};
|
||||
|
||||
const std::array<MacroInfo, 7> Macros = {{
|
||||
{"add_lib_module", ModuleKind::Lib, LibType::Unspecified},
|
||||
{"add_static_lib_module", ModuleKind::Lib, LibType::Static},
|
||||
{"add_shared_lib_module", ModuleKind::Lib, LibType::Shared},
|
||||
{"add_objects_module", ModuleKind::Obj, LibType::Unspecified},
|
||||
{"add_exe_module", ModuleKind::Exec, LibType::Unspecified},
|
||||
{"add_test_module", ModuleKind::Test, LibType::Unspecified},
|
||||
{"add_example_module", ModuleKind::Example, LibType::Unspecified},
|
||||
}};
|
||||
|
||||
const MacroInfo* macroInfo(const std::string& name)
|
||||
{
|
||||
std::string lowered = text::lower(name);
|
||||
for (const MacroInfo& m : Macros)
|
||||
{
|
||||
if (lowered == m.name)
|
||||
return &m;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool isModuleCall(const std::string& name)
|
||||
{
|
||||
return macroInfo(name) != nullptr;
|
||||
}
|
||||
|
||||
bool isKeyword(const std::string& s)
|
||||
{
|
||||
return s == "OUTPUT_NAME" || s == "TYPE" || s == "DEPENDS" || s == "EXTRA_SOURCES" ||
|
||||
s == "NO_DOCS";
|
||||
}
|
||||
|
||||
Module toModule(const cmake::Call& call, const sfs::path& dir, ModuleKind /*category*/)
|
||||
{
|
||||
const MacroInfo* info = macroInfo(call.name);
|
||||
Module m;
|
||||
m.macro = text::lower(call.name);
|
||||
m.dir = dir;
|
||||
m.dirName = dir.filename().string();
|
||||
m.kind = info->kind;
|
||||
m.libType = info->libType;
|
||||
std::string mode;
|
||||
bool haveId = false;
|
||||
for (const cmake::Token& t : call.args)
|
||||
{
|
||||
if (!t.quoted && isKeyword(t.text))
|
||||
{
|
||||
mode = t.text;
|
||||
continue;
|
||||
}
|
||||
if (!haveId)
|
||||
{
|
||||
m.id = t.text;
|
||||
haveId = true;
|
||||
}
|
||||
else if (mode == "OUTPUT_NAME")
|
||||
{
|
||||
m.outputName = t.text;
|
||||
}
|
||||
else if (mode == "TYPE")
|
||||
{
|
||||
std::string v = text::lower(t.text);
|
||||
m.libType = v == "shared" ? LibType::Shared
|
||||
: v == "static" ? LibType::Static
|
||||
: m.libType;
|
||||
}
|
||||
else if (mode == "DEPENDS")
|
||||
{
|
||||
m.deps.push_back(t.text);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
void scanDirectory(const sfs::path& container, ModuleKind category,
|
||||
std::vector<Module>& out, bool skipContainers)
|
||||
{
|
||||
std::error_code ec;
|
||||
if (!sfs::is_directory(container, ec))
|
||||
return;
|
||||
std::vector<sfs::path> dirs;
|
||||
for (const sfs::directory_entry& e : sfs::directory_iterator(container, ec))
|
||||
{
|
||||
if (!e.is_directory())
|
||||
continue;
|
||||
std::string name = e.path().filename().string();
|
||||
if (skipContainers && (name == "tests" || name == "examples") &&
|
||||
!sfs::exists(e.path() / "CMakeLists.txt"))
|
||||
continue;
|
||||
if (sfs::is_regular_file(e.path() / "CMakeLists.txt", ec))
|
||||
dirs.push_back(e.path());
|
||||
}
|
||||
std::sort(dirs.begin(), dirs.end());
|
||||
for (const sfs::path& dir : dirs)
|
||||
{
|
||||
std::optional<std::string> text = fs::tryReadText(dir / "CMakeLists.txt");
|
||||
if (!text)
|
||||
continue;
|
||||
for (const cmake::Call& call : cmake::findCalls(*text, isModuleCall))
|
||||
{
|
||||
if (call.args.empty())
|
||||
continue;
|
||||
out.push_back(toModule(call, dir, category));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sfs::path moduleDirectory(const Project& project, ModuleKind kind, const std::string& name)
|
||||
{
|
||||
sfs::path source = project.root / "source";
|
||||
if (kind == ModuleKind::Test)
|
||||
return source / "tests" / name;
|
||||
if (kind == ModuleKind::Example)
|
||||
return source / "examples" / name;
|
||||
return source / name;
|
||||
}
|
||||
|
||||
// <name in snake case> plus the kind's suffix, unless the name already ends that way
|
||||
// (a module called UtilTest is util_test, not util_test_test).
|
||||
std::string defaultId(ModuleKind kind, const std::string& name)
|
||||
{
|
||||
std::string base = text::snakeCase(name);
|
||||
std::string suffix = "_" + kindName(kind);
|
||||
if (kind == ModuleKind::Exec)
|
||||
suffix = "_exe";
|
||||
return text::endsWith(base, suffix) ? base : base + suffix;
|
||||
}
|
||||
|
||||
std::string applyTemplate(std::string t, const std::string& name, const std::string& snake)
|
||||
{
|
||||
t = text::replaceAll(std::move(t), "@NAME@", name);
|
||||
t = text::replaceAll(std::move(t), "@SNAKE@", snake);
|
||||
return t;
|
||||
}
|
||||
|
||||
const char* const MainCpp = R"(#include <iostream>
|
||||
|
||||
int main()
|
||||
{
|
||||
std::cout << "Hello from @NAME@\n";
|
||||
return 0;
|
||||
}
|
||||
)";
|
||||
|
||||
const char* const LibHeader = R"(#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace @SNAKE@
|
||||
{
|
||||
|
||||
/// A placeholder: replace it with the module's real interface.
|
||||
std::string describe();
|
||||
|
||||
}
|
||||
)";
|
||||
|
||||
const char* const LibSource = R"(#include "@SNAKE@/@NAME@.hpp"
|
||||
|
||||
namespace @SNAKE@
|
||||
{
|
||||
|
||||
std::string describe()
|
||||
{
|
||||
return "@NAME@";
|
||||
}
|
||||
|
||||
}
|
||||
)";
|
||||
|
||||
const char* const TestMain =
|
||||
R"(// A test is an executable that exits with 0 when everything holds; ctest runs it.
|
||||
int main()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
)";
|
||||
}
|
||||
|
||||
std::vector<Module> scanModules(const Project& project)
|
||||
{
|
||||
std::vector<Module> modules;
|
||||
sfs::path source = project.root / "source";
|
||||
scanDirectory(source, ModuleKind::Exec, modules, true);
|
||||
scanDirectory(source / "tests", ModuleKind::Test, modules, false);
|
||||
scanDirectory(source / "examples", ModuleKind::Example, modules, false);
|
||||
return modules;
|
||||
}
|
||||
|
||||
const Module* findModule(const std::vector<Module>& modules, const std::string& idOrDir)
|
||||
{
|
||||
for (const Module& m : modules)
|
||||
{
|
||||
if (m.id == idOrDir)
|
||||
return &m;
|
||||
}
|
||||
for (const Module& m : modules)
|
||||
{
|
||||
if (m.dirName == idOrDir)
|
||||
return &m;
|
||||
}
|
||||
// A directory name typed in another case, when only one module could mean it.
|
||||
const Module* found = nullptr;
|
||||
for (const Module& m : modules)
|
||||
{
|
||||
if (text::lower(m.dirName) == text::lower(idOrDir))
|
||||
{
|
||||
if (found != nullptr)
|
||||
return nullptr;
|
||||
found = &m;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
const Module& require(const std::vector<Module>& modules, const std::string& idOrDir)
|
||||
{
|
||||
if (const Module* m = findModule(modules, idOrDir))
|
||||
return *m;
|
||||
throw Error("there is no module '" + idOrDir + "' (see `breeze ls`)");
|
||||
}
|
||||
}
|
||||
|
||||
Module addModule(const Project& project, const NewModule& request)
|
||||
{
|
||||
if (!text::isName(request.name))
|
||||
throw Error("'" + request.name +
|
||||
"' is not a valid module name: it starts with a letter and has only "
|
||||
"letters, digits and '_' (it becomes a directory and a C++ namespace)");
|
||||
if (request.kind == ModuleKind::Obj && !project.sdk.supportsObjects)
|
||||
throw Error("this project's SDK (" + project.sdk.describe() +
|
||||
") has no objects modules. Move to an SDK that "
|
||||
"has them with " +
|
||||
(project.sdk.layout == SdkLayout::Legacy ? "`breeze sdk migrate <version>`"
|
||||
: "`breeze sdk upgrade`"));
|
||||
if (request.kind == ModuleKind::Lib && request.libType == LibType::Unspecified)
|
||||
throw Error("a library is either --static or --shared; say which");
|
||||
|
||||
std::string id = request.id ? *request.id : defaultId(request.kind, request.name);
|
||||
if (!text::isTargetId(id))
|
||||
throw Error("'" + id +
|
||||
"' is not a valid module id (letters, digits, '_', '.', '+', '-')");
|
||||
|
||||
std::vector<Module> existing = scanModules(project);
|
||||
for (const Module& m : existing)
|
||||
{
|
||||
if (m.id == id)
|
||||
throw Error("the id '" + id + "' is already taken by the module in source/" +
|
||||
m.dirName + (m.kind == ModuleKind::Test ? " (tests)" : ""));
|
||||
}
|
||||
|
||||
sfs::path dir = moduleDirectory(project, request.kind, request.name);
|
||||
if (sfs::exists(dir))
|
||||
throw Error("'" + fs::generic(sfs::relative(dir, project.root)) + "' already exists");
|
||||
|
||||
std::vector<std::string> deps;
|
||||
for (const std::string& d : request.deps)
|
||||
{
|
||||
if (d == id || d == request.name)
|
||||
throw Error("a module can't depend on itself ('" + d + "')");
|
||||
const Module* dep = findModule(existing, d);
|
||||
if (dep != nullptr)
|
||||
{
|
||||
if (dep->kind == ModuleKind::Exec || dep->kind == ModuleKind::Test ||
|
||||
dep->kind == ModuleKind::Example)
|
||||
throw Error("'" + d + "' is " +
|
||||
(dep->kind == ModuleKind::Exec || dep->kind == ModuleKind::Example
|
||||
? "an "
|
||||
: "a ") +
|
||||
kindName(dep->kind) +
|
||||
" module: only libraries and objects modules can be depended on");
|
||||
deps.push_back(dep->id);
|
||||
}
|
||||
else if (text::isTargetId(d))
|
||||
{
|
||||
deps.push_back(d); // a target from elsewhere: a dependency, a system library
|
||||
}
|
||||
else
|
||||
{
|
||||
throw Error("'" + d + "' is not a module of this project or a valid target name");
|
||||
}
|
||||
}
|
||||
|
||||
std::string snake = text::snakeCase(request.name);
|
||||
std::string call;
|
||||
switch (request.kind)
|
||||
{
|
||||
case ModuleKind::Exec:
|
||||
call = "add_exe_module(" + id + " OUTPUT_NAME " + cmake::quote(snake);
|
||||
break;
|
||||
case ModuleKind::Lib:
|
||||
call = "add_lib_module(" + id + " TYPE " +
|
||||
(request.libType == LibType::Shared ? "SHARED" : "STATIC") +
|
||||
" OUTPUT_NAME " + cmake::quote(snake);
|
||||
break;
|
||||
case ModuleKind::Obj:
|
||||
call = "add_objects_module(" + id + " OUTPUT_NAME " + cmake::quote(snake);
|
||||
break;
|
||||
case ModuleKind::Test:
|
||||
call = "add_test_module(" + id;
|
||||
break;
|
||||
case ModuleKind::Example:
|
||||
call = "add_example_module(" + id;
|
||||
break;
|
||||
}
|
||||
if (!deps.empty())
|
||||
call += " DEPENDS " + text::join(deps, " ");
|
||||
call += ")\n";
|
||||
|
||||
const char* comment = nullptr;
|
||||
switch (request.kind)
|
||||
{
|
||||
case ModuleKind::Exec:
|
||||
comment = "# An executable: every .cpp in this directory, headers in include/.\n";
|
||||
break;
|
||||
case ModuleKind::Lib:
|
||||
comment = "# A library: sources in src/, public headers in include/.\n";
|
||||
break;
|
||||
case ModuleKind::Obj:
|
||||
comment = "# An objects module: compiled to object files that whatever names it in "
|
||||
"DEPENDS takes in.\n";
|
||||
break;
|
||||
case ModuleKind::Test:
|
||||
comment = "# A test: an executable ctest runs (built with BUILD_TESTS=ON).\n";
|
||||
break;
|
||||
case ModuleKind::Example:
|
||||
comment = "# An example: an executable (built with BUILD_EXAMPLES=ON).\n";
|
||||
break;
|
||||
}
|
||||
|
||||
fs::writeText(dir / "CMakeLists.txt", std::string(comment) + call);
|
||||
if (request.kind == ModuleKind::Lib || request.kind == ModuleKind::Obj)
|
||||
{
|
||||
fs::writeText(dir / "include" / snake / (request.name + ".hpp"),
|
||||
applyTemplate(LibHeader, request.name, snake));
|
||||
fs::writeText(dir / "src" / (request.name + ".cpp"),
|
||||
applyTemplate(LibSource, request.name, snake));
|
||||
}
|
||||
else if (request.kind == ModuleKind::Test)
|
||||
{
|
||||
fs::writeText(dir / "main.cpp", TestMain);
|
||||
}
|
||||
else
|
||||
{
|
||||
fs::writeText(dir / "main.cpp", applyTemplate(MainCpp, request.name, snake));
|
||||
}
|
||||
|
||||
std::vector<Module> now = scanModules(project);
|
||||
const Module* created = findModule(now, id);
|
||||
if (created == nullptr)
|
||||
throw Error("internal error: the new module '" + id +
|
||||
"' was not found after writing it");
|
||||
return *created;
|
||||
}
|
||||
|
||||
std::vector<std::string> dependentsOf(const std::vector<Module>& modules, const Module& module,
|
||||
bool recursive)
|
||||
{
|
||||
std::vector<std::string> out;
|
||||
std::set<std::string> seen;
|
||||
std::vector<std::string> queue{module.id};
|
||||
while (!queue.empty())
|
||||
{
|
||||
std::string current = queue.front();
|
||||
queue.erase(queue.begin());
|
||||
for (const Module& m : modules)
|
||||
{
|
||||
if (std::find(m.deps.begin(), m.deps.end(), current) == m.deps.end())
|
||||
continue;
|
||||
if (seen.insert(m.id).second)
|
||||
{
|
||||
out.push_back(m.id);
|
||||
if (recursive)
|
||||
queue.push_back(m.id);
|
||||
}
|
||||
}
|
||||
if (!recursive)
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::string> dependenciesOf(const std::vector<Module>& modules,
|
||||
const Module& module, bool recursive)
|
||||
{
|
||||
std::vector<std::string> out;
|
||||
std::set<std::string> seen{module.id};
|
||||
std::vector<std::string> queue{module.id};
|
||||
while (!queue.empty())
|
||||
{
|
||||
std::string current = queue.front();
|
||||
queue.erase(queue.begin());
|
||||
const Module* m = findModule(modules, current);
|
||||
if (m == nullptr)
|
||||
continue;
|
||||
for (const std::string& d : m->deps)
|
||||
{
|
||||
if (seen.insert(d).second)
|
||||
{
|
||||
out.push_back(d);
|
||||
if (recursive)
|
||||
queue.push_back(d);
|
||||
}
|
||||
}
|
||||
if (!recursive)
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void deleteModule(const Project& project, const std::string& idOrDir)
|
||||
{
|
||||
std::vector<Module> modules = scanModules(project);
|
||||
const Module& m = require(modules, idOrDir);
|
||||
std::vector<std::string> users = dependentsOf(modules, m, false);
|
||||
if (!users.empty())
|
||||
throw Error("'" + m.id + "' can't be deleted: " + text::join(users, ", ") +
|
||||
(users.size() == 1 ? " depends" : " depend") + " on it");
|
||||
fs::removeTree(m.dir);
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
struct Edit
|
||||
{
|
||||
cmake::Token token;
|
||||
std::string value;
|
||||
};
|
||||
|
||||
// Applies replacements from the end of the file backwards so earlier offsets stay valid.
|
||||
void rewrite(const sfs::path& file, std::string text, std::vector<Edit> edits)
|
||||
{
|
||||
std::sort(edits.begin(), edits.end(),
|
||||
[](const Edit& a, const Edit& b) { return a.token.begin > b.token.begin; });
|
||||
for (const Edit& e : edits)
|
||||
cmake::replaceToken(text, e.token, e.value);
|
||||
fs::writeText(file, text);
|
||||
}
|
||||
}
|
||||
|
||||
RenameResult renameModule(const Project& project, const std::string& idOrDir,
|
||||
const std::optional<std::string>& newName,
|
||||
const std::optional<std::string>& newId)
|
||||
{
|
||||
if (!newName && !newId)
|
||||
throw Error("nothing to rename: give --name and/or --id");
|
||||
|
||||
std::vector<Module> modules = scanModules(project);
|
||||
const Module& m = require(modules, idOrDir);
|
||||
|
||||
RenameResult result;
|
||||
result.oldId = m.id;
|
||||
result.newId = newId ? *newId : m.id;
|
||||
result.oldDir = m.dirName;
|
||||
result.newDir = newName ? *newName : m.dirName;
|
||||
|
||||
if (newName && !text::isName(*newName))
|
||||
throw Error("'" + *newName + "' is not a valid module name");
|
||||
if (newId && !text::isTargetId(*newId))
|
||||
throw Error("'" + *newId + "' is not a valid module id");
|
||||
if (result.newId == result.oldId && result.newDir == result.oldDir)
|
||||
throw Error("the module already has that name and id");
|
||||
if (result.newId != result.oldId)
|
||||
{
|
||||
for (const Module& other : modules)
|
||||
{
|
||||
if (other.id == result.newId)
|
||||
throw Error("the id '" + result.newId + "' is already taken");
|
||||
}
|
||||
}
|
||||
sfs::path newDirPath = m.dir.parent_path() / result.newDir;
|
||||
if (result.newDir != result.oldDir && sfs::exists(newDirPath))
|
||||
throw Error("'" + fs::generic(sfs::relative(newDirPath, project.root)) +
|
||||
"' already exists");
|
||||
|
||||
// Everything is rewritten at the files' present locations first; the directory moves last.
|
||||
std::set<sfs::path> files;
|
||||
for (const Module& other : modules)
|
||||
files.insert(other.dir / "CMakeLists.txt");
|
||||
|
||||
for (const sfs::path& file : files)
|
||||
{
|
||||
std::string text = fs::readText(file);
|
||||
std::vector<Edit> edits;
|
||||
bool isOwn = file == m.dir / "CMakeLists.txt";
|
||||
bool touchedDependency = false;
|
||||
for (const cmake::Call& call : cmake::findCalls(text, isModuleCall))
|
||||
{
|
||||
if (call.args.empty())
|
||||
continue;
|
||||
if (isOwn && result.newId != result.oldId && call.args.front().text == result.oldId)
|
||||
edits.push_back({call.args.front(), result.newId});
|
||||
std::string mode;
|
||||
bool first = true;
|
||||
for (const cmake::Token& t : call.args)
|
||||
{
|
||||
if (!t.quoted && isKeyword(t.text))
|
||||
{
|
||||
mode = t.text;
|
||||
continue;
|
||||
}
|
||||
if (first)
|
||||
{
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
if (mode == "DEPENDS" && result.newId != result.oldId && t.text == result.oldId)
|
||||
{
|
||||
edits.push_back({t, result.newId});
|
||||
touchedDependency = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (edits.empty())
|
||||
continue;
|
||||
rewrite(file, text, std::move(edits));
|
||||
if (touchedDependency)
|
||||
{
|
||||
std::string dirName = file.parent_path().filename().string();
|
||||
if (!isOwn)
|
||||
result.updatedDependents.push_back(dirName);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.newDir != result.oldDir)
|
||||
{
|
||||
std::error_code ec;
|
||||
sfs::rename(m.dir, newDirPath, ec);
|
||||
if (ec)
|
||||
throw Error("cannot rename '" + fs::generic(m.dir) + "': " + ec.message());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
#include "breeze/Presets.hpp"
|
||||
|
||||
#include "breeze/Error.hpp"
|
||||
#include "breeze/Fs.hpp"
|
||||
#include "breeze/Process.hpp"
|
||||
#include "breeze/Text.hpp"
|
||||
|
||||
namespace breeze
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
Preset makePreset(const std::string& name, const std::string& description)
|
||||
{
|
||||
Preset p;
|
||||
p.name = name;
|
||||
p.description = description;
|
||||
std::size_t first = name.find('-');
|
||||
std::size_t second = first == std::string::npos ? first : name.find('-', first + 1);
|
||||
if (second != std::string::npos)
|
||||
{
|
||||
p.platform = name.substr(0, first);
|
||||
p.arch = name.substr(first + 1, second - first - 1);
|
||||
p.compiler = name.substr(second + 1);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Preset> listPresets(const Project& project)
|
||||
{
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(project.root / "CMakePresets.json", ec) &&
|
||||
!std::filesystem::exists(project.root / "CMakeUserPresets.json", ec))
|
||||
return {}; // an SDK from before presets
|
||||
proc::Options options;
|
||||
options.cwd = project.root;
|
||||
options.capture = true;
|
||||
proc::Result r = proc::run({"cmake", "--list-presets"}, options);
|
||||
if (r.exitCode != 0)
|
||||
throw Error("`cmake --list-presets` failed in '" + fs::generic(project.root) +
|
||||
"' -- is CMake installed, and is CMakePresets.json valid?");
|
||||
|
||||
std::vector<Preset> presets;
|
||||
for (const std::string& raw : text::splitLines(r.output))
|
||||
{
|
||||
std::string line = text::trim(raw);
|
||||
if (line.size() < 3 || line[0] != '"')
|
||||
continue;
|
||||
std::size_t close = line.find('"', 1);
|
||||
if (close == std::string::npos)
|
||||
continue;
|
||||
std::string name = line.substr(1, close - 1);
|
||||
std::string description;
|
||||
if (std::size_t dash = line.find('-', close); dash != std::string::npos)
|
||||
description = text::trim(line.substr(dash + 1));
|
||||
presets.push_back(makePreset(name, description));
|
||||
}
|
||||
return presets;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
bool same(const std::string& a, const std::string& b)
|
||||
{
|
||||
return text::normalized(a) == text::normalized(b);
|
||||
}
|
||||
|
||||
std::string describeChoices(const std::vector<Preset>& presets)
|
||||
{
|
||||
std::string out;
|
||||
for (const Preset& p : presets)
|
||||
out += " " + p.name + "\n";
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<std::string> resolvePreset(const Project& project, const Defaults& defaults,
|
||||
const PresetQuery& query, std::string* note)
|
||||
{
|
||||
std::vector<Preset> presets = listPresets(project);
|
||||
if (presets.empty())
|
||||
{
|
||||
if (query.preset || query.platform || query.arch || query.compiler)
|
||||
throw Error("this project's SDK (" + project.sdk.describe() +
|
||||
") has no CMake presets, so there is nothing to choose between. "
|
||||
"Move to a newer SDK with `breeze sdk migrate <version>`");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
auto byName = [&](const std::string& name) -> const Preset*
|
||||
{
|
||||
for (const Preset& p : presets)
|
||||
{
|
||||
if (same(p.name, name))
|
||||
return &p;
|
||||
}
|
||||
return nullptr;
|
||||
};
|
||||
|
||||
if (query.preset)
|
||||
{
|
||||
if (const Preset* p = byName(*query.preset))
|
||||
return p->name;
|
||||
throw Error("'" + *query.preset +
|
||||
"' is not a preset this project has for this machine. "
|
||||
"Choose one of:\n" +
|
||||
describeChoices(presets));
|
||||
}
|
||||
|
||||
bool anyGiven = query.platform || query.arch || query.compiler;
|
||||
std::optional<std::string> dPlatform = defaults.build("platform");
|
||||
std::optional<std::string> dArch = defaults.build("arch");
|
||||
std::optional<std::string> dCompiler = defaults.build("compiler");
|
||||
|
||||
if (!anyGiven)
|
||||
{
|
||||
if (std::optional<std::string> dp = defaults.build("preset"))
|
||||
{
|
||||
if (const Preset* p = byName(*dp))
|
||||
return p->name;
|
||||
throw Error(
|
||||
"the project's default preset '" + *dp +
|
||||
"' does not exist for this machine. "
|
||||
"Choose another with `breeze defaults build preset --set <name>`, from:\n" +
|
||||
describeChoices(presets));
|
||||
}
|
||||
if (!dPlatform && !dArch && !dCompiler)
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string platform = query.platform ? *query.platform
|
||||
: dPlatform ? *dPlatform
|
||||
: fs::hostPlatform();
|
||||
std::string arch = query.arch ? *query.arch : dArch ? *dArch : fs::hostArch();
|
||||
std::optional<std::string> compiler = query.compiler ? query.compiler : dCompiler;
|
||||
|
||||
std::vector<Preset> matches;
|
||||
for (const Preset& p : presets)
|
||||
{
|
||||
if (same(p.platform, platform) && same(p.arch, arch) &&
|
||||
(!compiler || same(p.compiler, *compiler)))
|
||||
matches.push_back(p);
|
||||
}
|
||||
if (matches.empty())
|
||||
{
|
||||
std::vector<Preset> sameTarget;
|
||||
for (const Preset& p : presets)
|
||||
{
|
||||
if (same(p.platform, platform) && same(p.arch, arch))
|
||||
sameTarget.push_back(p);
|
||||
}
|
||||
throw Error("this project has no preset for " + platform + "-" + arch +
|
||||
(compiler ? "-" + *compiler : std::string()) + " on this machine. " +
|
||||
(sameTarget.empty() ? "Available:\n" + describeChoices(presets)
|
||||
: "Presets for " + platform + "-" + arch + ":\n" +
|
||||
describeChoices(sameTarget)));
|
||||
}
|
||||
if (matches.size() > 1 && note != nullptr)
|
||||
{
|
||||
*note = "no compiler given: using '" + matches.front().name + "' (the others are: ";
|
||||
for (std::size_t i = 1; i < matches.size(); ++i)
|
||||
*note += (i > 1 ? ", " : "") + matches[i].name;
|
||||
*note += "). Pick one with -C or `breeze defaults build compiler --set <name>`.";
|
||||
}
|
||||
return matches.front().name;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
#include "breeze/Process.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
#include <array>
|
||||
#include <iostream>
|
||||
|
||||
#include "breeze/Error.hpp"
|
||||
#include "breeze/Fs.hpp"
|
||||
#include "breeze/Text.hpp"
|
||||
|
||||
#if defined(_WIN32)
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <io.h>
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <fcntl.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace breeze::proc
|
||||
{
|
||||
|
||||
#if defined(_WIN32)
|
||||
|
||||
namespace
|
||||
{
|
||||
std::wstring widen(const std::string& s)
|
||||
{
|
||||
if (s.empty())
|
||||
return {};
|
||||
int n =
|
||||
MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), nullptr, 0);
|
||||
std::wstring w(static_cast<std::size_t>(n), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), w.data(), n);
|
||||
return w;
|
||||
}
|
||||
|
||||
// The Microsoft C runtime's rules for one argument: quote when it has spaces or quotes,
|
||||
// double the backslashes that precede a quote.
|
||||
std::wstring quoteArg(const std::string& arg)
|
||||
{
|
||||
std::wstring w = widen(arg);
|
||||
if (!w.empty() && w.find_first_of(L" \t\n\v\"") == std::wstring::npos)
|
||||
return w;
|
||||
std::wstring out = L"\"";
|
||||
std::size_t backslashes = 0;
|
||||
for (wchar_t c : w)
|
||||
{
|
||||
if (c == L'\\')
|
||||
{
|
||||
++backslashes;
|
||||
continue;
|
||||
}
|
||||
if (c == L'"')
|
||||
out.append(backslashes * 2 + 1, L'\\');
|
||||
else
|
||||
out.append(backslashes, L'\\');
|
||||
backslashes = 0;
|
||||
out += c;
|
||||
}
|
||||
out.append(backslashes * 2, L'\\');
|
||||
out += L'"';
|
||||
return out;
|
||||
}
|
||||
|
||||
Result runCommandLine(std::wstring commandLine, const Options& options)
|
||||
{
|
||||
std::vector<std::pair<std::wstring, std::wstring>> saved;
|
||||
for (const auto& [key, value] : options.env)
|
||||
{
|
||||
std::wstring wk = widen(key);
|
||||
wchar_t previous[32768];
|
||||
DWORD n = GetEnvironmentVariableW(wk.c_str(), previous, 32768);
|
||||
saved.emplace_back(wk,
|
||||
n > 0 ? std::wstring(previous, n) : std::wstring(L"\x01unset"));
|
||||
SetEnvironmentVariableW(wk.c_str(), widen(value).c_str());
|
||||
}
|
||||
|
||||
SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE};
|
||||
HANDLE readEnd = nullptr;
|
||||
HANDLE writeEnd = nullptr;
|
||||
if (options.capture)
|
||||
{
|
||||
CreatePipe(&readEnd, &writeEnd, &sa, 0);
|
||||
SetHandleInformation(readEnd, HANDLE_FLAG_INHERIT, 0);
|
||||
}
|
||||
HANDLE nul = nullptr;
|
||||
if (options.silent)
|
||||
nul = CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_WRITE, &sa, OPEN_EXISTING, 0,
|
||||
nullptr);
|
||||
|
||||
STARTUPINFOW si{};
|
||||
si.cb = sizeof(si);
|
||||
si.dwFlags = STARTF_USESTDHANDLES;
|
||||
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
|
||||
si.hStdOutput = options.capture
|
||||
? writeEnd
|
||||
: (options.silent ? nul : GetStdHandle(STD_OUTPUT_HANDLE));
|
||||
si.hStdError = options.capture && options.mergeStderr
|
||||
? writeEnd
|
||||
: (options.silent ? nul : GetStdHandle(STD_ERROR_HANDLE));
|
||||
PROCESS_INFORMATION pi{};
|
||||
std::wstring cwd = options.cwd.empty() ? std::wstring() : options.cwd.wstring();
|
||||
BOOL ok = CreateProcessW(nullptr, commandLine.data(), nullptr, nullptr, TRUE, 0,
|
||||
nullptr, cwd.empty() ? nullptr : cwd.c_str(), &si, &pi);
|
||||
if (writeEnd)
|
||||
CloseHandle(writeEnd);
|
||||
if (nul)
|
||||
CloseHandle(nul);
|
||||
|
||||
Result result;
|
||||
if (!ok)
|
||||
{
|
||||
if (readEnd)
|
||||
CloseHandle(readEnd);
|
||||
result.exitCode = 127;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (readEnd)
|
||||
{
|
||||
std::array<char, 4096> buffer{};
|
||||
DWORD got = 0;
|
||||
while (ReadFile(readEnd, buffer.data(), static_cast<DWORD>(buffer.size()), &got,
|
||||
nullptr) &&
|
||||
got > 0)
|
||||
result.output.append(buffer.data(), got);
|
||||
CloseHandle(readEnd);
|
||||
}
|
||||
WaitForSingleObject(pi.hProcess, INFINITE);
|
||||
DWORD code = 0;
|
||||
GetExitCodeProcess(pi.hProcess, &code);
|
||||
result.exitCode = static_cast<int>(code);
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
}
|
||||
|
||||
for (const auto& [key, value] : saved)
|
||||
SetEnvironmentVariableW(key.c_str(),
|
||||
value == L"\x01unset" ? nullptr : value.c_str());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
Result run(const std::vector<std::string>& argv, const Options& options)
|
||||
{
|
||||
if (argv.empty())
|
||||
throw Error("internal error: nothing to run");
|
||||
std::wstring line;
|
||||
for (std::size_t i = 0; i < argv.size(); ++i)
|
||||
{
|
||||
if (i != 0)
|
||||
line += L' ';
|
||||
line += quoteArg(argv[i]);
|
||||
}
|
||||
return runCommandLine(std::move(line), options);
|
||||
}
|
||||
|
||||
Result runScript(const std::filesystem::path& script, const std::vector<std::string>& args,
|
||||
const Options& options)
|
||||
{
|
||||
// cmd.exe strips the outer quotes of what follows /c, so the whole command is wrapped once
|
||||
// more.
|
||||
std::wstring inner = quoteArg(script.string());
|
||||
for (const std::string& a : args)
|
||||
inner += L' ' + quoteArg(a);
|
||||
return runCommandLine(L"cmd.exe /d /s /c \"" + inner + L"\"", options);
|
||||
}
|
||||
|
||||
void execReplace(const std::vector<std::string>& argv)
|
||||
{
|
||||
Result r = run(argv);
|
||||
if (r.exitCode == 127)
|
||||
throw Error("cannot start '" + argv.front() + "'");
|
||||
std::exit(r.exitCode);
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> findInPath(const std::string& program)
|
||||
{
|
||||
const char* pathEnv = std::getenv("PATH");
|
||||
if (pathEnv == nullptr)
|
||||
return std::nullopt;
|
||||
static const std::vector<std::string> extensions = {"", ".exe", ".bat", ".cmd", ".com"};
|
||||
for (const std::string& dir : text::split(pathEnv, ';'))
|
||||
{
|
||||
for (const std::string& ext : extensions)
|
||||
{
|
||||
std::filesystem::path candidate = std::filesystem::path(dir) / (program + ext);
|
||||
if (std::filesystem::is_regular_file(candidate))
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void setEnvironment(const std::string& name, const std::string& value)
|
||||
{
|
||||
_putenv_s(name.c_str(), value.c_str());
|
||||
}
|
||||
|
||||
bool stdinIsTerminal()
|
||||
{
|
||||
return _isatty(_fileno(stdin)) != 0;
|
||||
}
|
||||
|
||||
bool stdoutIsTerminal()
|
||||
{
|
||||
return _isatty(_fileno(stdout)) != 0;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
Result run(const std::vector<std::string>& argv, const Options& options)
|
||||
{
|
||||
if (argv.empty())
|
||||
throw Error("internal error: nothing to run");
|
||||
|
||||
std::cout.flush();
|
||||
std::cerr.flush();
|
||||
std::fflush(nullptr);
|
||||
|
||||
std::array<int, 2> pipeFds = {-1, -1};
|
||||
if (options.capture && ::pipe(pipeFds.data()) != 0)
|
||||
throw Error("cannot create a pipe");
|
||||
|
||||
pid_t pid = ::fork();
|
||||
if (pid < 0)
|
||||
throw Error("cannot create a process");
|
||||
|
||||
if (pid == 0)
|
||||
{
|
||||
if (!options.cwd.empty() && ::chdir(options.cwd.c_str()) != 0)
|
||||
{
|
||||
std::fprintf(stderr, "breeze: cannot enter '%s'\n", options.cwd.c_str());
|
||||
::_exit(127);
|
||||
}
|
||||
for (const auto& [key, value] : options.env)
|
||||
::setenv(key.c_str(), value.c_str(), 1);
|
||||
if (options.capture)
|
||||
{
|
||||
::dup2(pipeFds[1], STDOUT_FILENO);
|
||||
if (options.mergeStderr)
|
||||
::dup2(pipeFds[1], STDERR_FILENO);
|
||||
::close(pipeFds[0]);
|
||||
::close(pipeFds[1]);
|
||||
}
|
||||
else if (options.silent)
|
||||
{
|
||||
int nul = ::open("/dev/null", O_WRONLY);
|
||||
::dup2(nul, STDOUT_FILENO);
|
||||
::dup2(nul, STDERR_FILENO);
|
||||
::close(nul);
|
||||
}
|
||||
std::vector<char*> args;
|
||||
for (const std::string& a : argv)
|
||||
args.push_back(const_cast<char*>(a.c_str()));
|
||||
args.push_back(nullptr);
|
||||
::execvp(args[0], args.data());
|
||||
std::fprintf(stderr, "breeze: cannot run '%s'\n", args[0]);
|
||||
::_exit(127);
|
||||
}
|
||||
|
||||
Result result;
|
||||
if (options.capture)
|
||||
{
|
||||
::close(pipeFds[1]);
|
||||
std::array<char, 4096> buffer{};
|
||||
ssize_t got = 0;
|
||||
while ((got = ::read(pipeFds[0], buffer.data(), buffer.size())) > 0)
|
||||
result.output.append(buffer.data(), static_cast<std::size_t>(got));
|
||||
::close(pipeFds[0]);
|
||||
}
|
||||
int status = 0;
|
||||
while (::waitpid(pid, &status, 0) < 0)
|
||||
{
|
||||
if (errno != EINTR)
|
||||
throw Error("cannot wait for '" + argv.front() + "'");
|
||||
}
|
||||
if (WIFEXITED(status))
|
||||
result.exitCode = WEXITSTATUS(status);
|
||||
else if (WIFSIGNALED(status))
|
||||
result.exitCode = 128 + WTERMSIG(status);
|
||||
return result;
|
||||
}
|
||||
|
||||
Result runScript(const std::filesystem::path& script, const std::vector<std::string>& args,
|
||||
const Options& options)
|
||||
{
|
||||
std::vector<std::string> argv;
|
||||
// A project unpacked from a zip often has scripts that lost their executable bit; the SDK's
|
||||
// scripts are bash, so that is how they are run then.
|
||||
if (!fs::isExecutable(script))
|
||||
argv.push_back("bash");
|
||||
argv.push_back(script.string());
|
||||
argv.insert(argv.end(), args.begin(), args.end());
|
||||
return run(argv, options);
|
||||
}
|
||||
|
||||
void execReplace(const std::vector<std::string>& argv)
|
||||
{
|
||||
if (argv.empty())
|
||||
throw Error("internal error: nothing to run");
|
||||
std::cout.flush();
|
||||
std::cerr.flush();
|
||||
std::vector<char*> args;
|
||||
for (const std::string& a : argv)
|
||||
args.push_back(const_cast<char*>(a.c_str()));
|
||||
args.push_back(nullptr);
|
||||
::execv(args[0], args.data());
|
||||
throw Error("cannot start '" + argv.front() + "'");
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> findInPath(const std::string& program)
|
||||
{
|
||||
if (program.find('/') != std::string::npos)
|
||||
return fs::isExecutable(program) ? std::optional<std::filesystem::path>(program)
|
||||
: std::nullopt;
|
||||
const char* pathEnv = std::getenv("PATH");
|
||||
if (pathEnv == nullptr)
|
||||
return std::nullopt;
|
||||
for (const std::string& dir : text::split(pathEnv, ':'))
|
||||
{
|
||||
std::filesystem::path candidate =
|
||||
(dir.empty() ? std::filesystem::path(".") : std::filesystem::path(dir)) / program;
|
||||
if (fs::isExecutable(candidate))
|
||||
return candidate;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void setEnvironment(const std::string& name, const std::string& value)
|
||||
{
|
||||
::setenv(name.c_str(), value.c_str(), 1);
|
||||
}
|
||||
|
||||
bool stdinIsTerminal()
|
||||
{
|
||||
return ::isatty(STDIN_FILENO) != 0;
|
||||
}
|
||||
|
||||
bool stdoutIsTerminal()
|
||||
{
|
||||
return ::isatty(STDOUT_FILENO) != 0;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
#include "breeze/Project.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "breeze/CMakeText.hpp"
|
||||
#include "breeze/Error.hpp"
|
||||
#include "breeze/Fs.hpp"
|
||||
#include "breeze/Text.hpp"
|
||||
|
||||
namespace breeze
|
||||
{
|
||||
|
||||
namespace sfs = std::filesystem;
|
||||
|
||||
std::string Sdk::describe() const
|
||||
{
|
||||
if (layout == SdkLayout::Legacy)
|
||||
return "legacy (flat layout, unversioned)";
|
||||
if (version)
|
||||
return version->toString();
|
||||
return "unversioned (breeze/ layout)";
|
||||
}
|
||||
|
||||
sfs::path Project::metaDir() const
|
||||
{
|
||||
return root / ".breeze";
|
||||
}
|
||||
|
||||
sfs::path Project::defaultsFile() const
|
||||
{
|
||||
return metaDir() / "defaults.json";
|
||||
}
|
||||
|
||||
sfs::path Project::toolsDir() const
|
||||
{
|
||||
return metaDir() / "tools";
|
||||
}
|
||||
|
||||
sfs::path Project::script(const std::string& name) const
|
||||
{
|
||||
return sdk.scriptsDir / (name + (fs::hostPlatform() == "windows" ? ".bat" : ".sh"));
|
||||
}
|
||||
|
||||
std::optional<Sdk> detectSdk(const sfs::path& root)
|
||||
{
|
||||
std::error_code ec;
|
||||
if (!sfs::is_regular_file(root / "CMakeLists.txt", ec))
|
||||
return std::nullopt;
|
||||
|
||||
Sdk sdk;
|
||||
if (sfs::is_regular_file(root / "breeze" / "cmake" / "Breeze.cmake", ec) &&
|
||||
sfs::is_regular_file(root / "project" / "Configuration.cmake", ec))
|
||||
{
|
||||
sdk.layout = SdkLayout::Breeze;
|
||||
sdk.dir = root / "breeze";
|
||||
sdk.scriptsDir = sdk.dir / "scripts";
|
||||
sdk.cmakeDir = sdk.dir / "cmake";
|
||||
sdk.settingsFile = root / "project" / "Configuration.cmake";
|
||||
if (std::optional<std::string> v = fs::tryReadText(sdk.dir / "VERSION"))
|
||||
sdk.version = parseVersion(*v);
|
||||
}
|
||||
else if (sfs::is_regular_file(root / "Configuration.cmake", ec) &&
|
||||
sfs::is_regular_file(root / "cmake" / "BuildHelpers.cmake", ec))
|
||||
{
|
||||
sdk.layout = SdkLayout::Legacy;
|
||||
sdk.dir = root;
|
||||
sdk.scriptsDir = root / "scripts";
|
||||
sdk.cmakeDir = root / "cmake";
|
||||
sdk.settingsFile = root / "Configuration.cmake";
|
||||
}
|
||||
else
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
if (std::optional<std::string> helpers =
|
||||
fs::tryReadText(sdk.cmakeDir / "BuildHelpers.cmake"))
|
||||
sdk.supportsObjects = helpers->find("macro(add_objects_module") != std::string::npos;
|
||||
return sdk;
|
||||
}
|
||||
|
||||
std::optional<Project> findProject(const sfs::path& start)
|
||||
{
|
||||
std::error_code ec;
|
||||
sfs::path dir = sfs::weakly_canonical(start, ec);
|
||||
if (ec)
|
||||
dir = start;
|
||||
while (true)
|
||||
{
|
||||
if (std::optional<Sdk> sdk = detectSdk(dir))
|
||||
return Project{dir, *sdk};
|
||||
if (!dir.has_parent_path() || dir.parent_path() == dir)
|
||||
return std::nullopt;
|
||||
dir = dir.parent_path();
|
||||
}
|
||||
}
|
||||
|
||||
Project requireProject(const sfs::path& start)
|
||||
{
|
||||
if (std::optional<Project> p = findProject(start))
|
||||
return *p;
|
||||
throw Error(
|
||||
"this is not inside a breeze project (no CMakeLists.txt with a breeze SDK found in '" +
|
||||
fs::generic(start) + "' or above). Create one with: breeze project new <name>");
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
const char* variableOf(Setting which)
|
||||
{
|
||||
switch (which)
|
||||
{
|
||||
case Setting::Name:
|
||||
return "PROJECT_TITLE";
|
||||
case Setting::Version:
|
||||
return "PROJECT_VERSION_NUMBER";
|
||||
case Setting::Id:
|
||||
return "PROJECT_CODE_NAME";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
bool isCmakeVersion(const std::string& v)
|
||||
{
|
||||
std::vector<std::string> parts = text::split(v, '.');
|
||||
if (parts.empty() || parts.size() > 4)
|
||||
return false;
|
||||
return std::all_of(parts.begin(), parts.end(),
|
||||
[](const std::string& p)
|
||||
{
|
||||
return !p.empty() &&
|
||||
std::all_of(p.begin(), p.end(),
|
||||
[](char c) { return c >= '0' && c <= '9'; });
|
||||
});
|
||||
}
|
||||
|
||||
void validate(Setting which, const std::string& value)
|
||||
{
|
||||
switch (which)
|
||||
{
|
||||
case Setting::Name:
|
||||
if (!text::isName(value, true))
|
||||
throw Error(
|
||||
"'" + value +
|
||||
"' is not a valid project name: it starts with a letter and has "
|
||||
"only letters, digits, '_' and '-'");
|
||||
break;
|
||||
case Setting::Version:
|
||||
if (!isCmakeVersion(value))
|
||||
throw Error("'" + value +
|
||||
"' is not a valid project version: use numbers separated by "
|
||||
"dots, like 1.2.0 (CMake's project(VERSION))");
|
||||
break;
|
||||
case Setting::Id:
|
||||
if (!text::isName(value) || std::any_of(value.begin(), value.end(), [](char c)
|
||||
{ return c >= 'A' && c <= 'Z'; }))
|
||||
throw Error(
|
||||
"'" + value +
|
||||
"' is not a valid project id: lower-case letters, digits and '_', "
|
||||
"starting with a letter");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Where a setting is set: the settings file, except in older legacy projects that kept the
|
||||
// id in CMakeLists.txt.
|
||||
sfs::path settingFile(const Project& project, Setting which)
|
||||
{
|
||||
std::optional<std::string> text = fs::tryReadText(project.sdk.settingsFile);
|
||||
if (text && cmake::findSet(*text, variableOf(which)))
|
||||
return project.sdk.settingsFile;
|
||||
if (project.sdk.layout == SdkLayout::Legacy)
|
||||
{
|
||||
std::optional<std::string> cmakeLists =
|
||||
fs::tryReadText(project.root / "CMakeLists.txt");
|
||||
if (cmakeLists && cmake::findSet(*cmakeLists, variableOf(which)))
|
||||
return project.root / "CMakeLists.txt";
|
||||
}
|
||||
return project.sdk.settingsFile;
|
||||
}
|
||||
|
||||
// The name and the version of a legacy project live in the project() call of
|
||||
// CMakeLists.txt.
|
||||
struct LegacyProjectCall
|
||||
{
|
||||
cmake::Call call;
|
||||
std::string text;
|
||||
};
|
||||
|
||||
LegacyProjectCall legacyProjectCall(const Project& p)
|
||||
{
|
||||
std::string text = fs::readText(p.root / "CMakeLists.txt");
|
||||
std::vector<cmake::Call> calls = cmake::findCalls(
|
||||
text, [](const std::string& n) { return text::lower(n) == "project"; });
|
||||
if (calls.empty() || calls.front().args.empty())
|
||||
throw Error("CMakeLists.txt has no project() call to read the project's name and "
|
||||
"version from");
|
||||
return {calls.front(), std::move(text)};
|
||||
}
|
||||
|
||||
std::size_t versionArgIndex(const cmake::Call& call)
|
||||
{
|
||||
for (std::size_t i = 0; i + 1 < call.args.size(); ++i)
|
||||
{
|
||||
if (call.args[i].text == "VERSION")
|
||||
return i + 1;
|
||||
}
|
||||
return call.args.size();
|
||||
}
|
||||
}
|
||||
|
||||
std::string readSetting(const Project& project, Setting which)
|
||||
{
|
||||
if (project.sdk.layout == SdkLayout::Legacy && which != Setting::Id)
|
||||
{
|
||||
LegacyProjectCall lp = legacyProjectCall(project);
|
||||
if (which == Setting::Name)
|
||||
return lp.call.args[0].text;
|
||||
std::size_t idx = versionArgIndex(lp.call);
|
||||
if (idx >= lp.call.args.size())
|
||||
throw Error("the project() call in CMakeLists.txt has no VERSION");
|
||||
return lp.call.args[idx].text;
|
||||
}
|
||||
|
||||
sfs::path file = settingFile(project, which);
|
||||
std::string text = fs::readText(file);
|
||||
std::optional<cmake::Call> set = cmake::findSet(text, variableOf(which));
|
||||
if (!set || set->args.size() < 2)
|
||||
throw Error(std::string("'") + fs::generic(file) + "' does not set " +
|
||||
variableOf(which));
|
||||
return set->args[1].text;
|
||||
}
|
||||
|
||||
void writeSetting(const Project& project, Setting which, const std::string& value)
|
||||
{
|
||||
validate(which, value);
|
||||
|
||||
if (project.sdk.layout == SdkLayout::Legacy && which != Setting::Id)
|
||||
{
|
||||
LegacyProjectCall lp = legacyProjectCall(project);
|
||||
const cmake::Token* token = nullptr;
|
||||
if (which == Setting::Name)
|
||||
{
|
||||
token = &lp.call.args[0];
|
||||
}
|
||||
else
|
||||
{
|
||||
std::size_t idx = versionArgIndex(lp.call);
|
||||
if (idx >= lp.call.args.size())
|
||||
throw Error("the project() call in CMakeLists.txt has no VERSION to change");
|
||||
token = &lp.call.args[idx];
|
||||
}
|
||||
std::string text = lp.text;
|
||||
cmake::replaceToken(text, *token, value);
|
||||
fs::writeText(project.root / "CMakeLists.txt", text);
|
||||
return;
|
||||
}
|
||||
|
||||
sfs::path file = settingFile(project, which);
|
||||
std::string text = fs::readText(file);
|
||||
std::optional<cmake::Call> set = cmake::findSet(text, variableOf(which));
|
||||
if (!set || set->args.size() < 2)
|
||||
throw Error(std::string("'") + fs::generic(file) + "' does not set " +
|
||||
variableOf(which));
|
||||
cmake::replaceToken(text, set->args[1], cmake::quote(value));
|
||||
fs::writeText(file, text);
|
||||
}
|
||||
|
||||
Defaults Defaults::load(const Project& project)
|
||||
{
|
||||
Defaults d;
|
||||
if (std::optional<std::string> text = fs::tryReadText(project.defaultsFile()))
|
||||
{
|
||||
nlohmann::json parsed = nlohmann::json::parse(*text, nullptr, false);
|
||||
if (parsed.is_discarded() || !parsed.is_object())
|
||||
throw Error("'" + fs::generic(project.defaultsFile()) +
|
||||
"' is not valid JSON; fix or delete it");
|
||||
d.m_data = std::move(parsed);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
std::optional<std::string> Defaults::build(const std::string& key) const
|
||||
{
|
||||
auto b = m_data.find("build");
|
||||
if (b == m_data.end() || !b->is_object())
|
||||
return std::nullopt;
|
||||
auto it = b->find(key);
|
||||
if (it == b->end() || !it->is_string())
|
||||
return std::nullopt;
|
||||
return it->get<std::string>();
|
||||
}
|
||||
|
||||
void Defaults::setBuild(const std::string& key, const std::string& value)
|
||||
{
|
||||
m_data["build"][key] = value;
|
||||
}
|
||||
|
||||
void Defaults::clearBuild(const std::string& key)
|
||||
{
|
||||
if (m_data.contains("build"))
|
||||
{
|
||||
m_data["build"].erase(key);
|
||||
if (m_data["build"].empty())
|
||||
m_data.erase("build");
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<std::string> Defaults::run() const
|
||||
{
|
||||
auto it = m_data.find("run");
|
||||
if (it == m_data.end() || !it->is_string())
|
||||
return std::nullopt;
|
||||
return it->get<std::string>();
|
||||
}
|
||||
|
||||
void Defaults::setRun(const std::string& id)
|
||||
{
|
||||
m_data["run"] = id;
|
||||
}
|
||||
|
||||
void Defaults::clearRun()
|
||||
{
|
||||
m_data.erase("run");
|
||||
}
|
||||
|
||||
void Defaults::save(const Project& project) const
|
||||
{
|
||||
if (m_data.empty())
|
||||
{
|
||||
std::error_code ec;
|
||||
sfs::remove(project.defaultsFile(), ec);
|
||||
return;
|
||||
}
|
||||
fs::writeText(project.defaultsFile(), m_data.dump(2) + "\n");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
#include "breeze/Sdks.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
|
||||
#include "breeze/CMakeText.hpp"
|
||||
#include "breeze/Config.hpp"
|
||||
#include "breeze/Error.hpp"
|
||||
#include "breeze/Fs.hpp"
|
||||
#include "breeze/Git.hpp"
|
||||
#include "breeze/Modules.hpp"
|
||||
#include "breeze/Process.hpp"
|
||||
#include "breeze/Text.hpp"
|
||||
|
||||
namespace breeze::sdk
|
||||
{
|
||||
|
||||
namespace sfs = std::filesystem;
|
||||
|
||||
namespace
|
||||
{
|
||||
// What a snapshot never hands to a project.
|
||||
const std::vector<std::string> Skip = {".git", "out", ".cache", "compile_commands.json"};
|
||||
|
||||
// Root files the tools look for there, so they stay at the root and belong to the project.
|
||||
const std::array<const char*, 4> DotFiles = {".clang-format", ".clang-tidy",
|
||||
".editorconfig", ".gitattributes"};
|
||||
|
||||
std::string hashOf(const std::string& s)
|
||||
{
|
||||
std::uint64_t h = 1469598103934665603ULL; // FNV-1a
|
||||
for (unsigned char c : s)
|
||||
{
|
||||
h ^= c;
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
std::ostringstream out;
|
||||
out << std::hex << h;
|
||||
return out.str();
|
||||
}
|
||||
|
||||
bool isBreezeSnapshot(const sfs::path& snap)
|
||||
{
|
||||
std::error_code ec;
|
||||
return sfs::is_regular_file(snap / "breeze" / "cmake" / "Breeze.cmake", ec) &&
|
||||
sfs::is_regular_file(snap / "project" / "Configuration.cmake", ec) &&
|
||||
sfs::is_regular_file(snap / "CMakeLists.txt", ec);
|
||||
}
|
||||
|
||||
const Source::Entry* findEntry(const std::vector<Source::Entry>& all, const Version& v)
|
||||
{
|
||||
for (const Source::Entry& e : all)
|
||||
{
|
||||
if (e.version == v)
|
||||
return &e;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// The newest release (a pre-release only when there is no release), optionally of one major
|
||||
// version.
|
||||
const Source::Entry* newest(const std::vector<Source::Entry>& all, std::optional<int> major)
|
||||
{
|
||||
const Source::Entry* best = nullptr;
|
||||
for (int pass = 0; pass < 2 && best == nullptr; ++pass)
|
||||
{
|
||||
for (const Source::Entry& e : all)
|
||||
{
|
||||
if (major && e.version.major != *major)
|
||||
continue;
|
||||
if (pass == 0 && !e.version.pre.empty())
|
||||
continue;
|
||||
if (best == nullptr || best->version < e.version)
|
||||
best = &e;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
std::string list(const std::vector<Source::Entry>& all)
|
||||
{
|
||||
std::vector<std::string> names;
|
||||
for (const Source::Entry& e : all)
|
||||
names.push_back(e.version.toString());
|
||||
return text::join(names, ", ");
|
||||
}
|
||||
|
||||
Source::Entry requireSnapshot(const Source& source, const Source::Entry& entry,
|
||||
sfs::path& snapshot)
|
||||
{
|
||||
snapshot = source.snapshot(entry);
|
||||
if (!isBreezeSnapshot(snapshot))
|
||||
throw Error("SDK " + entry.version.toString() + " (" + source.location() +
|
||||
") has no breeze/ folder with project/Configuration.cmake: it is not a "
|
||||
"template this "
|
||||
"CLI can install");
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Replaces the first value of set(<var> ...) in `text` by `raw` (already in CMake syntax).
|
||||
bool setRaw(std::string& text, const std::string& var, const std::string& raw)
|
||||
{
|
||||
std::optional<cmake::Call> set = cmake::findSet(text, var);
|
||||
if (!set || set->args.size() < 2)
|
||||
return false;
|
||||
cmake::replaceToken(text, set->args[1], raw);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Settings the snapshot defines and the project's file does not yet: appended, as written.
|
||||
std::vector<std::string> mergeMissingSettings(const sfs::path& projectFile,
|
||||
const sfs::path& snapshotFile)
|
||||
{
|
||||
std::string project = fs::readText(projectFile);
|
||||
std::string snapshot = fs::readText(snapshotFile);
|
||||
std::vector<std::string> added;
|
||||
for (const cmake::Call& call : cmake::findCalls(snapshot, [](const std::string& n)
|
||||
{ return text::lower(n) == "set"; }))
|
||||
{
|
||||
if (call.args.empty())
|
||||
continue;
|
||||
const std::string& var = call.args[0].text;
|
||||
if (cmake::findSet(project, var))
|
||||
continue;
|
||||
if (!project.empty() && project.back() != '\n')
|
||||
project += '\n';
|
||||
project += "\n# Added by `breeze sdk`: new in this SDK version.\n" +
|
||||
snapshot.substr(call.begin, call.end - call.begin) + "\n";
|
||||
added.push_back(var);
|
||||
}
|
||||
if (!added.empty())
|
||||
fs::writeText(projectFile, project);
|
||||
return added;
|
||||
}
|
||||
|
||||
std::vector<std::string> differingDotFiles(const sfs::path& root, const sfs::path& snapshot)
|
||||
{
|
||||
std::vector<std::string> differing;
|
||||
for (const char* name : DotFiles)
|
||||
{
|
||||
std::optional<std::string> mine = fs::tryReadText(root / name);
|
||||
std::optional<std::string> theirs = fs::tryReadText(snapshot / name);
|
||||
if (mine && theirs && *mine != *theirs)
|
||||
differing.push_back(name);
|
||||
}
|
||||
return differing;
|
||||
}
|
||||
|
||||
void moveAside(const sfs::path& from, const sfs::path& to)
|
||||
{
|
||||
std::error_code ec;
|
||||
sfs::create_directories(to.parent_path(), ec);
|
||||
fs::removeTree(to);
|
||||
sfs::rename(from, to, ec);
|
||||
if (ec)
|
||||
{
|
||||
if (sfs::is_directory(from))
|
||||
fs::copyTree(from, to);
|
||||
else
|
||||
sfs::copy_file(from, to, sfs::copy_options::overwrite_existing, ec);
|
||||
fs::removeTree(from);
|
||||
}
|
||||
}
|
||||
|
||||
void keepBackupsOutOfGit(const Project& project)
|
||||
{
|
||||
sfs::path ignore = project.metaDir() / ".gitignore";
|
||||
if (!sfs::exists(ignore))
|
||||
fs::writeText(ignore, "backup/\n");
|
||||
}
|
||||
|
||||
std::string fromLabel(const Project& project)
|
||||
{
|
||||
return project.sdk.version ? project.sdk.version->toString() : "unversioned";
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- moving a breeze/ project
|
||||
// between versions
|
||||
|
||||
void applyBreeze(const Project& project, const sfs::path& snapshot,
|
||||
const Source::Entry& entry)
|
||||
{
|
||||
sfs::path backup = project.metaDir() / "backup" / ("breeze-" + fromLabel(project));
|
||||
moveAside(project.root / "breeze", backup);
|
||||
keepBackupsOutOfGit(project);
|
||||
fs::copyTree(snapshot / "breeze", project.root / "breeze");
|
||||
|
||||
std::vector<std::string> added = mergeMissingSettings(
|
||||
project.sdk.settingsFile, snapshot / "project" / "Configuration.cmake");
|
||||
std::vector<std::string> dotFiles = differingDotFiles(project.root, snapshot);
|
||||
|
||||
std::cout << "SDK " << fromLabel(project) << " -> " << entry.version.toString() << "\n";
|
||||
std::cout << " breeze/ replaced (the old one is in "
|
||||
<< fs::generic(sfs::relative(backup, project.root)) << ")\n";
|
||||
for (const std::string& a : added)
|
||||
std::cout << " added the setting " << a << " to project/Configuration.cmake\n";
|
||||
for (const std::string& d : dotFiles)
|
||||
std::cout << " " << d
|
||||
<< " differs from the new SDK's; compare them if you want its changes\n";
|
||||
std::cout
|
||||
<< " delete out/build/ if the next build complains about a stale configuration\n";
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- moving a legacy (flat)
|
||||
// project to breeze/
|
||||
|
||||
struct OldProject
|
||||
{
|
||||
std::string name;
|
||||
std::string version;
|
||||
std::string description;
|
||||
std::vector<std::string> languages;
|
||||
std::string cxxStandard;
|
||||
};
|
||||
|
||||
OldProject readOldProject(const std::string& cmakeLists)
|
||||
{
|
||||
std::vector<cmake::Call> calls = cmake::findCalls(
|
||||
cmakeLists, [](const std::string& n) { return text::lower(n) == "project"; });
|
||||
if (calls.empty() || calls.front().args.empty())
|
||||
throw Error("the project's CMakeLists.txt has no project() call to read the name "
|
||||
"and version from");
|
||||
OldProject old;
|
||||
old.name = calls.front().args.front().text;
|
||||
std::string mode;
|
||||
for (std::size_t i = 1; i < calls.front().args.size(); ++i)
|
||||
{
|
||||
const cmake::Token& t = calls.front().args[i];
|
||||
if (!t.quoted && (t.text == "VERSION" || t.text == "DESCRIPTION" ||
|
||||
t.text == "LANGUAGES" || t.text == "HOMEPAGE_URL"))
|
||||
{
|
||||
mode = t.text;
|
||||
continue;
|
||||
}
|
||||
if (mode == "VERSION")
|
||||
old.version = t.text;
|
||||
else if (mode == "DESCRIPTION")
|
||||
old.description = t.text;
|
||||
else if (mode == "LANGUAGES")
|
||||
old.languages.push_back(t.text);
|
||||
}
|
||||
if (std::optional<cmake::Call> std = cmake::findSet(cmakeLists, "CMAKE_CXX_STANDARD");
|
||||
std && std->args.size() > 1)
|
||||
old.cxxStandard = std->args[1].text;
|
||||
return old;
|
||||
}
|
||||
|
||||
void migrateLegacy(const Project& project, const sfs::path& snapshot,
|
||||
const Source::Entry& entry)
|
||||
{
|
||||
const sfs::path& root = project.root;
|
||||
std::string oldCmake = fs::readText(root / "CMakeLists.txt");
|
||||
std::string oldConfig = fs::readText(root / "Configuration.cmake");
|
||||
OldProject old = readOldProject(oldCmake);
|
||||
|
||||
// The new settings: the snapshot's file with what this project said put in.
|
||||
std::string settings = fs::readText(snapshot / "project" / "Configuration.cmake");
|
||||
setRaw(settings, "PROJECT_TITLE", cmake::quote(old.name));
|
||||
if (!old.version.empty())
|
||||
setRaw(settings, "PROJECT_VERSION_NUMBER", cmake::quote(old.version));
|
||||
if (!old.description.empty())
|
||||
setRaw(settings, "PROJECT_SUMMARY", cmake::quote(old.description));
|
||||
if (!old.languages.empty())
|
||||
setRaw(settings, "PROJECT_LANGUAGES", text::join(old.languages, " "));
|
||||
if (!old.cxxStandard.empty())
|
||||
setRaw(settings, "PROJECT_CXX_STANDARD", old.cxxStandard);
|
||||
// Every other setting the project had changed, by name: collected first, applied after,
|
||||
// because each edit moves the offsets of what follows.
|
||||
std::vector<std::pair<std::string, std::string>> changes;
|
||||
for (const cmake::Call& call : cmake::findCalls(settings, [](const std::string& n)
|
||||
{ return text::lower(n) == "set"; }))
|
||||
{
|
||||
if (call.args.size() < 2)
|
||||
continue;
|
||||
const std::string var = call.args[0].text;
|
||||
if (var.rfind("PROJECT_", 0) == 0 && var != "PROJECT_CODE_NAME")
|
||||
continue;
|
||||
for (const std::string* source : {&oldConfig, &oldCmake})
|
||||
{
|
||||
std::optional<cmake::Call> was = cmake::findSet(*source, var);
|
||||
if (!was || was->args.size() < 2)
|
||||
continue;
|
||||
std::string raw =
|
||||
source->substr(was->args[1].begin, was->args[1].end - was->args[1].begin);
|
||||
std::string now =
|
||||
settings.substr(call.args[1].begin, call.args[1].end - call.args[1].begin);
|
||||
if (raw != now)
|
||||
changes.emplace_back(var, raw);
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::vector<std::string> carried;
|
||||
for (const auto& [var, raw] : changes)
|
||||
{
|
||||
setRaw(settings, var, raw);
|
||||
carried.push_back(var);
|
||||
}
|
||||
|
||||
// What the project had of the template's goes aside, nothing is thrown away.
|
||||
sfs::path backup =
|
||||
project.metaDir() / "backup" / ("legacy-to-" + entry.version.toString());
|
||||
fs::removeTree(backup);
|
||||
// Only what the template owned: the project's README.md and any other documents stay.
|
||||
for (const char* name : {"CMakeLists.txt", "CMakePresets.json", "Configuration.cmake",
|
||||
"cmake", "scripts", ".githooks", "StyleGuide.md"})
|
||||
{
|
||||
sfs::path p = root / name;
|
||||
if (sfs::exists(p))
|
||||
moveAside(p, backup / name);
|
||||
}
|
||||
keepBackupsOutOfGit(project);
|
||||
|
||||
fs::copyTree(snapshot / "breeze", root / "breeze");
|
||||
sfs::copy_file(snapshot / "CMakeLists.txt", root / "CMakeLists.txt",
|
||||
sfs::copy_options::overwrite_existing);
|
||||
sfs::copy_file(snapshot / "CMakePresets.json", root / "CMakePresets.json",
|
||||
sfs::copy_options::overwrite_existing);
|
||||
fs::writeText(root / "project" / "Configuration.cmake", settings);
|
||||
for (const char* name : DotFiles)
|
||||
{
|
||||
if (!sfs::exists(root / name) && sfs::exists(snapshot / name))
|
||||
sfs::copy_file(snapshot / name, root / name);
|
||||
}
|
||||
|
||||
std::cout << "legacy layout -> SDK " << entry.version.toString()
|
||||
<< " (breeze/ layout)\n";
|
||||
std::cout << " breeze/ and project/Configuration.cmake created; the new root "
|
||||
"CMakeLists.txt and "
|
||||
"CMakePresets.json are the SDK's\n";
|
||||
std::cout << " the old CMakeLists.txt, Configuration.cmake, cmake/, scripts/ and "
|
||||
".githooks/ are in "
|
||||
<< fs::generic(sfs::relative(backup, root)) << "\n";
|
||||
std::cout << " carried over: project name '" << old.name << "'"
|
||||
<< (old.version.empty() ? "" : ", version " + old.version)
|
||||
<< (carried.empty() ? "" : ", " + text::join(carried, ", ")) << "\n";
|
||||
|
||||
// The git hook directory moved.
|
||||
if (sfs::exists(root / ".git"))
|
||||
{
|
||||
proc::Options o;
|
||||
o.cwd = root;
|
||||
o.capture = true;
|
||||
proc::Result r = proc::run({"git", "config", "core.hooksPath"}, o);
|
||||
if (r.exitCode == 0 && text::trim(r.output) == ".githooks")
|
||||
{
|
||||
proc::Options q;
|
||||
q.cwd = root;
|
||||
q.silent = true;
|
||||
proc::run({"git", "config", "core.hooksPath", "breeze/githooks"}, q);
|
||||
std::cout << " git hooks now come from breeze/githooks\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Build directories made by the old scripts point at files that are gone.
|
||||
if (sfs::exists(root / "out" / "build"))
|
||||
{
|
||||
fs::removeTree(root / "out" / "build");
|
||||
std::cout << " removed out/build/ (it was configured by the old layout)\n";
|
||||
}
|
||||
std::cout << "next: breeze setup, then breeze build\n";
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------- Source
|
||||
|
||||
Source Source::configured()
|
||||
{
|
||||
GlobalConfig cfg = GlobalConfig::load();
|
||||
std::optional<std::string> location = cfg.effective(config::SdkSource);
|
||||
if (!location)
|
||||
throw Error("no SDK source is configured. Say where SDK versions come from with:\n"
|
||||
" breeze config sdk-source --set <git repository (URL or path) | "
|
||||
"directory of versions>");
|
||||
return Source(*location);
|
||||
}
|
||||
|
||||
Source::Source(std::string location) : m_location(std::move(location))
|
||||
{
|
||||
if (git::isRepository(m_location))
|
||||
m_isRepository = true;
|
||||
else if (!sfs::is_directory(m_location))
|
||||
throw Error("the SDK source '" + m_location +
|
||||
"' is neither a git repository nor a directory");
|
||||
}
|
||||
|
||||
std::vector<Source::Entry> Source::versions() const
|
||||
{
|
||||
std::vector<Entry> out;
|
||||
if (m_isRepository)
|
||||
{
|
||||
for (const git::Tag& t : git::versionTags(m_location))
|
||||
out.push_back({t.version, t.name});
|
||||
}
|
||||
else
|
||||
{
|
||||
for (const sfs::directory_entry& e : sfs::directory_iterator(m_location))
|
||||
{
|
||||
if (!e.is_directory())
|
||||
continue;
|
||||
if (std::optional<Version> v = parseVersion(e.path().filename().string()))
|
||||
out.push_back({*v, e.path().filename().string()});
|
||||
}
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Entry& a, const Entry& b) { return a.version < b.version; });
|
||||
return out;
|
||||
}
|
||||
|
||||
sfs::path Source::snapshot(const Entry& entry) const
|
||||
{
|
||||
if (!m_isRepository)
|
||||
return sfs::path(m_location) / entry.ref;
|
||||
sfs::path cache = breezeHome() / "sdk" / hashOf(m_location) / entry.version.toString();
|
||||
if (sfs::exists(cache / "breeze"))
|
||||
return cache;
|
||||
std::cout << "fetching SDK " << entry.version.toString() << " from " << m_location << "\n";
|
||||
fs::removeTree(cache);
|
||||
git::exportTree(m_location, entry.ref, cache);
|
||||
return cache;
|
||||
}
|
||||
|
||||
Source::Entry Source::resolve(const std::optional<std::string>& wanted) const
|
||||
{
|
||||
std::vector<Entry> all = versions();
|
||||
if (all.empty())
|
||||
throw Error("the SDK source '" + m_location + "' has no versions (tags like v1.0.0)");
|
||||
if (!wanted || text::lower(*wanted) == "latest")
|
||||
return *newest(all, std::nullopt);
|
||||
std::optional<Version> v = parseVersion(*wanted);
|
||||
if (!v)
|
||||
throw Error("'" + *wanted + "' is not a version (use 1.2.3, or latest)");
|
||||
if (const Entry* e = findEntry(all, *v))
|
||||
return *e;
|
||||
throw Error("there is no SDK " + v->toString() + " in '" + m_location +
|
||||
"'. Available: " + list(all));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------- commands
|
||||
|
||||
namespace
|
||||
{
|
||||
std::string moduleNameFor(const std::string& projectName)
|
||||
{
|
||||
std::string out;
|
||||
bool upper = true;
|
||||
for (char c : projectName)
|
||||
{
|
||||
if (c == '-' || c == '_')
|
||||
{
|
||||
upper = true;
|
||||
continue;
|
||||
}
|
||||
out += upper ? static_cast<char>(std::toupper(static_cast<unsigned char>(c))) : c;
|
||||
upper = false;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string idFor(const std::string& projectName)
|
||||
{
|
||||
return text::replaceAll(text::lower(projectName), "-", "_");
|
||||
}
|
||||
}
|
||||
|
||||
void createProject(const std::string& name, const std::optional<std::string>& id,
|
||||
const std::optional<std::string>& sdkVersion)
|
||||
{
|
||||
if (!text::isName(name, true))
|
||||
throw Error(
|
||||
"'" + name +
|
||||
"' is not a valid project name: it starts with a letter and has only letters, "
|
||||
"digits, '_' and '-'");
|
||||
std::string projectId = id ? *id : idFor(name);
|
||||
sfs::path dest = sfs::current_path() / name;
|
||||
std::error_code ec;
|
||||
if (sfs::exists(dest, ec) && !sfs::is_empty(dest, ec))
|
||||
throw Error("'" + name + "' already exists and is not empty");
|
||||
|
||||
Source source = Source::configured();
|
||||
Source::Entry entry = source.resolve(sdkVersion);
|
||||
sfs::path snapshot;
|
||||
requireSnapshot(source, entry, snapshot);
|
||||
|
||||
fs::copyTree(snapshot, dest, Skip);
|
||||
// A new project starts without the template's demonstration code.
|
||||
for (const char* dir : {"source", "vendor"})
|
||||
{
|
||||
fs::removeTree(dest / dir);
|
||||
}
|
||||
fs::writeText(dest / "source" / ".gitkeep", "");
|
||||
|
||||
std::optional<Sdk> detected = detectSdk(dest);
|
||||
if (!detected)
|
||||
throw Error("internal error: the project made from SDK " + entry.version.toString() +
|
||||
" is not recognised as one");
|
||||
Project project{dest, *detected};
|
||||
writeSetting(project, Setting::Name, name);
|
||||
writeSetting(project, Setting::Id, projectId);
|
||||
writeSetting(project, Setting::Version, "0.1.0");
|
||||
{
|
||||
std::string settings = fs::readText(project.sdk.settingsFile);
|
||||
setRaw(settings, "PROJECT_SUMMARY", cmake::quote(name));
|
||||
fs::writeText(project.sdk.settingsFile, settings);
|
||||
}
|
||||
fs::writeText(
|
||||
dest / "README.md",
|
||||
"# " + name +
|
||||
"\n\nThe project's own README: say what it is here. Doxygen uses this file as "
|
||||
"the documentation's main page.\n\nBuilt with the breeze SDK "
|
||||
"(breeze/):\n\n```sh\nbreeze setup "
|
||||
"# once, to check the tools\nbreeze build\nbreeze run\n```\n");
|
||||
|
||||
NewModule first;
|
||||
first.kind = ModuleKind::Exec;
|
||||
first.name = moduleNameFor(name);
|
||||
addModule(project, first);
|
||||
fs::removeTree(dest / "source" / ".gitkeep");
|
||||
|
||||
std::cout << "created " << name << " with SDK " << entry.version.toString() << "\n";
|
||||
std::cout << " an executable module '" << first.name << "' to start from\n";
|
||||
std::cout << "next:\n cd " << name << "\n breeze setup\n breeze build\n breeze run\n";
|
||||
}
|
||||
|
||||
void update(const Project& project)
|
||||
{
|
||||
Source source = Source::configured();
|
||||
std::vector<Source::Entry> all = source.versions();
|
||||
std::cout << "this project's SDK: " << project.sdk.describe() << "\n";
|
||||
std::cout << "source: " << source.location() << "\n";
|
||||
if (all.empty())
|
||||
{
|
||||
std::cout << "the source has no versions\n";
|
||||
return;
|
||||
}
|
||||
std::cout << "available: " << list(all) << "\n";
|
||||
|
||||
const Source::Entry* latest = newest(all, std::nullopt);
|
||||
if (project.sdk.layout == SdkLayout::Legacy)
|
||||
{
|
||||
std::cout << "this project has the legacy layout: `breeze sdk migrate "
|
||||
<< latest->version.toString() << "` moves it to the breeze/ layout\n";
|
||||
return;
|
||||
}
|
||||
if (!project.sdk.version)
|
||||
{
|
||||
std::cout << "this SDK has no version file: `breeze sdk upgrade` moves to "
|
||||
<< latest->version.toString() << "\n";
|
||||
return;
|
||||
}
|
||||
const Version& current = *project.sdk.version;
|
||||
const Source::Entry* compatible = newest(all, current.major);
|
||||
bool behind = compatible != nullptr && current < compatible->version;
|
||||
if (behind)
|
||||
std::cout << "update available: " << compatible->version.toString()
|
||||
<< " (`breeze sdk upgrade`)\n";
|
||||
if (latest->version.major > current.major)
|
||||
std::cout << "a newer major version exists: " << latest->version.toString()
|
||||
<< " (`breeze sdk migrate " << latest->version.toString() << "`)\n";
|
||||
if (!behind && latest->version.major <= current.major)
|
||||
std::cout << "up to date\n";
|
||||
}
|
||||
|
||||
void upgrade(const Project& project)
|
||||
{
|
||||
if (project.sdk.layout == SdkLayout::Legacy)
|
||||
throw Error("this project has the legacy layout, which has no upgrades: `breeze sdk "
|
||||
"migrate <version>` "
|
||||
"moves it to the breeze/ layout (see `breeze sdk update`)");
|
||||
Source source = Source::configured();
|
||||
std::vector<Source::Entry> all = source.versions();
|
||||
if (all.empty())
|
||||
throw Error("the SDK source '" + source.location() + "' has no versions");
|
||||
const Source::Entry* target = project.sdk.version ? newest(all, project.sdk.version->major)
|
||||
: newest(all, std::nullopt);
|
||||
if (project.sdk.version && (target == nullptr || !(*project.sdk.version < target->version)))
|
||||
{
|
||||
std::cout << "SDK " << project.sdk.describe()
|
||||
<< " is the newest of its major version\n";
|
||||
const Source::Entry* latest = newest(all, std::nullopt);
|
||||
if (latest->version.major > project.sdk.version->major)
|
||||
std::cout << "a newer major version exists: `breeze sdk migrate "
|
||||
<< latest->version.toString() << "`\n";
|
||||
return;
|
||||
}
|
||||
sfs::path snapshot;
|
||||
requireSnapshot(source, *target, snapshot);
|
||||
applyBreeze(project, snapshot, *target);
|
||||
}
|
||||
|
||||
void migrate(const Project& project, const std::string& version)
|
||||
{
|
||||
Source source = Source::configured();
|
||||
Source::Entry entry = source.resolve(version);
|
||||
sfs::path snapshot;
|
||||
requireSnapshot(source, entry, snapshot);
|
||||
if (project.sdk.layout == SdkLayout::Legacy)
|
||||
{
|
||||
migrateLegacy(project, snapshot, entry);
|
||||
return;
|
||||
}
|
||||
if (project.sdk.version && *project.sdk.version == entry.version)
|
||||
{
|
||||
std::cout << "this project is already on SDK " << entry.version.toString() << "\n";
|
||||
return;
|
||||
}
|
||||
if (project.sdk.version && entry.version < *project.sdk.version)
|
||||
std::cout << "note: this is an older SDK than the project's (" << fromLabel(project)
|
||||
<< ")\n";
|
||||
applyBreeze(project, snapshot, entry);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
#include "breeze/Text.hpp"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace breeze::text
|
||||
{
|
||||
|
||||
std::string trim(std::string_view s)
|
||||
{
|
||||
std::size_t b = 0;
|
||||
std::size_t e = s.size();
|
||||
while (b < e && std::isspace(static_cast<unsigned char>(s[b])))
|
||||
++b;
|
||||
while (e > b && std::isspace(static_cast<unsigned char>(s[e - 1])))
|
||||
--e;
|
||||
return std::string(s.substr(b, e - b));
|
||||
}
|
||||
|
||||
std::vector<std::string> split(std::string_view s, char delimiter)
|
||||
{
|
||||
std::vector<std::string> out;
|
||||
std::size_t start = 0;
|
||||
while (true)
|
||||
{
|
||||
std::size_t pos = s.find(delimiter, start);
|
||||
if (pos == std::string_view::npos)
|
||||
{
|
||||
out.emplace_back(s.substr(start));
|
||||
return out;
|
||||
}
|
||||
out.emplace_back(s.substr(start, pos - start));
|
||||
start = pos + 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::string> splitLines(std::string_view s)
|
||||
{
|
||||
std::vector<std::string> lines = split(s, '\n');
|
||||
for (std::string& line : lines)
|
||||
{
|
||||
if (!line.empty() && line.back() == '\r')
|
||||
line.pop_back();
|
||||
}
|
||||
if (!lines.empty() && lines.back().empty())
|
||||
lines.pop_back();
|
||||
return lines;
|
||||
}
|
||||
|
||||
std::string join(const std::vector<std::string>& parts, std::string_view separator)
|
||||
{
|
||||
std::string out;
|
||||
for (std::size_t i = 0; i < parts.size(); ++i)
|
||||
{
|
||||
if (i != 0)
|
||||
out += separator;
|
||||
out += parts[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string lower(std::string_view s)
|
||||
{
|
||||
std::string out(s);
|
||||
std::transform(out.begin(), out.end(), out.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return out;
|
||||
}
|
||||
|
||||
bool startsWith(std::string_view s, std::string_view prefix)
|
||||
{
|
||||
return s.size() >= prefix.size() && s.substr(0, prefix.size()) == prefix;
|
||||
}
|
||||
|
||||
bool endsWith(std::string_view s, std::string_view suffix)
|
||||
{
|
||||
return s.size() >= suffix.size() && s.substr(s.size() - suffix.size()) == suffix;
|
||||
}
|
||||
|
||||
std::string replaceAll(std::string s, std::string_view from, std::string_view to)
|
||||
{
|
||||
if (from.empty())
|
||||
return s;
|
||||
std::size_t pos = 0;
|
||||
while ((pos = s.find(from, pos)) != std::string::npos)
|
||||
{
|
||||
s.replace(pos, from.size(), to);
|
||||
pos += to.size();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string snakeCase(std::string_view s)
|
||||
{
|
||||
std::string out;
|
||||
for (std::size_t i = 0; i < s.size(); ++i)
|
||||
{
|
||||
unsigned char c = static_cast<unsigned char>(s[i]);
|
||||
if (c == '-' || c == ' ' || c == '.')
|
||||
c = '_';
|
||||
if (std::isupper(c))
|
||||
{
|
||||
bool prevLowerOrDigit =
|
||||
i > 0 && (std::islower(static_cast<unsigned char>(s[i - 1])) ||
|
||||
std::isdigit(static_cast<unsigned char>(s[i - 1])));
|
||||
bool acronymEnds = i > 0 && std::isupper(static_cast<unsigned char>(s[i - 1])) &&
|
||||
i + 1 < s.size() &&
|
||||
std::islower(static_cast<unsigned char>(s[i + 1]));
|
||||
if ((prevLowerOrDigit || acronymEnds) && !out.empty() && out.back() != '_')
|
||||
out += '_';
|
||||
out += static_cast<char>(std::tolower(c));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c == '_' && (out.empty() || out.back() == '_'))
|
||||
continue;
|
||||
out += static_cast<char>(c);
|
||||
}
|
||||
}
|
||||
while (!out.empty() && out.back() == '_')
|
||||
out.pop_back();
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string normalized(std::string_view s)
|
||||
{
|
||||
std::string out;
|
||||
for (char c : s)
|
||||
{
|
||||
unsigned char u = static_cast<unsigned char>(c);
|
||||
if (std::isalnum(u))
|
||||
out += static_cast<char>(std::tolower(u));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool isName(std::string_view s, bool allowDash)
|
||||
{
|
||||
if (s.empty() || !std::isalpha(static_cast<unsigned char>(s[0])))
|
||||
return false;
|
||||
return std::all_of(s.begin(), s.end(),
|
||||
[allowDash](char c)
|
||||
{
|
||||
unsigned char u = static_cast<unsigned char>(c);
|
||||
return std::isalnum(u) || c == '_' || (allowDash && c == '-');
|
||||
});
|
||||
}
|
||||
|
||||
bool isTargetId(std::string_view s)
|
||||
{
|
||||
if (s.empty() || !(std::isalpha(static_cast<unsigned char>(s[0])) || s[0] == '_'))
|
||||
return false;
|
||||
return std::all_of(s.begin(), s.end(),
|
||||
[](char c)
|
||||
{
|
||||
unsigned char u = static_cast<unsigned char>(c);
|
||||
return std::isalnum(u) || c == '_' || c == '.' || c == '+' ||
|
||||
c == '-' || c == ':';
|
||||
});
|
||||
}
|
||||
|
||||
std::string orNone(std::string_view s)
|
||||
{
|
||||
return s.empty() ? std::string("<none>") : std::string(s);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
#include "breeze/Tools.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <iostream>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "breeze/Config.hpp"
|
||||
#include "breeze/Error.hpp"
|
||||
#include "breeze/Fs.hpp"
|
||||
#include "breeze/Git.hpp"
|
||||
#include "breeze/Process.hpp"
|
||||
#include "breeze/Text.hpp"
|
||||
#include "breeze/Version.hpp"
|
||||
|
||||
namespace breeze::tools
|
||||
{
|
||||
|
||||
namespace sfs = std::filesystem;
|
||||
using nlohmann::json;
|
||||
|
||||
bool isReservedName(const std::string& word)
|
||||
{
|
||||
static const std::array<const char*, 6> Reserved = {"install", "uninstall", "update",
|
||||
"upgrade", "migrate", "ls"};
|
||||
return std::any_of(Reserved.begin(), Reserved.end(),
|
||||
[&](const char* r) { return word == r; });
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
sfs::path registryFile(const Project& project)
|
||||
{
|
||||
return project.metaDir() / "tools.json";
|
||||
}
|
||||
|
||||
json loadRegistry(const Project& project)
|
||||
{
|
||||
json registry = json::object();
|
||||
if (std::optional<std::string> text = fs::tryReadText(registryFile(project)))
|
||||
{
|
||||
registry = json::parse(*text, nullptr, false);
|
||||
if (registry.is_discarded() || !registry.is_object())
|
||||
throw Error("'" + fs::generic(registryFile(project)) +
|
||||
"' is not valid JSON; fix or delete it");
|
||||
}
|
||||
if (!registry.contains("tools") || !registry["tools"].is_object())
|
||||
registry["tools"] = json::object();
|
||||
return registry;
|
||||
}
|
||||
|
||||
void saveRegistry(const Project& project, const json& registry)
|
||||
{
|
||||
if (registry["tools"].empty())
|
||||
{
|
||||
std::error_code ec;
|
||||
sfs::remove(registryFile(project), ec);
|
||||
return;
|
||||
}
|
||||
fs::writeText(registryFile(project), registry.dump(2) + "\n");
|
||||
}
|
||||
|
||||
struct Manifest
|
||||
{
|
||||
std::string id;
|
||||
std::string version;
|
||||
std::string description;
|
||||
std::string entry; // relative to the tool's directory, for this OS
|
||||
};
|
||||
|
||||
Manifest readManifest(const sfs::path& dir)
|
||||
{
|
||||
std::optional<std::string> text = fs::tryReadText(dir / "breeze-tool.json");
|
||||
if (!text)
|
||||
throw Error("this is not a breeze tool: it has no breeze-tool.json at its root");
|
||||
json j = json::parse(*text, nullptr, false);
|
||||
if (j.is_discarded() || !j.is_object())
|
||||
throw Error("breeze-tool.json is not valid JSON");
|
||||
Manifest m;
|
||||
m.id = j.value("id", "");
|
||||
m.version = j.value("version", "");
|
||||
m.description = j.value("description", "");
|
||||
if (!text::isName(m.id, true))
|
||||
throw Error("breeze-tool.json needs an \"id\" (letters, digits, '_', '-'; starting "
|
||||
"with a letter)");
|
||||
if (isReservedName(m.id))
|
||||
throw Error("'" + m.id + "' can't be a tool id: `breeze tool " + m.id +
|
||||
"` is a command of the CLI");
|
||||
if (j.contains("entry") && j["entry"].is_string())
|
||||
{
|
||||
m.entry = j["entry"].get<std::string>();
|
||||
}
|
||||
else if (j.contains("entry") && j["entry"].is_object())
|
||||
{
|
||||
m.entry =
|
||||
j["entry"].value(fs::hostPlatform() == "windows" ? "windows" : "posix", "");
|
||||
}
|
||||
if (m.entry.empty())
|
||||
throw Error("breeze-tool.json needs an \"entry\" for this OS (a path, or { "
|
||||
"\"posix\": ..., \"windows\": ... })");
|
||||
if (!sfs::is_regular_file(dir / m.entry))
|
||||
throw Error("the tool's entry '" + m.entry + "' is not in the repository");
|
||||
return m;
|
||||
}
|
||||
|
||||
struct Fetched
|
||||
{
|
||||
sfs::path dir; // a temporary directory holding the tool's files
|
||||
Manifest manifest;
|
||||
std::string version;
|
||||
};
|
||||
|
||||
enum class Pick
|
||||
{
|
||||
Newest,
|
||||
SameMajor,
|
||||
Exact
|
||||
};
|
||||
|
||||
// Gets the tool from `repo`: the version wanted, if the repository has version tags; else
|
||||
// its files as they are.
|
||||
Fetched fetch(const std::string& repo, Pick pick, const std::optional<Version>& basis)
|
||||
{
|
||||
sfs::path tmp = fs::makeTempDir("breeze-tool");
|
||||
sfs::path dir = tmp / "tool";
|
||||
std::string version;
|
||||
try
|
||||
{
|
||||
if (git::isRepository(repo))
|
||||
{
|
||||
std::vector<git::Tag> tags = git::versionTags(repo);
|
||||
const git::Tag* chosen = nullptr;
|
||||
for (int pass = 0; pass < 2 && chosen == nullptr; ++pass)
|
||||
{
|
||||
for (const git::Tag& t : tags)
|
||||
{
|
||||
if (pick == Pick::Exact && basis && !(t.version == *basis))
|
||||
continue;
|
||||
if (pick == Pick::SameMajor && basis && t.version.major != basis->major)
|
||||
continue;
|
||||
if (pass == 0 && pick != Pick::Exact && !t.version.pre.empty())
|
||||
continue;
|
||||
if (chosen == nullptr || chosen->version < t.version)
|
||||
chosen = &t;
|
||||
}
|
||||
}
|
||||
if (pick == Pick::Exact && chosen == nullptr)
|
||||
{
|
||||
std::vector<std::string> names;
|
||||
for (const git::Tag& t : tags)
|
||||
names.push_back(t.version.toString());
|
||||
std::sort(names.begin(), names.end());
|
||||
throw Error(
|
||||
"there is no version " + basis->toString() + " of the tool in '" +
|
||||
repo + "'. Available: " +
|
||||
(names.empty() ? std::string("none") : text::join(names, ", ")));
|
||||
}
|
||||
git::exportTree(repo, chosen ? chosen->name : "", dir);
|
||||
if (chosen)
|
||||
version = chosen->version.toString();
|
||||
}
|
||||
else if (sfs::is_directory(repo))
|
||||
{
|
||||
fs::copyTree(repo, dir, {".git"});
|
||||
}
|
||||
else
|
||||
{
|
||||
throw Error("'" + repo + "' is neither a git repository nor a directory");
|
||||
}
|
||||
Manifest manifest = readManifest(dir);
|
||||
if (version.empty())
|
||||
version = manifest.version.empty() ? "0.0.0" : manifest.version;
|
||||
return {dir, manifest, version};
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
fs::removeTree(tmp);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
void discard(const Fetched& f)
|
||||
{
|
||||
fs::removeTree(f.dir.parent_path());
|
||||
}
|
||||
|
||||
// Puts fetched files in the project and records them.
|
||||
void place(const Project& project, const Fetched& f, const std::string& repo)
|
||||
{
|
||||
sfs::path target = project.toolsDir() / f.manifest.id;
|
||||
fs::removeTree(target);
|
||||
std::error_code ec;
|
||||
sfs::create_directories(target.parent_path(), ec);
|
||||
fs::copyTree(f.dir, target);
|
||||
fs::makeExecutable(target / f.manifest.entry);
|
||||
json registry = loadRegistry(project);
|
||||
registry["tools"][f.manifest.id] = {{"repo", repo}, {"version", f.version}};
|
||||
saveRegistry(project, registry);
|
||||
}
|
||||
|
||||
Tool toolFrom(const Project& project, const std::string& id, const json& entry)
|
||||
{
|
||||
Tool t;
|
||||
t.id = id;
|
||||
t.repo = entry.value("repo", "");
|
||||
t.version = entry.value("version", "");
|
||||
t.dir = project.toolsDir() / id;
|
||||
if (std::optional<std::string> text = fs::tryReadText(t.dir / "breeze-tool.json"))
|
||||
{
|
||||
json j = json::parse(*text, nullptr, false);
|
||||
if (j.is_object())
|
||||
t.description = j.value("description", "");
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
Tool requireTool(const Project& project, const std::string& id)
|
||||
{
|
||||
json registry = loadRegistry(project);
|
||||
if (!registry["tools"].contains(id))
|
||||
throw Error("the tool '" + id +
|
||||
"' is not installed in this project (see `breeze tool ls`)");
|
||||
return toolFrom(project, id, registry["tools"][id]);
|
||||
}
|
||||
|
||||
Version versionOf(const Tool& t)
|
||||
{
|
||||
std::optional<Version> v = parseVersion(t.version);
|
||||
return v ? *v : Version{};
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Tool> installed(const Project& project)
|
||||
{
|
||||
std::vector<Tool> out;
|
||||
json registry = loadRegistry(project);
|
||||
for (auto it = registry["tools"].begin(); it != registry["tools"].end(); ++it)
|
||||
out.push_back(toolFrom(project, it.key(), it.value()));
|
||||
return out;
|
||||
}
|
||||
|
||||
void install(const Project& project, const std::string& repo)
|
||||
{
|
||||
Fetched f = fetch(repo, Pick::Newest, std::nullopt);
|
||||
try
|
||||
{
|
||||
json registry = loadRegistry(project);
|
||||
if (registry["tools"].contains(f.manifest.id))
|
||||
throw Error("the tool '" + f.manifest.id +
|
||||
"' is already installed (`breeze tool upgrade " + f.manifest.id + "`)");
|
||||
place(project, f, repo);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
discard(f);
|
||||
throw;
|
||||
}
|
||||
discard(f);
|
||||
std::cout << "installed " << f.manifest.id << " " << f.version << "\n";
|
||||
if (!f.manifest.description.empty())
|
||||
std::cout << " " << f.manifest.description << "\n";
|
||||
std::cout << "run it with: breeze tool " << f.manifest.id << " ...\n";
|
||||
}
|
||||
|
||||
void uninstall(const Project& project, const std::string& id)
|
||||
{
|
||||
Tool t = requireTool(project, id);
|
||||
fs::removeTree(t.dir);
|
||||
json registry = loadRegistry(project);
|
||||
registry["tools"].erase(id);
|
||||
saveRegistry(project, registry);
|
||||
std::error_code ec;
|
||||
sfs::remove(project.toolsDir(),
|
||||
ec); // only when empty; so is .breeze/ then, if nothing else is in it
|
||||
sfs::remove(project.metaDir(), ec);
|
||||
std::cout << "uninstalled " << id << "\n";
|
||||
}
|
||||
|
||||
void update(const Project& project, const std::string& id)
|
||||
{
|
||||
Tool t = requireTool(project, id);
|
||||
if (!git::isRepository(t.repo))
|
||||
{
|
||||
std::cout << id << " " << t.version
|
||||
<< " comes from a directory, which has no versions to look for\n";
|
||||
return;
|
||||
}
|
||||
Version current = versionOf(t);
|
||||
std::vector<git::Tag> tags = git::versionTags(t.repo);
|
||||
const git::Tag* sameMajor = nullptr;
|
||||
const git::Tag* newest = nullptr;
|
||||
for (const git::Tag& tag : tags)
|
||||
{
|
||||
if (!tag.version.pre.empty())
|
||||
continue;
|
||||
if (newest == nullptr || newest->version < tag.version)
|
||||
newest = &tag;
|
||||
if (tag.version.major == current.major &&
|
||||
(sameMajor == nullptr || sameMajor->version < tag.version))
|
||||
sameMajor = &tag;
|
||||
}
|
||||
std::cout << id << " " << t.version << " (" << t.repo << ")\n";
|
||||
bool behind = sameMajor != nullptr && current < sameMajor->version;
|
||||
if (behind)
|
||||
std::cout << "update available: " << sameMajor->version.toString()
|
||||
<< " (`breeze tool upgrade " << id << "`)\n";
|
||||
if (newest != nullptr && newest->version.major > current.major)
|
||||
std::cout << "a newer major version exists: " << newest->version.toString()
|
||||
<< " (`breeze tool migrate " << id << " " << newest->version.toString()
|
||||
<< "`)\n";
|
||||
if (!behind && (newest == nullptr || newest->version.major <= current.major))
|
||||
std::cout << "up to date\n";
|
||||
}
|
||||
|
||||
void upgrade(const Project& project, const std::string& id)
|
||||
{
|
||||
Tool t = requireTool(project, id);
|
||||
Version current = versionOf(t);
|
||||
Fetched f = fetch(t.repo, Pick::SameMajor, current);
|
||||
if (!(current < *parseVersion(f.version)) && git::isRepository(t.repo))
|
||||
{
|
||||
discard(f);
|
||||
std::cout << id << " " << t.version << " is the newest of its major version\n";
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (f.manifest.id != id)
|
||||
throw Error("the repository now says its tool is '" + f.manifest.id + "', not '" +
|
||||
id + "'");
|
||||
place(project, f, t.repo);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
discard(f);
|
||||
throw;
|
||||
}
|
||||
discard(f);
|
||||
std::cout << id << " " << t.version << " -> " << f.version << "\n";
|
||||
}
|
||||
|
||||
void migrate(const Project& project, const std::string& id, const std::string& version)
|
||||
{
|
||||
Tool t = requireTool(project, id);
|
||||
std::optional<Version> wanted = parseVersion(version);
|
||||
if (!wanted)
|
||||
throw Error("'" + version + "' is not a version (use 1.2.3)");
|
||||
if (!git::isRepository(t.repo))
|
||||
throw Error("'" + id + "' comes from a directory, which has only one version");
|
||||
Fetched f = fetch(t.repo, Pick::Exact, wanted);
|
||||
try
|
||||
{
|
||||
if (f.manifest.id != id)
|
||||
throw Error("that version says its tool is '" + f.manifest.id + "', not '" + id +
|
||||
"'");
|
||||
place(project, f, t.repo);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
discard(f);
|
||||
throw;
|
||||
}
|
||||
discard(f);
|
||||
std::cout << id << " " << t.version << " -> " << f.version << "\n";
|
||||
}
|
||||
|
||||
void list(const Project& project)
|
||||
{
|
||||
std::vector<Tool> all = installed(project);
|
||||
if (all.empty())
|
||||
{
|
||||
std::cout << "no tools installed (`breeze tool install <repository>`)\n";
|
||||
return;
|
||||
}
|
||||
std::size_t idWidth = 2;
|
||||
std::size_t versionWidth = 7;
|
||||
for (const Tool& t : all)
|
||||
{
|
||||
idWidth = std::max(idWidth, t.id.size());
|
||||
versionWidth = std::max(versionWidth, t.version.size());
|
||||
}
|
||||
for (const Tool& t : all)
|
||||
{
|
||||
std::cout << t.id << std::string(idWidth - t.id.size() + 2, ' ') << t.version
|
||||
<< std::string(versionWidth - t.version.size() + 2, ' ') << t.repo << "\n";
|
||||
if (!t.description.empty())
|
||||
std::cout << " " << t.description << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<int> run(const Project& project, const std::string& id,
|
||||
const std::vector<std::string>& args)
|
||||
{
|
||||
json registry = loadRegistry(project);
|
||||
if (!registry["tools"].contains(id))
|
||||
return std::nullopt;
|
||||
Tool t = toolFrom(project, id, registry["tools"][id]);
|
||||
Manifest manifest = readManifest(t.dir);
|
||||
sfs::path entry = t.dir / manifest.entry;
|
||||
|
||||
proc::Options options;
|
||||
options.cwd = project.root;
|
||||
GlobalConfig cfg = GlobalConfig::load();
|
||||
options.env = {
|
||||
{"BREEZE_PROJECT_ROOT", project.root.string()},
|
||||
{"BREEZE_SDK_DIR", project.sdk.dir.string()},
|
||||
{"BREEZE_SDK_VERSION", project.sdk.version ? project.sdk.version->toString() : ""},
|
||||
{"BREEZE_TOOL_ID", id},
|
||||
{"BREEZE_TOOL_DIR", t.dir.string()},
|
||||
{"BREEZE_CLI", fs::executablePath().string()},
|
||||
{"BREEZE_HOME", breezeHome().string()},
|
||||
{"BREEZE_EDITOR", cfg.effective(config::Editor).value_or("")}};
|
||||
std::string ext = text::lower(entry.extension().string());
|
||||
if (fs::hostPlatform() == "windows" && (ext == ".bat" || ext == ".cmd"))
|
||||
return proc::runScript(entry, args, options).exitCode;
|
||||
std::vector<std::string> argv{entry.string()};
|
||||
argv.insert(argv.end(), args.begin(), args.end());
|
||||
return proc::run(argv, options).exitCode;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#include "breeze/Version.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <charconv>
|
||||
|
||||
#include "breeze/Text.hpp"
|
||||
|
||||
namespace breeze
|
||||
{
|
||||
|
||||
std::string Version::toString() const
|
||||
{
|
||||
std::string s =
|
||||
std::to_string(major) + "." + std::to_string(minor) + "." + std::to_string(patch);
|
||||
if (!pre.empty())
|
||||
s += "-" + pre;
|
||||
return s;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
bool parseNumber(std::string_view s, int& out)
|
||||
{
|
||||
if (s.empty())
|
||||
return false;
|
||||
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out);
|
||||
return ec == std::errc() && ptr == s.data() + s.size() && out >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Version> parseVersion(std::string_view text)
|
||||
{
|
||||
std::string t = text::trim(text);
|
||||
if (!t.empty() && (t[0] == 'v' || t[0] == 'V'))
|
||||
t.erase(0, 1);
|
||||
if (std::size_t plus = t.find('+'); plus != std::string::npos)
|
||||
t.erase(plus);
|
||||
|
||||
Version v;
|
||||
if (std::size_t dash = t.find('-'); dash != std::string::npos)
|
||||
{
|
||||
v.pre = t.substr(dash + 1);
|
||||
t.erase(dash);
|
||||
if (v.pre.empty())
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<std::string> parts = text::split(t, '.');
|
||||
if (parts.empty() || parts.size() > 3)
|
||||
return std::nullopt;
|
||||
std::array<int*, 3> fields = {&v.major, &v.minor, &v.patch};
|
||||
for (std::size_t i = 0; i < parts.size(); ++i)
|
||||
{
|
||||
if (!parseNumber(parts[i], *fields[i]))
|
||||
return std::nullopt;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
int compare(const Version& a, const Version& b)
|
||||
{
|
||||
if (a.major != b.major)
|
||||
return a.major < b.major ? -1 : 1;
|
||||
if (a.minor != b.minor)
|
||||
return a.minor < b.minor ? -1 : 1;
|
||||
if (a.patch != b.patch)
|
||||
return a.patch < b.patch ? -1 : 1;
|
||||
if (a.pre.empty() != b.pre.empty())
|
||||
return a.pre.empty() ? 1 : -1; // a release is newer than its own pre-release
|
||||
if (a.pre != b.pre)
|
||||
return a.pre < b.pre ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool operator<(const Version& a, const Version& b)
|
||||
{
|
||||
return compare(a, b) < 0;
|
||||
}
|
||||
|
||||
bool operator==(const Version& a, const Version& b)
|
||||
{
|
||||
return compare(a, b) == 0;
|
||||
}
|
||||
|
||||
std::optional<Version> greatest(const std::vector<Version>& versions,
|
||||
std::optional<int> sameMajorAs)
|
||||
{
|
||||
std::optional<Version> best;
|
||||
for (const Version& v : versions)
|
||||
{
|
||||
if (sameMajorAs && v.major != *sameMajorAs)
|
||||
continue;
|
||||
if (!best || *best < v)
|
||||
best = v;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user