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:
+484
@@ -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 `<format>` 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 <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -13,9 +13,8 @@ struct BuildResult {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build a single package step into work_dir.
|
// Build a single package step into work_dir.
|
||||||
//
|
// Runs prepare, build, check, and install phases in order,
|
||||||
// NOTE: compile-only stub — the real build backend was never committed.
|
// with full variable interpolation and config file generation.
|
||||||
// Always fails so the rebuild pipeline never records a fake install.
|
|
||||||
BuildResult build(const resolve::BuildStep& step,
|
BuildResult build(const resolve::BuildStep& step,
|
||||||
const std::string& work_dir,
|
const std::string& work_dir,
|
||||||
int jobs);
|
int jobs);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
@@ -38,10 +39,12 @@ struct Patch {
|
|||||||
int level = 1;
|
int level = 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum class EnvMode : std::uint8_t { Set, Soft, Append };
|
||||||
|
|
||||||
struct EnvEntry {
|
struct EnvEntry {
|
||||||
std::string key;
|
std::string key;
|
||||||
std::string value;
|
std::string value;
|
||||||
bool soft = false;
|
EnvMode mode = EnvMode::Set;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct NamedService {
|
struct NamedService {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ enum class TokenType {
|
|||||||
Lbrace, // {
|
Lbrace, // {
|
||||||
Rbrace, // }
|
Rbrace, // }
|
||||||
Equals, // =
|
Equals, // =
|
||||||
|
Plus, // +
|
||||||
Lbracket, // [
|
Lbracket, // [
|
||||||
Rbracket, // ]
|
Rbracket, // ]
|
||||||
Comma, // ,
|
Comma, // ,
|
||||||
|
|||||||
+309
-3
@@ -1,17 +1,323 @@
|
|||||||
#include "kappa/build/build.hpp"
|
#include "kappa/build/build.hpp"
|
||||||
|
#include "kappa/eval/vars.hpp"
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
#include <format>
|
#include <format>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string_view>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
namespace kappa::build {
|
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,
|
BuildResult build(const resolve::BuildStep& step,
|
||||||
const std::string& work_dir,
|
const std::string& work_dir,
|
||||||
int jobs) {
|
int jobs) {
|
||||||
BuildResult result;
|
BuildResult result;
|
||||||
result.phase = "build";
|
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(
|
result.error = std::format(
|
||||||
"build backend not implemented (stub): {} in {} (jobs={})",
|
"command exited with code {}: {}", rc, cmd);
|
||||||
step.name, work_dir, jobs);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -42,6 +42,7 @@ std::string_view token_name(TokenType type) {
|
|||||||
case TokenType::Lbrace: return "{";
|
case TokenType::Lbrace: return "{";
|
||||||
case TokenType::Rbrace: return "}";
|
case TokenType::Rbrace: return "}";
|
||||||
case TokenType::Equals: return "=";
|
case TokenType::Equals: return "=";
|
||||||
|
case TokenType::Plus: return "+";
|
||||||
case TokenType::Lbracket: return "[";
|
case TokenType::Lbracket: return "[";
|
||||||
case TokenType::Rbracket: return "]";
|
case TokenType::Rbracket: return "]";
|
||||||
case TokenType::Comma: return ",";
|
case TokenType::Comma: return ",";
|
||||||
@@ -143,7 +144,7 @@ Token Lexer::next() {
|
|||||||
return {TokenType::Newline, "\n", token_start_line_, token_start_col_};
|
return {TokenType::Newline, "\n", token_start_line_, token_start_col_};
|
||||||
}
|
}
|
||||||
if (c == '"') { return scan_string(); }
|
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_symbol();
|
||||||
}
|
}
|
||||||
return scan_ident();
|
return scan_ident();
|
||||||
@@ -155,7 +156,7 @@ Token Lexer::scan_ident() {
|
|||||||
while (pos_ < source_.size()) {
|
while (pos_ < source_.size()) {
|
||||||
char c = peek();
|
char c = peek();
|
||||||
if (std::isspace(static_cast<unsigned char>(c))) { break; }
|
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();
|
lexeme += advance();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +198,7 @@ Token Lexer::scan_symbol() {
|
|||||||
case '{': return {TokenType::Lbrace, "{", token_start_line_, token_start_col_};
|
case '{': return {TokenType::Lbrace, "{", token_start_line_, token_start_col_};
|
||||||
case '}': return {TokenType::Rbrace, "}", 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::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::Lbracket, "[", token_start_line_, token_start_col_};
|
||||||
case ']': return {TokenType::Rbracket, "]", 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_};
|
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)) {
|
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
|
||||||
if (at(TokenType::Newline)) { advance(); continue; }
|
if (at(TokenType::Newline)) { advance(); continue; }
|
||||||
auto key = consume(TokenType::Ident).lexeme;
|
auto key = consume(TokenType::Ident).lexeme;
|
||||||
bool soft = false;
|
EnvMode mode = EnvMode::Set;
|
||||||
if (at(TokenType::Ident) && current_.lexeme == "?") {
|
if (at(TokenType::Plus)) {
|
||||||
advance();
|
advance();
|
||||||
consume(TokenType::Equals);
|
consume(TokenType::Equals);
|
||||||
soft = true;
|
mode = EnvMode::Append;
|
||||||
|
} else if (at(TokenType::Ident) && current_.lexeme == "?") {
|
||||||
|
advance();
|
||||||
|
consume(TokenType::Equals);
|
||||||
|
mode = EnvMode::Soft;
|
||||||
} else {
|
} else {
|
||||||
consume(TokenType::Equals);
|
consume(TokenType::Equals);
|
||||||
}
|
}
|
||||||
pkg.env_entries.push_back(
|
pkg.env_entries.push_back(
|
||||||
{std::move(key), consume(TokenType::String).lexeme, soft});
|
{std::move(key), consume(TokenType::String).lexeme, mode});
|
||||||
skip_newlines();
|
skip_newlines();
|
||||||
}
|
}
|
||||||
consume(TokenType::Rbrace);
|
consume(TokenType::Rbrace);
|
||||||
|
|||||||
+8
-4
@@ -214,15 +214,19 @@ void SysParser::parse_system_block(SystemConfig& cfg) {
|
|||||||
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
|
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
|
||||||
if (at(TokenType::Newline)) { advance(); continue; }
|
if (at(TokenType::Newline)) { advance(); continue; }
|
||||||
auto k = consume_ident();
|
auto k = consume_ident();
|
||||||
bool soft = false;
|
EnvMode mode = EnvMode::Set;
|
||||||
if (at(TokenType::Ident) && current_.lexeme == "?") {
|
if (at(TokenType::Plus)) {
|
||||||
advance();
|
advance();
|
||||||
consume(TokenType::Equals);
|
consume(TokenType::Equals);
|
||||||
soft = true;
|
mode = EnvMode::Append;
|
||||||
|
} else if (at(TokenType::Ident) && current_.lexeme == "?") {
|
||||||
|
advance();
|
||||||
|
consume(TokenType::Equals);
|
||||||
|
mode = EnvMode::Soft;
|
||||||
} else {
|
} else {
|
||||||
consume(TokenType::Equals);
|
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();
|
skip_newlines();
|
||||||
}
|
}
|
||||||
consume(TokenType::Rbrace);
|
consume(TokenType::Rbrace);
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ static void write_env(std::ostream& os, int d,
|
|||||||
if (entries.empty()) { return; }
|
if (entries.empty()) { return; }
|
||||||
os << Indent(d) << "env {\n";
|
os << Indent(d) << "env {\n";
|
||||||
for (auto& e : entries) {
|
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";
|
<< '"' << e.value << "\"\n";
|
||||||
}
|
}
|
||||||
os << Indent(d) << "}\n";
|
os << Indent(d) << "}\n";
|
||||||
|
|||||||
Reference in New Issue
Block a user