Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ jobs:
ODIN_VERSION: dev-2026-08
steps:
- uses: actions/checkout@v7
with:
submodules: recursive

# The release tarball's top-level directory is named after the nightly
# it was cut from (odin-linux-amd64-nightly+2026-08-06), not after the
Expand Down Expand Up @@ -67,6 +69,49 @@ jobs:
odin build src -out:hb
python3 scripts/editor_keys_test.py ./hb

# hashmake lives outside src/, so `odin test src` never builds it. This
# is the end-to-end check: it discovers cJSON's sources by listing the
# submodule, compiles each with clang, links them and runs the result.
- name: hashmake builds and runs the cJSON demo
run: |
odin build tools/hashmake -out:hashmake
cd examples/hashmake
../../hashmake --graph
../../hashmake -n
../../hashmake | tee /tmp/demo.out
grep -q "Version:" /tmp/demo.out

# The second build must reuse every cache entry the first one wrote.
# Checked by entry count rather than by wall-clock, which is not a thing
# a CI runner can promise.
- name: hashmake rebuilds nothing when nothing changed
run: |
cd examples/hashmake
rm -rf /tmp/hmcache
../../hashmake --cache-dir /tmp/hmcache >/dev/null
before=$(ls /tmp/hmcache | grep -c "^sha256-")
../../hashmake --cache-dir /tmp/hmcache >/dev/null
after=$(ls /tmp/hmcache | grep -c "^sha256-")
echo "cache entries: $before then $after"
test "$before" = "$after"

- name: hashmake refuses a cycle
run: |
mkdir -p /tmp/cyc && cd /tmp/cyc
cat > hashmake.hb <<'EOF'
{
.default = "a",
.targets = {
.a = { .needs = { .x = "b" }, .build = (let p; nothing) },
.b = { .needs = { .x = "a" }, .build = (let p; nothing) },
},
}
EOF
if "$GITHUB_WORKSPACE/hashmake" 2>/tmp/cyc.err; then
echo "expected a cycle to be refused"; exit 1
fi
grep -q "dependency cycle" /tmp/cyc.err

# The third backend. `odin test` builds natively, so this job is what
# actually executes fs_windows.odin, task_native.odin's Windows half and
# term_windows.odin - the same suite as the Linux job, on the other native
Expand All @@ -87,6 +132,8 @@ jobs:
run: git config --global core.symlinks true

- uses: actions/checkout@v7
with:
submodules: recursive

- name: Cache Odin
id: cache-odin
Expand Down Expand Up @@ -157,6 +204,8 @@ jobs:
ODIN_VERSION: dev-2026-08
steps:
- uses: actions/checkout@v7
with:
submodules: recursive

- name: Cache Odin
id: cache-odin
Expand Down Expand Up @@ -230,6 +279,8 @@ jobs:
WAMR_COMMIT: 16ea74cc6f3671d81db2c4a8dac08fba6eadc73b
steps:
- uses: actions/checkout@v7
with:
submodules: recursive

- name: Cache Odin
id: cache-odin
Expand Down Expand Up @@ -301,6 +352,8 @@ jobs:
ODIN_VERSION: dev-2026-08
steps:
- uses: actions/checkout@v7
with:
submodules: recursive

- name: Cache Odin
id: cache-odin
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ jobs:
ODIN_VERSION: dev-2026-08
steps:
- uses: actions/checkout@v7
with:
submodules: recursive

- name: Cache Odin
id: cache-odin
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ __pycache__/
# the real site, and by scripts/ for a local one. Committing them coupled every
# tracked file to a build product.
docs/repo-files.json
/hashmake
/hashmake.exe
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "examples/hashmake/vendor/cJSON"]
path = examples/hashmake/vendor/cJSON
url = https://github.com/DaveGamble/cJSON
25 changes: 25 additions & 0 deletions GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,31 @@ If a freshly built binary refuses to start with *"An Application Control policy
- **`./hb --cache-dir <path> ...`** - override where `ctx.cache` writes to, and where `cached` keeps its entries (defaults to your XDG cache dir; `%LOCALAPPDATA%\hashedbuild` on Windows). Handy for a throwaway cache: point it somewhere temporary and `cached` starts from nothing.
- **`./hb --help`**, **`./hb --version`** - usage and version.

### `hashmake` - the build tool

A second binary, built from `tools/hashmake`, that treats a HashedBuild program
as a dependency graph:

```sh
odin build tools/hashmake -out:hashmake
cd examples/hashmake && ../../hashmake
```

That discovers the C sources in a vendored cJSON checkout, compiles each with
clang, links them, and runs the result. Run it a second time and it rebuilds
nothing - not because hashmake remembers, but because each node wraps its work
in `cached`, whose key contains the *content* of the files it read.

- **`hashmake`** - build the graph's `.default` target.
- **`hashmake <target>...`** - build these instead.
- **`hashmake --graph`** - print the dependency graph and stop.
- **`hashmake -n`** - print the order targets would be built in, and stop.
- **`hashmake -C <dir>`**, **`-f <file>`** - run elsewhere, or read a differently named build file.
- **`hashmake --allow-any-path`** - let the build file read outside its own directory. By default it cannot: hashmake evaluates it with `ctx.dir` set and only the `workdir` permission (LANGUAGE.md's "Where a path is allowed to reach").
- **`hashmake --cache-dir <path>`** - as `hb`'s.

`tools/hashmake/README.md` covers what a `hashmake.hb` has to evaluate to.

## Try each part of the video

### 1. The parser - see the AST
Expand Down
132 changes: 127 additions & 5 deletions LANGUAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,29 @@ rejected, which `SPEC.md` §3 says it should not be.

→ `examples/numeric-literals.hb` (§3)

### Measuring and cutting text

`textlen` and `textslice` count **codepoints, not bytes** — the type is `Utf8`,
and a byte index could cut a character in half. `.start` is 1-based, matching
`[i]`, and reaching past the end is a failure rather than a short answer.

```hashedbuild
textlen "héllo" // => 5, not 6
textslice { .text = "cJSON.c", .start = 6, .count = 2 } // => ".c"
```

These are the primitives rather than a set of ready-made predicates, because
the predicate you want is a few lines of HashedBuild:

```hashedbuild
let endswith (let a;
let t a.text; let s a.suffix;
(textlen t) >= (textlen s)
and (textslice { .text = t, .start = (textlen t) - (textlen s) + 1, .count = textlen s }) == s);
```

→ `examples/text-slicing.hb` (§16)

## Operators

```hashedbuild
Expand Down Expand Up @@ -141,6 +164,32 @@ it exists, use a pattern (below) rather than an access.
→ `examples/tables-map.hb`, `examples/tables-sequence.hb`,
`examples/table-and-concat.hb` (§5)

### Traversing one

There are no loops, and recursion cannot reach a Table's entries by itself, so
`fold` is how a Table is walked. `.step` is called with `{ .acc, .key, .value }`
and returns the next accumulator:

```hashedbuild
fold { .table = {10, 20, 30}, .init = 0, .step = (let s; s.acc + s.value) } // => 60
```

**Entries are visited in ascending key order, not the order they were written.**
That buys two things. A sequence (keys 1..N) folds in index order, which is what
makes folding a list of filenames mean anything; and two Tables that compare
equal — §6 ignores entry order — fold to the same answer.

It is deliberately the only traversal primitive: `map`, `filter` and appending
to a sequence are each a line or two of HashedBuild on top of it, rather than a
builtin apiece.

```hashedbuild
let seq_len (let t; fold { .table = t, .init = 0, .step = (let s; s.acc + 1) });
let append (let a; a.seq concat { [(seq_len a.seq) + 1] = a.item });
```

→ `examples/folding-a-table.hb` (§16)

## Functions

Three spellings, all producing ordinary values you can store in a Table, pass
Expand Down Expand Up @@ -370,8 +419,73 @@ Paths in the single-argument form resolve relative to the source file being
run, not to your shell's working directory — so a script behaves the same
wherever you invoke it from.

`listdir` gives a directory's entries as a value — names only, sorted byte-wise
so a build does not depend on readdir order:

```hashedbuild
listdir (loadfile "examples/listing") // => {"alpha.txt", "beta.md", "gamma.txt"}
```

→ `examples/files-sandboxed.hb`, `examples/files-symlink.hb`,
`examples/option-picker.hb` (§3, §16)
`examples/option-picker.hb`, `examples/listing-a-directory.hb` (§3, §16)

### Where a path is allowed to reach

A path written **without** a directory handle is governed by a permission, and
which one you hold decides one of three behaviours:

| permission | `loadfile "x"` resolves |
|---|---|
| `anypath` | anywhere — relative to the source file's directory, or absolute. Granted at the root, so this is the behaviour programs already had. |
| `workdir` | contained to `ctx.dir`, the directory the run is rooted at. `..`, an absolute path, and a symlink pointing outward are refused; `.` resolves to `ctx.dir` rather than through it. |
| neither | refused — only the `{ .dir, .path }` handle forms work. |

`ctx.dir` is that directory as a handle, usable anywhere a `.dir` is. It is not
a `File`: a `File` of a directory hashes over its contents (§3), and since §15
puts the whole `ctx` into every cache key, that would put the entire project
tree into every entry. It hashes as a bare tag instead, exactly as `ctx.cache`
does — what you read *through* it are ordinary `File`s that still hash by
content.

```hashedbuild
loadfile { .dir = ctx.dir, .path = "notes.txt" }
(loadfile "..") chctx chperm { .name = "anypath", .enabled = 1 == 0 } // now a failure
```

→ `examples/workdir-containment.hb` (§9, §16)

### Running a program

`exec` starts a program in a fresh scratch directory holding nothing but the
inputs it was given, and hands back the outputs it declared **as values**:

```hashedbuild
exec {
.cmd = "clang",
.args = { "-c", "greet.c", "-o", "greet.o" },
.inputs = { ["greet.c"] = <a File> },
.outputs = { "greet.o" },
}
// => { status: 0, stdout: "…", stderr: "…", outputs: { greet.o: <file> } }
```

That shape is the point rather than a convenience. A cache key excludes
anything an expression reads at run time (see Caching below), so a thinner exec
that wrote into a directory and let you `loadfile` the result afterwards would
answer with the first run's bytes forever. Here an input is a `File` and a
`File` is its content, so inputs are *in* the key — which is what makes
`cached exec { … }` correct.

**A non-zero exit is not a failure.** It comes back as `.status`, so you can
`check` it and show `.stderr`. A command that cannot be started, an input that
cannot be written, or a declared output that is not there are all fatal, as is
calling it without `ctx.permissions.exec`.

Two honest limits: it contains what is *handed to* a program, not what that
program then does — a compiler started this way can read whatever you can — and
WASI cannot start a process at all, where it says so rather than pretending.

→ `examples/running-a-program.hb`, `examples/hashmake/hashmake.hb` (§16)

## Hashing

Expand Down Expand Up @@ -616,10 +730,18 @@ that used to be listed here — a directory `File`, a `Function`, and a cyclic
value — plus `ctx.cache`. See the Hashing section above for what each of them
encodes. A given value has the same digest on every target.

Also absent: `true`/`false` literals, loops of any kind (recursion is the only
repetition there is — see above), a `Bytes`-returning counterpart to
`filetext`, directory listing as a value, and the `#context` implicit name. `SPEC.md` describes several of these as settled
design; none of them run today.
**Directory listing and Table traversal have landed**: `listdir` answers what
is in a directory, and `fold` walks a Table, which is what `map`/`filter`/append
are written on top of. There are still no *loops* — `fold` and recursion are the
whole of repetition, and a fold cannot stop early.

Also absent: `true`/`false` literals, a `Bytes`-returning counterpart to
`filetext`, and the `#context` implicit name. `SPEC.md` describes several of
these as settled design; none of them run today.

Two limits worth knowing rather than discovering: `exec`'s `.outputs` collects
regular files only, not directories; and `createfile` is still exclusive, with
no overwrite mode.

Removed rather than pending: `serialize` and `serialize_file` were specified in
§15 and are gone as of 2026-08-28 — the canonical byte encoding they would have
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ let choice loadfile "choice.txt" |> filetext;

Run it with `./hb examples/option-picker.hb` (from anywhere - its paths resolve relative to the script itself, not your shell's current directory), or explore it live with `./hb -i` - a two-to-four-pane terminal editor with a built-in examples picker, a live AST view, and a step-by-step evaluation trace. This one example touches a few of the language's actual design points: files are ordinary values (`loadfile`/`createfile`), branching is built from general composable operators rather than bespoke syntax (`then`/`else` chains into an if/else-if/else), and `error` is a genuinely unrecoverable failure - unlike a failed `then`, no enclosing `else` catches it.

And the first thing built *on* the language now runs too. **[hashmake](tools/hashmake/)** is a build tool whose build files are HashedBuild programs: a `hashmake.hb` evaluates to a dependency graph, and each node is a function from its prerequisites to the artifact it builds.

```sh
odin build tools/hashmake -out:hashmake
cd examples/hashmake && ../../hashmake
```

That vendors [cJSON](https://github.com/DaveGamble/cJSON), finds its C sources by *listing the directory* rather than naming them, compiles each with clang, links them, and runs the result. Run it again and it rebuilds nothing - not because hashmake remembers what it did, but because each node wraps its work in `cached`, whose key holds the *content* of the files it read. Edit one source and exactly that object and the link rebuild; undo the edit and it is a cache hit again, which a timestamp-based tool cannot do. See **[examples/hashmake/](examples/hashmake/)**.

**[LANGUAGE.md](LANGUAGE.md)** is the tour of everything that works today, feature by feature, with a runnable snippet for each and an explicit list of what isn't built yet. **[examples/](examples/)** has a runnable file per feature - all of them executed by the test suite, so they can't drift from the implementation. `SPEC.md` is the full design, including the parts that don't run yet.

# Examples
Expand Down
Loading