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:
2026-08-04 12:58:38 -04:00
parent 0aca4b49ca
commit e817e58698
9 changed files with 828 additions and 19 deletions
+310 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+7 -1
View File
@@ -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";