diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md new file mode 100644 index 0000000..e9d526f --- /dev/null +++ b/STYLEGUIDE.md @@ -0,0 +1,484 @@ +# Style Guide + +If you're reading this because you thought kappa's C++ looked different from +what you're used to — good. That's the point. + +This isn't a suggestion box. It's what the codebase looks like, and it's what +your code will look like after you've rewritten it three times because the PR +reviewer sent it back. Save yourself the rewrite. Read this first. + +--- + +## The Philosophy + +We write C++ like it's the year 2026 and the committee finally shipped +something usable. No polyfills. No third-party libraries. No Boost. The +standard library is sufficient for a package manager. If you disagree, you +haven't read `` closely enough. + +Every line of kappa assumes the reader is competent. We don't explain what +`std::string_view` is. We don't annotate obvious control flow. Comments exist +to explain *why*, never *what*. If your code needs a comment to be +understood, the code is wrong. + +Simplicity is a moral position. The scheduler is the hardest thing in this +codebase, and it's 290 lines. If your feature adds more than that, you're +building the wrong feature. + +--- + +## Naming + +### Structs, classes, enums + +```cpp +// PascalCase. Always. +struct BuildResult { }; +enum class TokenType : std::uint8_t { }; + +// Enum values are PascalCase too. This isn't Java. +enum class EnvMode : std::uint8_t { Set, Soft, Append }; + +// Acronyms stay capitalized. B-Tree is BTree, not Btree. +// Two-letter acronyms stay capitalized. ID, not Id. +``` + +Type names state what the thing *is*, not what it's *for*. `SchedResult`, not +`ResultForScheduler`. `InitPaths`, not `PathsForInitSystems`. + +### Variables and functions + +```cpp +// snake_case. No Hungarian notation. No m_ prefix. No s_ prefix. +int pending_deps = 0; +void compute_depths(Scheduler& s); +std::string_view token_name(TokenType type); +``` + +Member variables and locals look identical. If you can't tell them apart, +your functions are too long. Fix the function. + +### Files + +``` +src/service/openrc.cpp # snake_case, lowercase +include/kappa/service/types.hpp # .hpp for headers, .cpp for source +``` + +One public class per header is a myth invented by Java developers. Group +related declarations. `types.hpp` holds all enums and structs for a module. +If a module has one public struct and one public function, they go in the +same header. + +--- + +## Formatting + +### Indentation and braces + +Four spaces. Attached braces (a K&R variant). + +```cpp +// ✓ yes — brace on the same line as the control structure +if (pid < 0) { + return -1; +} + +// ✗ no — Allman/BSD braces on their own line +if (pid < 0) +{ + return -1; +} + +// ✗ no — missing braces on single-statement bodies +if (pid < 0) return -1; +``` + +Always braces. Even for single statements. The compiler doesn't care. +The human reviewing your diff at 2 AM does. clang-tidy will flag bare +bodies — apply the fix every time. + +### Line length + +100 columns. Not 80 — we're not teletypes. Not 120 — if you need 120 +characters to express a thought, your thought is too complicated. Split it. + +### Section separators + +```cpp +// --- Section description --- +// or +// --------------------------------------------------------------------------- +// Longer section description spanning the full runway +// --------------------------------------------------------------------------- +``` + +Thin lines (`---`) for sub-sections within a file. Thick lines +(`-----------`) for top-level section boundaries. The difference communicates +hierarchy without nesting. + +### Switch cases + +```cpp +switch (is) { +case InitSystem::Systemd: + return generate_systemd_service(spec); +case InitSystem::S6: + return generate_s6_service(spec); +case InitSystem::Unknown: +default: + return {}; +} +``` + +Cases at the same indentation as the switch. No blocks around single-return +cases. Break or return in every non-fallthrough case. If you're falling +through intentionally, wrap it with `[[fallthrough]];` on a line by itself. + +--- + +## Types + +### Use `auto` when the type is obvious, explicit when it isn't + +```cpp +// ✓ yes — type is obvious from initialization +auto scope = eval::make_default_scope(); +auto& pkg = registry.at(name); + +// ✓ yes — structured bindings, type is obvious +for (auto& [key, val] : features) { } + +// ✓ yes — explicit where the type carries meaning +std::unique_lock lock(s.mtx); // not auto lock = ... +std::uint64_t h = 14695981039346656037ULL; // not auto h = ... +``` + +### `const` is the default + +Everything is `const` until proven mutable. + +```cpp +// ✓ yes +const auto& step = plan.steps[i]; +for (const auto& entry : entries) { } + +// ✗ no — mutable when it shouldn't be +auto& step = plan.steps[i]; +``` + +### View types over owning types in parameters + +```cpp +// ✓ yes +void set_root(std::string_view path); +bool is_supported(std::string_view name); + +// ✗ no +void set_root(const std::string& path); +``` + +Return owning types. Accept views. The caller decides ownership. You decide +what you need to read. + +### Strong enums only + +```cpp +// ✓ yes +enum class InitSystem : std::uint8_t { Systemd, OpenRC, S6 }; + +// ✗ no +enum InitSystem { INIT_SYSTEMD, INIT_OPENRC, INIT_S6 }; +``` + +No unscoped enums. No ALL_CAPS enum values. No integer conversions without +explicit intent. If you need to serialize an enum to an integer, write a +`to_string` function. The enum's numeric value is an implementation detail, +not an interface. + +--- + +## Functions + +### One responsibility per function + +If your function name contains the word "and", it does at least two things +and needs to be split. If the body doesn't fit on one screen, it does too +much. "One screen" means approximately 30 lines. The scheduler's +`compute_depths` is 30 lines. So is the resolver's `resolve`. They're at the +upper bound. If yours is longer, you're doing something wrong. + +### Error handling: return, don't throw + +```cpp +// ✓ yes +struct FetchResult { + std::filesystem::path work_dir; + std::string error; + bool ok() const { return error.empty(); } +}; + +FetchResult fetch(const PackageDef& pkg); + +// ✗ no +void fetch(const PackageDef& pkg); // throws on error +``` + +Exceptions are for unrecoverable programmer errors — out-of-memory, null +dereference, violated invariants. They belong in constructors and in the +parser (where `ParseError` is the only way to unwind back to diagnostics). + +Every operational failure — network down, disk full, configure script failed, +hash mismatch — is a return value. A struct with `bool ok` and +`std::string error`. Check the `ok` field, read the `error` string, don't +catch exceptions for normal operation. + +### Return early, return often + +```cpp +// ✓ yes +if (to.empty()) { + return {false, "destination is empty"}; +} +// ... main logic ... + +// ✗ no +if (!to.empty()) { + // ... 40 lines of nesting ... +} else { + return {false, "destination is empty"}; +} +``` + +Guard clauses at the top. Happy path straight down the left margin. If your +code has three levels of nesting, you missed an early return opportunity. + +### Static helpers over lambdas + +If a helper is more than 5 lines, extract it to a file-static function above +the public API. Named functions are greppable. Named functions show up in +stack traces. Lambdas don't. The one exception is a `run_phase` lambda in +`build()` — it captures local state that would require a 5-parameter helper +and it's clearly a one-off control flow wrapper, not a reusable abstraction. + +--- + +## Namespaces + +```cpp +namespace kappa::module { + +// Everything goes here. + +} // namespace kappa::module +``` + +C++17 nested namespace syntax. Closing brace gets a comment with the +namespace name. These comments survive diffs, refactors, and editors that +collapse braces. They cost one line and save ten minutes of scrolling up to +figure out which brace closes what. + +### No `using namespace` at file scope + +```cpp +// ✓ yes — inside a function +namespace fs = std::filesystem; + +// ✗ no — at file scope +using namespace std; +``` + +Namespace aliases are acceptable inside functions — `namespace fs = +std::filesystem;` is fine when the file does a lot of path manipulation. But +at file scope? No. You're not writing `using namespace std;` at the top of a +header and you're not doing the subtler version of the same sin. + +--- + +## Headers + +```cpp +#pragma once + +#include "kappa/resolve/plan.hpp" +#include "kappa/dsl/ast.hpp" + +#include +#include + +namespace kappa::build { + +struct BuildResult { }; + +BuildResult build(const resolve::BuildStep& step, + const std::string& work_dir, + int jobs); + +} // namespace kappa::build +``` + +`#pragma once` at the top. No include guards. This is 2026. + +Project headers first, in quotes. System headers second, in angle brackets. +Blank line between the two groups. Alphabetical within each group. + +Headers include only what they need to compile. If `build.hpp` uses +`resolve::BuildStep` by reference, it includes `resolve/plan.hpp`. It does +not forward-declare `BuildStep` — we don't forward-declare across module +boundaries. The include is the contract: "this module depends on that one." + +Headers never contain implementation. No `inline` functions. No +template definitions in headers (we don't use templates). The one exception +is `parse_util.hpp`, which defines `ParseError` inline because it's a thin +exception wrapper and splitting it would be ceremony for ceremony's sake. +One exception per codebase is a pattern. Two is a problem. + +--- + +## Modules + +Every module follows this structure: + +``` +include/kappa/{module}/ +├── types.hpp # enums, structs, parse/validate declarations +├── {feature}.hpp # public function declarations + +src/{module}/ +├── types.cpp # parse/validate/describe implementations +├── backend_a.cpp # per-variant generation (if applicable) +├── backend_b.cpp +└── install.cpp # dispatch + orchestration (if applicable) +``` + +If a module doesn't need `types.hpp` (single struct, single function), both +go in `{feature}.hpp`. If a module has no backends, skip them. But don't +invent a third pattern. `service/` and `boot/` are the templates. Copy them. + +--- + +## Strings and formatting + +```cpp +// ✓ yes +auto msg = std::format("building {} (depth={})", name, depth); +result.error = std::format("command exited with code {}: {}", rc, cmd); + +// ✗ no — ostringstream for trivial concatenation +std::ostringstream oss; +oss << "building " << name << " (depth=" << depth << ")"; + +// ✓ yes — ostringstream for incremental construction +std::ostringstream out; +out << "[Unit]\n"; +out << std::format("Description={}\n", desc); +``` + +`std::format` for one-shot strings. `std::ostringstream` for building up +output incrementally (service files, bootloader configs, formatter output). +String concatenation with `+` is acceptable for two or three pieces. +Anything more goes through `std::format`. + +### String views for parameters + +```cpp +// ✓ yes +InitSystem parse_init_system(std::string_view name); +void print_error(std::ostream& os, std::string_view source, + SourceLocation loc, std::string_view message); + +// ✗ no +InitSystem parse_init_system(const std::string& name); +``` + +Views everywhere, except when you need to store the string. + +--- + +## The DSL + +The `.kap` DSL grammar is the contract. You can extend it. You cannot break +existing configs. Every new token type requires: +1. An entry in `TokenType` +2. A case in `token_name()` +3. Parsing logic in the appropriate parser +4. A formatting case in `format.cpp` +5. At least one test in `test.sh` that exercises the new syntax + +If you're adding a keyword, think twice. The lexer already has 29 token +types. Every new one increases parse time and mental overhead. Can this be +expressed with the existing grammar? If yes, don't add a keyword. + +--- + +## Thread safety + +The scheduler is multithreaded. If you touch shared state, you own the lock. + +```cpp +{ + std::unique_lock lock(s.mtx); + s.waiting.erase(idx); +} +// lock released here — no shared state access beyond this point +``` + +Use scoped locks. Never lock/unlock manually. Never hold a lock across a +condition variable wait without understanding why. If you think you need +`memory_order_release`, you probably need `memory_order_acq_rel` and you +should document why in a three-line comment above the operation. + +If a data structure is touched by multiple threads, its access pattern must +be documented at the declaration site, not in a PR description. "This is +only written under the lock, read atomically elsewhere" goes in the header. + +--- + +## What clang-tidy enforces + +We run with `-Wall -Wextra -Wpedantic` and a `.clang-tidy` config. Zero +warnings. Not "zero warnings except for that one file." Zero. + +The following are non-negotiable: +- Every `if`/`for`/`while` body has braces +- `auto` variables that are never modified are `const auto` +- Variables are initialized at declaration +- No unused includes +- No redundant declarations + +If clang-tidy suggests a fix and you disagree, you're wrong. Apply the fix. +The only acceptable override is `// NOLINT` with a justification comment — +and if you write that more than twice in a file, the reviewer will ask you to +rethink your design. + +--- + +## What we reject + +- **Comments that narrate the code.** `// Increment counter` above `i++` + is an insult. Delete it. + +- **Dead code.** No commented-out blocks. No `#if 0`. If it's not used, + it doesn't exist. Git remembers. + +- **Premature abstraction.** Three identical lines do not need a + function. Ten do. The threshold is somewhere in between and you should + err on the side of duplication. + +- **C heritage.** `printf`, `malloc`, `NULL`, raw `char*` strings, + `#define` constants. The 1970s called. Don't answer. + +- **Over-engineering.** The build backend doesn't need a plugin + architecture. The lexer doesn't need a state machine framework. + Solve the problem in front of you, not the one you imagine someone + might have in three years. + +- **Cleverness.** If your solution makes you feel smart, it's wrong. + The best code is the code you forget about because it never breaks. + +--- + +Kappa does one thing: build your system from source, init-agnostically. +Everything in this style guide exists to keep that codebase small, fast, and +comprehensible. If a rule conflicts with that goal, the goal wins — but +you'd better have a good reason, and you'd better write it down. diff --git a/include/kappa/build/build.hpp b/include/kappa/build/build.hpp index 0de7824..4dea4ae 100644 --- a/include/kappa/build/build.hpp +++ b/include/kappa/build/build.hpp @@ -13,9 +13,8 @@ struct BuildResult { }; // Build a single package step into work_dir. -// -// NOTE: compile-only stub — the real build backend was never committed. -// Always fails so the rebuild pipeline never records a fake install. +// Runs prepare, build, check, and install phases in order, +// with full variable interpolation and config file generation. BuildResult build(const resolve::BuildStep& step, const std::string& work_dir, int jobs); diff --git a/include/kappa/dsl/ast.hpp b/include/kappa/dsl/ast.hpp index dfba282..8035c2c 100644 --- a/include/kappa/dsl/ast.hpp +++ b/include/kappa/dsl/ast.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -38,10 +39,12 @@ struct Patch { int level = 1; }; +enum class EnvMode : std::uint8_t { Set, Soft, Append }; + struct EnvEntry { std::string key; std::string value; - bool soft = false; + EnvMode mode = EnvMode::Set; }; struct NamedService { diff --git a/include/kappa/dsl/token.hpp b/include/kappa/dsl/token.hpp index 636829b..b25f8f6 100644 --- a/include/kappa/dsl/token.hpp +++ b/include/kappa/dsl/token.hpp @@ -13,6 +13,7 @@ enum class TokenType { Lbrace, // { Rbrace, // } Equals, // = + Plus, // + Lbracket, // [ Rbracket, // ] Comma, // , diff --git a/src/build/build.cpp b/src/build/build.cpp index 0778bcc..5391561 100644 --- a/src/build/build.cpp +++ b/src/build/build.cpp @@ -1,17 +1,323 @@ #include "kappa/build/build.hpp" +#include "kappa/eval/vars.hpp" +#include +#include #include +#include +#include +#include +#include +#include +#include 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& 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 capture_env() { + std::unordered_map 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& env, + const std::vector& 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; } diff --git a/src/dsl/lexer.cpp b/src/dsl/lexer.cpp index f82dd0f..5949de1 100644 --- a/src/dsl/lexer.cpp +++ b/src/dsl/lexer.cpp @@ -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(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_}; diff --git a/src/dsl/parser.cpp b/src/dsl/parser.cpp index e9ade7f..c691dad 100644 --- a/src/dsl/parser.cpp +++ b/src/dsl/parser.cpp @@ -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); diff --git a/src/dsl/system.cpp b/src/dsl/system.cpp index 468c724..d4d22ec 100644 --- a/src/dsl/system.cpp +++ b/src/dsl/system.cpp @@ -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); diff --git a/src/tools/format.cpp b/src/tools/format.cpp index 5641dec..6785ffa 100644 --- a/src/tools/format.cpp +++ b/src/tools/format.cpp @@ -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";