feat: implement build backend and += env operator, add style guide
- Replace build stub with full phase execution (prepare/build/check/install) via /bin/sh with variable interpolation, config file generation, and env handling (hard-set, soft/?=, append/+=) - Add += append operator for environment variables across DSL, parser, system config parser, build backend, and formatter - Add STYLEGUIDE.md documenting all codebase conventions - Replace EnvEntry bool soft with EnvMode enum (Set/Soft/Append) - Add Plus token type to lexer for += parsing
This commit is contained in:
+310
-4
@@ -1,17 +1,323 @@
|
||||
#include "kappa/build/build.hpp"
|
||||
#include "kappa/eval/vars.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <format>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <string_view>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace kappa::build {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell execution — runs a command through /bin/sh in the given cwd.
|
||||
// Environment variables are applied before exec.
|
||||
// Returns exit code, or -1 if fork/exec fails.
|
||||
// ---------------------------------------------------------------------------
|
||||
static int exec_sh(const std::string& cmd,
|
||||
const std::unordered_map<std::string, std::string>& env,
|
||||
const fs::path& cwd) {
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
for (const auto& [k, v] : env) {
|
||||
setenv(k.c_str(), v.c_str(), 1);
|
||||
}
|
||||
if (!cwd.empty()) {
|
||||
std::error_code ec;
|
||||
fs::current_path(cwd, ec);
|
||||
}
|
||||
execl("/bin/sh", "sh", "-c", cmd.c_str(), nullptr);
|
||||
_exit(127);
|
||||
}
|
||||
if (pid < 0) { return -1; }
|
||||
|
||||
int status = 0;
|
||||
pid_t w;
|
||||
do {
|
||||
w = waitpid(pid, &status, 0);
|
||||
} while (w == -1 && errno == EINTR);
|
||||
|
||||
return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config-file variable resolution helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
struct CfResolved {
|
||||
std::string value;
|
||||
bool skip_line = false;
|
||||
};
|
||||
|
||||
// Parse "${cfg.port ? 8080}" suffix after the variable name.
|
||||
// Extracts the modifier (?, !) and default value from inner text after a space.
|
||||
static void parse_cf_suffix(std::string_view& inner,
|
||||
bool& optional,
|
||||
bool& required,
|
||||
std::string_view& default_val) {
|
||||
auto space = inner.find(' ');
|
||||
if (space == std::string_view::npos) { return; }
|
||||
|
||||
auto suffix = inner.substr(space + 1);
|
||||
while (!suffix.empty() && suffix.front() == ' ') {
|
||||
suffix.remove_prefix(1);
|
||||
}
|
||||
if (suffix.empty()) { return; }
|
||||
|
||||
if (suffix.front() == '?') {
|
||||
optional = true;
|
||||
default_val = suffix.substr(1);
|
||||
while (!default_val.empty() && default_val.front() == ' ') {
|
||||
default_val.remove_prefix(1);
|
||||
}
|
||||
} else if (suffix.front() == '!') {
|
||||
required = true;
|
||||
}
|
||||
inner = inner.substr(0, space);
|
||||
}
|
||||
|
||||
static void trim_trailing_spaces(std::string_view& s) {
|
||||
while (!s.empty() && s.back() == ' ') {
|
||||
s.remove_suffix(1);
|
||||
}
|
||||
}
|
||||
|
||||
static CfResolved resolve_cf_value(std::string_view raw,
|
||||
const eval::Scope& scope) {
|
||||
CfResolved result;
|
||||
std::string out;
|
||||
out.reserve(raw.size());
|
||||
std::size_t i = 0;
|
||||
|
||||
while (i < raw.size()) {
|
||||
if (raw[i] != '$' || i + 1 >= raw.size() || raw[i + 1] != '{') {
|
||||
out += raw[i];
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
|
||||
auto end = raw.find('}', i + 2);
|
||||
if (end == std::string_view::npos) {
|
||||
out += raw.substr(i);
|
||||
break;
|
||||
}
|
||||
|
||||
auto inner = raw.substr(i + 2, end - (i - 2));
|
||||
|
||||
bool optional = false;
|
||||
bool required = false;
|
||||
std::string_view default_val;
|
||||
parse_cf_suffix(inner, optional, required, default_val);
|
||||
trim_trailing_spaces(inner);
|
||||
|
||||
auto rv = eval::resolve(inner, scope);
|
||||
|
||||
if (rv.kind == eval::VarKind::Unknown) {
|
||||
if (optional) {
|
||||
out += default_val;
|
||||
} else if (required) {
|
||||
result.skip_line = true;
|
||||
return result;
|
||||
}
|
||||
} else {
|
||||
out += rv.value;
|
||||
}
|
||||
|
||||
i = end + 1;
|
||||
}
|
||||
|
||||
result.value = std::move(out);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write a config file to destdir.
|
||||
// "default" → skip if file exists; "replace" → always overwrite;
|
||||
// "merge" → not yet implemented, falls back to replace.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void write_config_file(const dsl::ConfigFile& cf,
|
||||
const eval::Scope& scope,
|
||||
const fs::path& destdir) {
|
||||
auto path = destdir / cf.path;
|
||||
|
||||
if (cf.mode == "default" && fs::exists(path)) { return; }
|
||||
|
||||
std::error_code ec;
|
||||
fs::create_directories(path.parent_path(), ec);
|
||||
if (ec) { return; }
|
||||
|
||||
std::ofstream out(path);
|
||||
if (!out) { return; }
|
||||
|
||||
out << "# Generated by kappa — do not edit manually\n";
|
||||
for (const auto& [key, raw_val] : cf.entries) {
|
||||
auto resolved = resolve_cf_value(raw_val, scope);
|
||||
if (resolved.skip_line) { continue; }
|
||||
const auto& val = resolved.value;
|
||||
if (val.contains(' ') || val.empty()) {
|
||||
out << key << " = \"" << val << "\"\n";
|
||||
} else {
|
||||
out << key << " = " << val << "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build variable scope from a resolved build step.
|
||||
// ---------------------------------------------------------------------------
|
||||
static eval::Scope build_scope(const resolve::BuildStep& step,
|
||||
const fs::path& destdir,
|
||||
int jobs) {
|
||||
eval::Scope scope = eval::make_default_scope();
|
||||
|
||||
const auto& pkg = *step.package;
|
||||
const auto& resolved = step.resolved;
|
||||
|
||||
scope.builtins["prefix"] = resolved.config.contains("prefix")
|
||||
? resolved.config.at("prefix") : "/usr";
|
||||
scope.builtins["jobs"] = std::to_string(jobs);
|
||||
scope.builtins["jobopts"] = std::format("-j{}", jobs);
|
||||
scope.builtins["destdir"] = destdir.string();
|
||||
scope.builtins["enabledinit"] = step.enabled_init;
|
||||
|
||||
scope.config = resolved.config;
|
||||
if (!scope.config.contains("prefix")) {
|
||||
scope.config["prefix"] = scope.builtins["prefix"];
|
||||
}
|
||||
|
||||
scope.features = resolved.features;
|
||||
|
||||
scope.package["name"] = pkg.name;
|
||||
scope.package["version"] = pkg.version;
|
||||
scope.package["source"] = pkg.source;
|
||||
|
||||
return scope;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot current process environment into a map.
|
||||
// ---------------------------------------------------------------------------
|
||||
static std::unordered_map<std::string, std::string> capture_env() {
|
||||
std::unordered_map<std::string, std::string> env;
|
||||
for (char** envp = ::environ; *envp != nullptr; ++envp) {
|
||||
std::string_view entry(*envp);
|
||||
auto eq = entry.find('=');
|
||||
if (eq != std::string_view::npos) {
|
||||
env.emplace(std::string(entry.substr(0, eq)),
|
||||
std::string(entry.substr(eq + 1)));
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply package env block to a mutable env map.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void apply_package_env(
|
||||
std::unordered_map<std::string, std::string>& env,
|
||||
const std::vector<dsl::EnvEntry>& entries) {
|
||||
for (const auto& e : entries) {
|
||||
switch (e.mode) {
|
||||
case dsl::EnvMode::Append: {
|
||||
auto it = env.find(e.key);
|
||||
if (it != env.end() && !it->second.empty()) {
|
||||
it->second += ' ';
|
||||
}
|
||||
env[e.key] += e.value;
|
||||
break;
|
||||
}
|
||||
case dsl::EnvMode::Soft:
|
||||
env.try_emplace(e.key, e.value);
|
||||
break;
|
||||
case dsl::EnvMode::Set:
|
||||
env[e.key] = e.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — build a single package.
|
||||
// ---------------------------------------------------------------------------
|
||||
BuildResult build(const resolve::BuildStep& step,
|
||||
const std::string& work_dir,
|
||||
int jobs) {
|
||||
BuildResult result;
|
||||
result.phase = "build";
|
||||
result.error = std::format(
|
||||
"build backend not implemented (stub): {} in {} (jobs={})",
|
||||
step.name, work_dir, jobs);
|
||||
const auto& pkg = *step.package;
|
||||
|
||||
// Workspace
|
||||
fs::path work(work_dir);
|
||||
fs::path destdir = work / "destdir";
|
||||
std::error_code ec;
|
||||
fs::create_directories(destdir, ec);
|
||||
if (ec) {
|
||||
result.phase = "setup";
|
||||
result.error = std::format("cannot create destdir {}: {}",
|
||||
destdir.string(), ec.message());
|
||||
return result;
|
||||
}
|
||||
|
||||
// Variable scope
|
||||
auto scope = build_scope(step, destdir, jobs);
|
||||
|
||||
// Environment
|
||||
auto env = capture_env();
|
||||
apply_package_env(env, pkg.env_entries);
|
||||
|
||||
for (const auto& e : pkg.env_entries) {
|
||||
switch (e.mode) {
|
||||
case dsl::EnvMode::Append: {
|
||||
const char* existing = getenv(e.key.c_str());
|
||||
std::string val = existing ? std::string(existing) + " " + e.value : e.value;
|
||||
setenv(e.key.c_str(), val.c_str(), 1);
|
||||
break;
|
||||
}
|
||||
case dsl::EnvMode::Soft:
|
||||
setenv(e.key.c_str(), e.value.c_str(), 0);
|
||||
break;
|
||||
case dsl::EnvMode::Set:
|
||||
setenv(e.key.c_str(), e.value.c_str(), 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Config files
|
||||
for (const auto& cf : pkg.config_files) {
|
||||
write_config_file(cf, scope, destdir);
|
||||
}
|
||||
|
||||
// Phases
|
||||
auto run_phase = [&](const dsl::Phase& phase,
|
||||
const char* phase_name) -> bool {
|
||||
if (phase.commands.empty()) { return true; }
|
||||
|
||||
for (const auto& raw_cmd : phase.commands) {
|
||||
auto cmd = eval::interpolate(raw_cmd, scope);
|
||||
if (cmd.empty()) { continue; }
|
||||
|
||||
std::cout << std::format(" [{}] {}\n", phase_name, cmd);
|
||||
int rc = exec_sh(cmd, env, work);
|
||||
if (rc != 0) {
|
||||
result.phase = phase_name;
|
||||
result.error = std::format(
|
||||
"command exited with code {}: {}", rc, cmd);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!run_phase(pkg.prepare, "prepare")) { return result; }
|
||||
if (!run_phase(pkg.build, "build")) { return result; }
|
||||
if (!run_phase(pkg.check, "check")) { return result; }
|
||||
if (!run_phase(pkg.install, "install")) { return result; }
|
||||
|
||||
result.ok = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -42,6 +42,7 @@ std::string_view token_name(TokenType type) {
|
||||
case TokenType::Lbrace: return "{";
|
||||
case TokenType::Rbrace: return "}";
|
||||
case TokenType::Equals: return "=";
|
||||
case TokenType::Plus: return "+";
|
||||
case TokenType::Lbracket: return "[";
|
||||
case TokenType::Rbracket: return "]";
|
||||
case TokenType::Comma: return ",";
|
||||
@@ -143,7 +144,7 @@ Token Lexer::next() {
|
||||
return {TokenType::Newline, "\n", token_start_line_, token_start_col_};
|
||||
}
|
||||
if (c == '"') { return scan_string(); }
|
||||
if (c == '{' || c == '}' || c == '=' || c == '[' || c == ']' || c == ',') {
|
||||
if (c == '{' || c == '}' || c == '=' || c == '+' || c == '[' || c == ']' || c == ',') {
|
||||
return scan_symbol();
|
||||
}
|
||||
return scan_ident();
|
||||
@@ -155,7 +156,7 @@ Token Lexer::scan_ident() {
|
||||
while (pos_ < source_.size()) {
|
||||
char c = peek();
|
||||
if (std::isspace(static_cast<unsigned char>(c))) { break; }
|
||||
if (c == '"' || c == '=' || c == '[' || c == ']' || c == ',') { break; }
|
||||
if (c == '"' || c == '=' || c == '+' || c == '[' || c == ']' || c == ',') { break; }
|
||||
lexeme += advance();
|
||||
}
|
||||
|
||||
@@ -197,6 +198,7 @@ Token Lexer::scan_symbol() {
|
||||
case '{': return {TokenType::Lbrace, "{", token_start_line_, token_start_col_};
|
||||
case '}': return {TokenType::Rbrace, "}", token_start_line_, token_start_col_};
|
||||
case '=': return {TokenType::Equals, "=", token_start_line_, token_start_col_};
|
||||
case '+': return {TokenType::Plus, "+", token_start_line_, token_start_col_};
|
||||
case '[': return {TokenType::Lbracket, "[", token_start_line_, token_start_col_};
|
||||
case ']': return {TokenType::Rbracket, "]", token_start_line_, token_start_col_};
|
||||
case ',': return {TokenType::Comma, ",", token_start_line_, token_start_col_};
|
||||
|
||||
+8
-4
@@ -224,16 +224,20 @@ void Parser::parse_body(PackageDef& pkg) {
|
||||
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
|
||||
if (at(TokenType::Newline)) { advance(); continue; }
|
||||
auto key = consume(TokenType::Ident).lexeme;
|
||||
bool soft = false;
|
||||
if (at(TokenType::Ident) && current_.lexeme == "?") {
|
||||
EnvMode mode = EnvMode::Set;
|
||||
if (at(TokenType::Plus)) {
|
||||
advance();
|
||||
consume(TokenType::Equals);
|
||||
soft = true;
|
||||
mode = EnvMode::Append;
|
||||
} else if (at(TokenType::Ident) && current_.lexeme == "?") {
|
||||
advance();
|
||||
consume(TokenType::Equals);
|
||||
mode = EnvMode::Soft;
|
||||
} else {
|
||||
consume(TokenType::Equals);
|
||||
}
|
||||
pkg.env_entries.push_back(
|
||||
{std::move(key), consume(TokenType::String).lexeme, soft});
|
||||
{std::move(key), consume(TokenType::String).lexeme, mode});
|
||||
skip_newlines();
|
||||
}
|
||||
consume(TokenType::Rbrace);
|
||||
|
||||
+8
-4
@@ -214,15 +214,19 @@ void SysParser::parse_system_block(SystemConfig& cfg) {
|
||||
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
|
||||
if (at(TokenType::Newline)) { advance(); continue; }
|
||||
auto k = consume_ident();
|
||||
bool soft = false;
|
||||
if (at(TokenType::Ident) && current_.lexeme == "?") {
|
||||
EnvMode mode = EnvMode::Set;
|
||||
if (at(TokenType::Plus)) {
|
||||
advance();
|
||||
consume(TokenType::Equals);
|
||||
soft = true;
|
||||
mode = EnvMode::Append;
|
||||
} else if (at(TokenType::Ident) && current_.lexeme == "?") {
|
||||
advance();
|
||||
consume(TokenType::Equals);
|
||||
mode = EnvMode::Soft;
|
||||
} else {
|
||||
consume(TokenType::Equals);
|
||||
}
|
||||
cfg.system.env.push_back({std::move(k), consume_string(), soft});
|
||||
cfg.system.env.push_back({std::move(k), consume_string(), mode});
|
||||
skip_newlines();
|
||||
}
|
||||
consume(TokenType::Rbrace);
|
||||
|
||||
@@ -18,7 +18,13 @@ static void write_env(std::ostream& os, int d,
|
||||
if (entries.empty()) { return; }
|
||||
os << Indent(d) << "env {\n";
|
||||
for (auto& e : entries) {
|
||||
os << Indent(d + 1) << e.key << (e.soft ? " ?= " : " = ")
|
||||
const char* op;
|
||||
switch (e.mode) {
|
||||
case dsl::EnvMode::Soft: op = " ?= "; break;
|
||||
case dsl::EnvMode::Append: op = " += "; break;
|
||||
default: op = " = "; break;
|
||||
}
|
||||
os << Indent(d + 1) << e.key << op
|
||||
<< '"' << e.value << "\"\n";
|
||||
}
|
||||
os << Indent(d) << "}\n";
|
||||
|
||||
Reference in New Issue
Block a user