a small Odin build script library inspired by tsoding/nob.h
Find a file
2026-05-05 04:57:48 +00:00
cli.odin add cli_has_flag 2026-05-03 17:37:53 -04:00
cmd.odin add cmd_from_shell_str helper for posix targets 2026-05-04 23:08:42 -04:00
cmd_posix.odin add cmd_from_shell_str helper for posix targets 2026-05-04 23:08:42 -04:00
errors.odin refactor API to use #caller_location for better error reporting 2026-05-03 15:20:48 -04:00
fs.odin refactor API to use #caller_location for better error reporting 2026-05-03 15:20:48 -04:00
mjolnob.odin fix memory leak in rebuild_self_if_needed 2026-05-03 17:04:04 -04:00
README.md Update README.md 2026-05-05 04:57:48 +00:00

Mjolnob

Mjobnob is a small Odin library for creating build "scripts" in Odin itself.

It's kind of like tsoding/nob.h, but for Odin and probably worse in many ways.

If you would like to see some "real world" examples, I have one for a hobby kernel and one for an early-dev scratch-built Linux distro.

If you're wondering why I would build this when the Odin compiler is already a fantastic build tool, I encourage you to give the example links above a quick skim to see my use case. For 99% of what I do, I'll never need this. That said, it's nice to be able to define my build steps in the language I enjoy writing the most on the occasion that I do need it.

Usage

  • running external commands
  • reading and writing files
  • collecting files from a directory tree
  • deciding when outputs need rebuilding
  • reading environment variables and simple CLI flags

Error Handling Style

Most helpers come in two forms:

  • foo(...) exits the process on failure
  • foo_or_err(...) returns (value, mjolnob.Error) or mjolnob.Error

This makes the package convenient for builds where failing fast is desirable, while still allowing explicit error propagation when needed.

Running Commands

Build a command with cmd_make, add arguments with cmd_append, then run it with either cmd_run or cmd_run_and_capture.

package build

import "mjolnob"

main :: proc() {
    env := mjolnob.env_get()
    defer mjolnob.env_destroy(&env)

    cmd := mjolnob.cmd_make("odin", "build", "src/main.odin", "-file")
    defer mjolnob.cmd_destroy(&cmd)

    mjolnob.cmd_append(&cmd, "-out:bin/app")
    mjolnob.cmd_run(cmd, env, echo = true)
}
  • the echo: bool argument to cmd_run* enables or disables printing the command line before invocation (default: false)
  • cmd_run streams stdout/stderr directly to the terminal
  • cmd_run_and_capture returns a Capture_Result containing stdout and stderr
  • a Cmd can be reused by calling cmd_clear to discard previous arguments before using cmd_append

Example with captured output:

main :: proc() {
    env := mjolnob.env_get()
    defer mjolnob.env_destroy(&env)

    cmd := mjolnob.cmd_make("git", "rev-parse", "HEAD")
    defer mjolnob.cmd_destroy(&cmd)

    result := mjolnob.cmd_run_and_capture(cmd, env)
    defer mjolnob.capture_result_destroy(&result)

    fmt.println(string(result.stdout))
}

On POSIX targets, the cmd_from_shell_str helper can be used for creating commands that invoke a shell.

Example:

cmd := mjolnob.cmd_make("bash", "-c", "echo foo && ls")                 // this can be replaced
cmd := mjolnob.cmd_from_shell_str("echo foo && ls -l", shell = "bash")  // with this

This is a simple convenience wrapper that creates a command with the args {shell, "-c", cmd_line}. The shell argument can be omitted and will default to sh.

Filesystem Helpers

Check and Create Paths

if !mjolnob.exists("config.ini") {
    // handle config parsing, etc.
}

mjolnob.mkdir_if_missing("build")

mkdir_if_missing is safe to call even if the directory already exists.

Read and Write Files

data := mjolnob.read_entire_file("input.txt")
defer delete(data)

mjolnob.write_entire_file("output.txt", data)
mjolnob.write_entire_file("output2.txt", "hello")

write_entire_file is overloaded for both []u8 and string.

Copy and Stat Files

import "core:os"
import "mjolnob"

// destination, source - same as core:os.copy_file()
mjolnob.copy_file("build/config.json", "config.json")

info := mjolnob.stat_file("build/config.json")
defer os.file_info_delete(info, context.allocator)

stat_file returns os.File_Info from core:os, so cleanup is done with os.file_info_delete.

Collecting Files

Use collect_files to recursively walk a directory and keep files whose base name matches a shell-like pattern.

sources := mjolnob.collect_files("src", "*.odin")
defer mjolnob.file_list_destroy(&sources)

for path in sources.paths {
    fmt.println(path)
}

Notes:

  • only regular files are included
  • matching is done against info.name, not the full path
  • destroy the returned File_List with file_list_destroy

Rebuild Checks

Use needs_rebuild to compare an output file against one or more input files.

if mjolnob.needs_rebuild("bin/app", "src/main.odin", "src/lib.odin") {
    // rebuild bin/app
}

There are two entrypoints:

  • needs_rebuild(output_path, ..input_paths)
  • needs_rebuild(output_path, file_list)

Behavior:

  • returns true if the output file does not exist
  • returns true if any input is newer than the output
  • otherwise returns false

Example using a collected file list:

sources := mjolnob.collect_files("src", "*.odin")
defer mjolnob.file_list_destroy(&sources)

if mjolnob.needs_rebuild("bin/app", sources) {
    // run build steps
}

Environment Access

Read the current process environment with env_get.

env := mjolnob.env_get()
defer mjolnob.env_destroy(&env)

This is primarily useful for passing environment variables into cmd_run and cmd_run_and_capture.

CLI Flags

cli_has_flag performs a simple scan of os.args[1:].

if mjolnob.cli_has_flag("--verbose") {
    // enable extra logging
}

It only checks for exact string equality and does not parse flag values.

Self-Rebuilding Build Scripts

rebuild_self_if_needed(source_path, echo := false) is intended for Odin build scripts that compile themselves before running.

main :: proc() {
    mjolnob.rebuild_self_if_needed("build.odin", true)
    // rest of build script
}

Behavior:

  • compares the current executable timestamp with the source file timestamp
  • rebuilds the executable with odin build <source> -file
  • replaces the current executable
  • reruns the new executable with the original arguments and environment

Minimal Example

package build

import "mjolnob"

main :: proc() {
    mjolnob.rebuild_self_if_needed("build.odin", true)

    env := mjolnob.env_get()
    defer mjolnob.env_destroy(&env)

    mjolnob.mkdir_if_missing("build")

    sources := mjolnob.collect_files("src", "*.odin")
    defer mjolnob.file_list_destroy(&sources)

    if mjolnob.needs_rebuild("build/app", sources) {
        cmd := mjolnob.cmd_make("odin", "build", "src/main.odin", "-file", "-out:build/app")
        defer mjolnob.cmd_destroy(&cmd)

        mjolnob.cmd_run(cmd, env, true)
    }
}

This example is somewhat contrived, but it shows the general flow of a mjolnob build program.

Ownership Summary

Callers should explicitly destroy these values when finished:

  • Env with env_destroy
  • Cmd with cmd_destroy
  • Capture_Result with capture_result_destroy
  • File_List with file_list_destroy
  • buffers from read_entire_file with delete
  • os.File_Info from stat_file with os.file_info_delete