about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--Cargo.lock286
-rw-r--r--Cargo.toml18
-rw-r--r--README.md276
-rw-r--r--build.rs97
-rw-r--r--config/sild/sild.cfg90
-rw-r--r--flake.lock161
-rw-r--r--flake.nix169
-rw-r--r--lua_src/main.lua48
-rw-r--r--lua_src/sild/ansi.lua41
-rw-r--r--lua_src/sild/commands.lua278
-rw-r--r--lua_src/sild/config.lua173
-rw-r--r--lua_src/sild/debugger.lua151
-rw-r--r--lua_src/sild/eval.lua71
-rw-r--r--lua_src/sild/highlight.lua84
-rw-r--r--lua_src/sild/parse.lua44
-rw-r--r--lua_src/sild/session.lua11
-rw-r--r--lua_src/sild/ui.lua109
-rwxr-xr-xrust_src/main.rs65
19 files changed, 2175 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..af6c404
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+/result
+.luarc.json
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..a5b7628
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,286 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "libc"
+version = "0.2.183"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "lua-src"
+version = "550.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e836dc8ae16806c9bdcf42003a88da27d163433e3f9684c52f0301258004a4fb"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "luajit-src"
+version = "210.6.6+707c12b"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295"
+dependencies = [
+ "cc",
+ "which",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "mlua"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccd36acfa49ce6ee56d1307a061dd302c564eee757e6e4cd67eb4f7204846fab"
+dependencies = [
+ "bstr",
+ "either",
+ "libc",
+ "mlua-sys",
+ "num-traits",
+ "parking_lot",
+ "rustc-hash",
+ "rustversion",
+]
+
+[[package]]
+name = "mlua-sys"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f1c3a7fc7580227ece249fd90aa2fa3b39eb2b49d3aec5e103b3e85f2c3dfc8"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "lua-src",
+ "luajit-src",
+ "pkg-config",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "sild"
+version = "0.1.0"
+dependencies = [
+ "mlua",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "which"
+version = "8.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..1651c6e
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "sild"
+version = "0.1.0"
+edition = "2021"
+build = "build.rs"
+
+[[bin]]
+name = "sild"
+path = "rust_src/main.rs"
+
+[dependencies]
+mlua = { version = "0.11.3", features = ["lua54", "vendored"] }
+
+[profile.release]
+opt-level = "z"
+lto = true
+codegen-units = 1
+strip = true
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..62dcb22
--- /dev/null
+++ b/README.md
@@ -0,0 +1,276 @@
+# sild - The Small Interactive Lua Debugger
+
+`sild` is a standalone, TUI debugger for Lua scripts. It build statically and can run on most
+
+```
+sild [options] <script.lua> [script args...]
+```
+
+---
+
+## Installation
+
+Copy `sild` to a location on your `$PATH` and make it executable:
+
+```sh
+cp sild ~/.local/bin/sild
+chmod +x ~/.local/bin/sild
+```
+
+---
+
+## Quick start
+
+```sh
+sild myscript.lua
+```
+
+sild will pause on the first line of your script. From there:
+
+| Key                   | Action                                   |
+|-----------------------|------------------------------------------|
+| `n / <enter>`         | step to next line                        |
+| `c`                   | continue freely (honours breakpoints)    |
+| `c <line>`            | continue until line (one-shot)           |
+| `finish`              | run until current function returns       |
+| `vars`                | list locals in scope with types          |
+| `eval <expr>`         | evaluate a Lua expression                |
+| `dump <tbl> [depth]`  | dump table recursively (default depth 3) |
+| `bt`                  | stack traceback                          |
+| `calls`               | show function call counts                |
+| `set <name> <expr>`   | set a local variable by name             |
+| `watch <expr>`        | watch expression, printed each step      |
+| `unwatch <expr>`      | remove a watch                           |
+| `watches`             | list active watches                      |
+| `bp <line>`           | set breakpoint at line                   |
+| `bp <line> if <expr>` | conditional breakpoint                   |
+| `bpdel <line>`        | remove breakpoint at line                |
+| `bplist`              | list all breakpoints                     |
+| `q`                   | quit sild                                |
+| `? / h / help`        | show this help                           |
+
+---
+
+## Command-line options
+
+| Flag                  | Description                                            |
+|-----------------------|--------------------------------------------------------|
+| `-b, --break <line>`  | Set a breakpoint at `<line>` before execution starts.  |
+| `-c, --config <file>` | Load config from `<file>` instead of the default path. |
+| `-l, --lines <n>`     | Number of source lines shown in the code view.         |
+| `-C, --columns <n>`   | Inner width of the code box in characters.             |
+| `-W, --width <n>`     | Width of the history column in characters.             |
+| `-h, --help`          | Print help and exit.                                   |
+| `-v, --version`       | Print version and exit.                                |
+
+Arguments after the script name are passed to the script via the usual `arg` table.
+
+```sh
+sild -b 42 -b 87 myscript.lua arg1 arg2
+```
+
+---
+
+## Inline annotations
+
+To write breakpoints and watches in your script, write them in the following annotation form.
+
+```lua
+--- BREAK
+```
+Break on the following line.
+
+```lua
+--- BREAK if n > 5
+```
+Break on the following line, only if the expression returns truthy. The expression is evaluated in the scope of your code at that point.
+
+```lua
+--- WATCH n
+--- WATCH a[1]
+```
+Register a watch. The watch is added once, the first time the annotation is reached, and stays watched until removed with `unwatch`.
+
+```lua
+--- UNWATCH n
+```
+Remove a registered watch.
+
+---
+
+## Configuration
+
+The default config file is loaded from first:
+
+```
+$XDG_CONFIG_HOME/sild/sild.cfg
+```
+else
+```
+~/.config/sild/sild.cfg
+```
+
+You can use a custom config with:
+```
+sild --config /path/to/file.cfg
+```
+
+If a config file does not exist, sild runs with it's built-in defaults.
+
+### Format
+
+```ini
+# Lines starting with # are comments.
+# key = value
+code_lines = 20
+col_keyword = 1;32
+```
+
+Keys and values are separated by `=`. Strings may be optionally quoted. Numbers must be plain integers. Unknown keys produce a warning on stderr.
+
+### Available settings
+
+#### Layout
+
+| Key          | Default | Description                                       |
+|--------------|---------|---------------------------------------------------|
+| `auto_size`  | `true`  | Should the UI set it's own size for the terminal. | 
+| `code_lines` | `15`    | Number of source lines shown in the code box.     |
+| `code_inner` | `56`    | Inner width of the code box in characters.        |
+| `hist_width` | `40`    | Width of the history column in characters.        |
+
+#### Colours
+
+All colours are specified as ANSI SGR codes as the digits between `\e[` and `m`.
+
+| Code range | Meaning               |
+|------------|-----------------------|
+| `0`        | Reset                 |
+| `1`        | Bold                  |
+| `2`        | Dim                   |
+| `31`–`37`  | Foreground colours    |
+| `90`–`97`  | Bright foreground     |
+| `1;33`     | Compound (bold yellow)|
+| `""`       | No colour             |
+
+| Key           | Default | Applied to                         |
+|---------------|---------|------------------------------------|
+| `col_keyword` | `1;34`  | Lua keywords                       |
+| `col_string`  | `32`    | String literals                    |
+| `col_number`  | `35`    | Numeric literals                   |
+| `col_operator`| `33`    | Operators                          |
+| `col_comment` | `2`     | Comments                           |
+| `col_current` | `33`    | Current line marker                |
+| `col_breakpt` | `31`    | Breakpoint line number             |
+| `col_dim`     | `2`     | Dimmed elements                    |
+| `col_header`  | `36`    | History column headers             |
+| `col_lineno`  | `33`    | Line number inside history headers |
+| `col_watch`   | `36`    | Watch label prefix                 |
+| `col_value`   | `33`    | Evaluated values                   |
+| `col_info`    | `36`    | Informational messages             |
+| `col_error`   | `31`    | Error messages                     |
+| `col_prompt`  | `33`    | Banner and breakpoint messages     |
+
+### Priority order
+
+Settings are prioritised in this order, from lowest to highest priority:
+
+1. Built-in defaults (always present)
+2. Config file (`~/.config/sild/sild.cfg` or `--config`)
+3. Command-line flags (`--lines`, `--columns`, `--width`)
+
+---
+
+## Useful Notes
+
+`rlwrap` can be used to enable command history:
+```sh
+alias sild='rlwrap sild'
+```
+
+## TODO:
+### Do all `'-- TODO'` items
+- `lua_src/main.lua`
+```
+27:-- TODO: Look for annotations in require'd files
+28:local breakpoints_list, actions_list = parse.annotations(cli.script_path)
+```
+
+```
+43:  -- TODO: use XDG_CONFIG_HOME here
+42: print(ansi.color_dim("n=step  c=continue  ?=help  q=quit") .. "  " ..
+43:   -- TODO: use XDG_CONFIG_HOME here
+  ansi.color_dim("config: " .. (cli.config_file or (os.getenv("HOME") or "~") .. "/.config/sild/sild.cfg")) .. "\n")
+```
+
+- `lua_src/sild/commands.lua`
+```
+10: local function print_help()
+11:  local function row(cmd, desc)
+12:    print("  " .. ansi.color_value(ansi.fit(cmd, 28)) .. "  " .. desc)
+13:  end
+14:  -- TODO: Fit to screen width
+15:  print()
+16:  print(ansi.color_bold("-- Navigation ------------------"))
+```
+
+- `lua_src/sild/config.lua`
+```
+31:-- TODO: Replace all this if-elseing with tables of handlers
+32:function M.parse_file(path)
+```
+
+- `lua_src/sild/ui.lua`
+```
+8:-- TODO: Make part of config
+9:local SPLIT_POSITION = 0.35
+```
+
+### Add `.editorconfig` instead of using global format styles
+
+### Add MacOS target
+- `flake.nix`
+```
+let
+  # TODO: Add x86_64-darwin and aarch64-darwin
+  buildTargets = {
+```
+
+### callchart - print a bar chart out of call data
+
+### PgUp PgDn to scroll code window
+
+### Fix `auto_size` toggle
+- `lua_src/sild/config.lua`
+```
+local config = {
+  -- layout
+  auto_size        = true,
+```
+
+### Luac detection (maybe decomp support?)
+
+### `parse.lua` is only about file parsing, maybe rename?
+
+### EmmyLua document all functions
+
+### Hex code colour support
+
+### Improve error messages for target scripts
+
+### Have the program name and description set in rust (even possibly cargo)
+
+### Allow debugging more than just scripts, be able to descend layers
+
+### 'vars' is not comprehensive, modify how it chooses
+
+### Fix partially broken watch annotations
+
+### See if highlight works with multiline strings and longer floating point numbers like (123.456)
+  Also doesn't handle multiline comments and string escape sequences
+  
+### These appear at start of debugging program, see if we can skip them
+  ```sh
+  [sild.debugger:174]
+  [main.lua:47]
+  ```
diff --git a/build.rs b/build.rs
new file mode 100644
index 0000000..417f61c
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,97 @@
+use std::{
+  env, fs,
+  io::Write,
+  path::{Path, PathBuf},
+};
+
+const IGNORE_DIRS: &[&str] = &[];
+
+const IGNORE_FILES: &[&str] = &["main.lua"];
+
+fn main() {
+  let crate_root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
+  let lua_src = crate_root.join("lua_src");
+  let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
+  let out_file = out_dir.join("bundled_modules.rs");
+
+  println!("cargo:rerun-if-changed=lua_src");
+
+  let mut entries: Vec<(String, String)> = Vec::new();
+  collect(&lua_src, &lua_src, &mut entries);
+
+  // Files are sorted alphabetically here to make output deterministic
+  entries.sort_by(|a, b| a.0.cmp(&b.0));
+
+  let mut out = match fs::File::create(&out_file) {
+    Ok(f) => f,
+    Err(e) => {
+      eprintln!(
+        "ERROR: could not write to '{}': {e}",
+        out_file.display()
+      );
+      std::process::exit(1);
+    }
+  };
+
+  writeln!(out, "// AUTO-GENERATED by build.rs").unwrap();
+  writeln!(out, "pub const BUNDLED_MODULES: &[(&str, &str)] = &[").unwrap();
+
+  for (module_name, rel_path) in &entries {
+    writeln!(
+            out,
+            r#"    ("{module_name}", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/lua_src/{rel_path}"))),"#
+        ).unwrap();
+  }
+
+  writeln!(out, "];").unwrap();
+
+  eprintln!("INFORMATION: Bundled {} Lua module(s)", entries.len());
+  for (name, path) in &entries {
+    eprintln!("               {name:30} ← lua_src/{path}");
+  }
+}
+
+fn collect(root: &Path, dir: &Path, out: &mut Vec<(String, String)>) {
+  let entries = match fs::read_dir(dir) {
+    Ok(e) => e,
+    Err(e) => {
+      eprintln!("ERROR: could not read '{}': {e}", dir.display());
+      return;
+    }
+  };
+
+  for entry in entries.flatten() {
+    let path = entry.path();
+    let file_name = path.file_name().unwrap().to_string_lossy();
+
+    if path.is_dir() {
+      if IGNORE_DIRS.contains(&file_name.as_ref()) {
+        eprintln!("INFORMATION: skipping directory lua_src/{file_name}/");
+        continue;
+      }
+      collect(root, &path, out);
+      continue;
+    }
+
+    if path.extension().and_then(|e| e.to_str()) != Some("lua") {
+      continue;
+    }
+
+    let rel = path.strip_prefix(root).unwrap();
+    let rel_str = rel.to_string_lossy();
+
+    if IGNORE_FILES.contains(&rel_str.as_ref()) {
+      eprintln!("INFORMATION: skipping file 'lua_src/{rel_str}'");
+      continue;
+    }
+
+    let module_name = rel
+      .with_extension("")
+      .components()
+      .map(|c| c.as_os_str().to_string_lossy())
+      .collect::<Vec<_>>()
+      .join(".");
+
+    out.push((module_name, rel_str.replace('\\', "/")));
+  }
+}
diff --git a/config/sild/sild.cfg b/config/sild/sild.cfg
new file mode 100644
index 0000000..b845946
--- /dev/null
+++ b/config/sild/sild.cfg
@@ -0,0 +1,90 @@
+# sild configuration file
+# Default location: ~/.config/sild/sild.cfg
+# Override with: sild --config /path/to/file.cfg
+
+##########
+# Layout #
+##########
+
+# Auto Size
+# Default: true
+# auto_size = true
+
+# Number of source lines shown in the code view box.
+# Default: 15
+# code_lines = 15
+
+# Inner width of the code box in characters.
+# Default: 56
+# code_inner = 56
+
+# Width of the history column in characters.
+# Default: 40
+# hist_width = 40
+
+###########
+# Colours #
+###########
+
+# Colours are specified as ANSI SGR codes.
+# Learn more here: https://gist.github.com/ConnerWill/d4b6c776b509add763e17f9f113fd25b
+
+# Lua keywords
+# Default: 1;34 (bold blue)
+# col_keyword = 1;34
+
+# String literals
+# Default: 32 (green)
+# col_string = 32
+
+# Numeric literals
+# Default: 35 (magenta)
+# col_number = 35
+
+# Operators
+# Default: 33 (yellow)
+# col_operator = 33
+
+# Comments
+# Default: 2 (dim)
+# col_comment = 2
+
+# Current line marker
+# Default: 33 (yellow)
+# col_current = 33
+
+# Breakpoint line number
+# Default: 31 (red)
+# col_breakpt = 31
+
+# Dimmed elements
+# Default: 2 (dim)
+# col_dim = 2
+
+# History column headers
+# Default: 36 (cyan)
+# col_header = 36
+
+# Line number inside history headers
+# Default: 33 (yellow)
+# col_lineno = 33
+
+# Watch label prefix
+# Default: 36 (cyan)
+# col_watch = 36
+
+# Evaluated values
+# Default: 33 (yellow)
+# col_value = 33
+
+# Informational messages
+# Default: 36 (cyan)
+# col_info = 36
+
+# Error messages
+# Default: 31 (red)
+# col_error = 31
+
+# sild banner and breakpoint messages
+# Default: 33 (yellow)
+# col_prompt = 33
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..f3b2e82
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,161 @@
+{
+  "nodes": {
+    "fenix": {
+      "inputs": {
+        "nixpkgs": [
+          "nixpkgs"
+        ],
+        "rust-analyzer-src": "rust-analyzer-src"
+      },
+      "locked": {
+        "lastModified": 1773299640,
+        "narHash": "sha256-kTsZ5xGZqaeJ8jWsfZNACo/VsW3riVuIQEPWVGiqWKM=",
+        "owner": "nix-community",
+        "repo": "fenix",
+        "rev": "8ac78ff968869cd05d9cb42fbf63bdbc6851ec19",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-community",
+        "repo": "fenix",
+        "type": "github"
+      }
+    },
+    "fenix_2": {
+      "inputs": {
+        "nixpkgs": [
+          "naersk",
+          "nixpkgs"
+        ],
+        "rust-analyzer-src": "rust-analyzer-src_2"
+      },
+      "locked": {
+        "lastModified": 1752475459,
+        "narHash": "sha256-z6QEu4ZFuHiqdOPbYss4/Q8B0BFhacR8ts6jO/F/aOU=",
+        "owner": "nix-community",
+        "repo": "fenix",
+        "rev": "bf0d6f70f4c9a9cf8845f992105652173f4b617f",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-community",
+        "repo": "fenix",
+        "type": "github"
+      }
+    },
+    "flake-utils": {
+      "inputs": {
+        "systems": "systems"
+      },
+      "locked": {
+        "lastModified": 1731533236,
+        "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "naersk": {
+      "inputs": {
+        "fenix": "fenix_2",
+        "nixpkgs": [
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1769799857,
+        "narHash": "sha256-88IFXZ7Sa1vxbz5pty0Io5qEaMQMMUPMonLa3Ls/ss4=",
+        "owner": "nix-community",
+        "repo": "naersk",
+        "rev": "9d4ed44d8b8cecdceb1d6fd76e74123d90ae6339",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-community",
+        "repo": "naersk",
+        "type": "github"
+      }
+    },
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1773122722,
+        "narHash": "sha256-FIqHByVqxCprNjor1NqF80F2QQoiiyqanNNefdlvOg4=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "62dc67aa6a52b4364dd75994ec00b51fbf474e50",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "fenix": "fenix",
+        "flake-utils": "flake-utils",
+        "naersk": "naersk",
+        "nixpkgs": "nixpkgs"
+      }
+    },
+    "rust-analyzer-src": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1773194001,
+        "narHash": "sha256-50PPXBtH2xfKuNfQfUNOyuIFgZPEz5QVertQWS2MQJE=",
+        "owner": "rust-lang",
+        "repo": "rust-analyzer",
+        "rev": "8ed3cca4d30610fd0d3c1179c85418de2dc0a7c1",
+        "type": "github"
+      },
+      "original": {
+        "owner": "rust-lang",
+        "ref": "nightly",
+        "repo": "rust-analyzer",
+        "type": "github"
+      }
+    },
+    "rust-analyzer-src_2": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1752428706,
+        "narHash": "sha256-EJcdxw3aXfP8Ex1Nm3s0awyH9egQvB2Gu+QEnJn2Sfg=",
+        "owner": "rust-lang",
+        "repo": "rust-analyzer",
+        "rev": "591e3b7624be97e4443ea7b5542c191311aa141d",
+        "type": "github"
+      },
+      "original": {
+        "owner": "rust-lang",
+        "ref": "nightly",
+        "repo": "rust-analyzer",
+        "type": "github"
+      }
+    },
+    "systems": {
+      "locked": {
+        "lastModified": 1681028828,
+        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+        "owner": "nix-systems",
+        "repo": "default",
+        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-systems",
+        "repo": "default",
+        "type": "github"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..25444f8
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,169 @@
+{
+  description = "sild - The Small Interactive Lua Debugger";
+
+  inputs = {
+    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+
+    # Fenix provides the rust toolchains
+    fenix.url = "github:nix-community/fenix";
+    fenix.inputs.nixpkgs.follows = "nixpkgs";
+
+    # Naersk builds the rust project
+    naersk.url = "github:nix-community/naersk";
+    naersk.inputs.nixpkgs.follows = "nixpkgs";
+
+    flake-utils.url = "github:numtide/flake-utils";
+  };
+
+  outputs =
+    {
+      self,
+      nixpkgs,
+      fenix,
+      naersk,
+      flake-utils,
+    }:
+    let
+      # TODO: Add x86_64-darwin and aarch64-darwin
+      buildTargets = {
+        "x86_64-linux" = {
+          crossSystemConfig = "x86_64-unknown-linux-musl";
+          rustTarget = "x86_64-unknown-linux-musl";
+        };
+        "aarch64-linux" = {
+          crossSystemConfig = "aarch64-unknown-linux-musl";
+          rustTarget = "aarch64-unknown-linux-musl";
+        };
+      };
+
+      makePackage =
+        buildSystem: targetName:
+        let
+          target = buildTargets.${targetName};
+          rustTarget = target.rustTarget;
+
+          pkgs = import nixpkgs {
+            system = buildSystem;
+            overlays = [ fenix.overlays.default ];
+          };
+
+          pkgsCross = import nixpkgs {
+            system = buildSystem;
+            crossSystem = {
+              config = target.crossSystemConfig;
+              isStatic = true;
+            };
+            overlays = [ fenix.overlays.default ];
+          };
+
+          fenixPkgs = pkgs.fenix;
+
+          toolchain = fenixPkgs.combine [
+            fenixPkgs.stable.rustc
+            fenixPkgs.stable.cargo
+            fenixPkgs.targets.${rustTarget}.stable.rust-std
+          ];
+
+          naersk-lib = pkgs.callPackage naersk {
+            cargo = toolchain;
+            rustc = toolchain;
+          };
+
+          targetCC = "${pkgsCross.stdenv.cc}/bin/${pkgsCross.stdenv.cc.targetPrefix}cc";
+
+        in
+        naersk-lib.buildPackage {
+          name = "sild-${targetName}";
+          src = ./.;
+
+          strictDeps = true;
+          doCheck = false;
+
+          # Don't strip Lua
+          singleStep = true;
+
+          TARGET_CC = targetCC;
+          "CC_${builtins.replaceStrings [ "-" ] [ "_" ] rustTarget}" = targetCC;
+
+          CARGO_BUILD_TARGET = rustTarget;
+          CARGO_BUILD_RUSTFLAGS = [
+            "-C"
+            "target-feature=+crt-static"
+            "-C"
+            "link-args=-static"
+            "-C"
+            "linker=${targetCC}"
+          ];
+        };
+
+      perSystemOutputs =
+        buildSystem:
+        let
+          pkgs = import nixpkgs {
+            system = buildSystem;
+            overlays = [ fenix.overlays.default ];
+          };
+
+          devToolchain = pkgs.fenix.stable.withComponents [
+            "rustc"
+            "cargo"
+            "rust-src"
+            "clippy"
+            "rustfmt"
+            "rust-std"
+          ];
+
+        in
+        {
+          packages =
+            builtins.mapAttrs (targetName: _: makePackage buildSystem targetName) buildTargets
+            //
+              (
+                let
+                  nativeTarget =
+                    if pkgs.stdenv.isLinux then
+                      "${builtins.head (builtins.match "([^-]+)-.*" buildSystem)}-linux"
+                    else
+                      null;
+                in
+                if nativeTarget != null && buildTargets ? ${nativeTarget} then
+                  { default = makePackage buildSystem nativeTarget; }
+                else
+                  { }
+              );
+
+          devShells.default = pkgs.mkShell {
+            name = "sild-dev";
+
+            nativeBuildInputs = [
+              devToolchain
+              pkgs.rust-analyzer
+              pkgs.pkg-config
+
+              # Cross linkers for musl
+              pkgs.pkgsCross.aarch64-multiplatform-musl.stdenv.cc
+              pkgs.pkgsCross.musl64.stdenv.cc
+            ];
+
+            # Musl cross linkers locations
+            CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER = "${pkgs.pkgsCross.musl64.stdenv.cc}/bin/${pkgs.pkgsCross.musl64.stdenv.cc.targetPrefix}cc";
+            CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER = "${pkgs.pkgsCross.aarch64-multiplatform-musl.stdenv.cc}/bin/${pkgs.pkgsCross.aarch64-multiplatform-musl.stdenv.cc.targetPrefix}cc";
+
+            shellHook = ''
+              echo ""
+              echo "  SILD Nix Devenv"
+              echo "  Rust: v$(rustc --version |  awk '{print $2}')"
+              echo "  mlua: $(cargo tree | grep 'mlua v' | awk '{print $3}')"
+              echo ""
+              echo "  Build commands:"
+              echo "    cargo build --release                                     # native debug build"
+              echo "    nix build .#x86_64-linux                                  # static x86_64 build"
+              echo "    nix build .#aarch64-linux                                 # static aarch64 build"
+              echo ""
+            '';
+          };
+        };
+
+    in
+    flake-utils.lib.eachDefaultSystem (system: perSystemOutputs system);
+}
diff --git a/lua_src/main.lua b/lua_src/main.lua
new file mode 100644
index 0000000..776f2a4
--- /dev/null
+++ b/lua_src/main.lua
@@ -0,0 +1,48 @@
+local config = require("sild.config")
+local parse = require("sild.parse")
+local session = require("sild.session")
+local debugger = require("sild.debugger")
+local commands = require("sild.commands")
+
+local cli = config.parse_cli(arg)
+
+if not cli.script_path then
+  config.print_help(SILD_VERSION); os.exit(1)
+end
+
+local home = os.getenv("HOME") or os.getenv("USERPROFILE") or ""
+local xdg_config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config")
+config.parse_file(cli.config_file or (xdg_config_home .. "/sild/sild.cfg"))
+
+local chunk, load_err = loadfile(cli.script_path)
+if not chunk then
+  local ansi = require("sild.ansi")
+  io.stderr:write(ansi.color_error(
+    "ERROR: cannot load '" .. cli.script_path .. "': " .. tostring(load_err)
+  ) .. "\n")
+  os.exit(1)
+end
+
+local script_src_key = "@" .. cli.script_path
+-- TODO: Look for annotations in require'd files
+local breakpoints_list, actions_list = parse.annotations(cli.script_path)
+session.breakpoints[script_src_key] = breakpoints_list
+for _, ln in ipairs(cli.breaks) do
+  session.breakpoints[script_src_key][ln] = true
+end
+session.actions[script_src_key] = actions_list
+
+local real_arg = {}
+for i, v in ipairs(cli.script_args) do real_arg[i] = v end
+real_arg[0] = cli.script_path
+arg = real_arg
+
+local ansi = require("sild.ansi")
+print(ansi.color_prompt("sild " .. SILD_VERSION) .. "  " .. ansi.color_bold(cli.script_path))
+print(ansi.color_dim("n=step  c=continue  ?=help  q=quit") .. "  " ..
+  -- TODO: use XDG_CONFIG_HOME here
+  ansi.color_dim("config: " .. (cli.config_file or (os.getenv("HOME") or "~") .. "/.config/sild/sild.cfg")) .. "\n")
+
+local has_immediate_bp = next(session.breakpoints[script_src_key]) ~= nil
+debugger.install_hook(has_immediate_bp, commands.run)
+chunk()
diff --git a/lua_src/sild/ansi.lua b/lua_src/sild/ansi.lua
new file mode 100644
index 0000000..8ee99df
--- /dev/null
+++ b/lua_src/sild/ansi.lua
@@ -0,0 +1,41 @@
+local config = require("sild.config").config
+
+local M = {}
+
+function M.color(code, s)
+  if not code or code == "" then return s end
+  return "\027[" .. code .. "m" .. s .. "\027[0m"
+end
+
+function M.ansi_len(s)
+  return #s:gsub("\027%[[%d;]*m", "")
+end
+
+-- Pad/Truncate ANSI String
+function M.fit(s, n)
+  s = s or ""
+  local vlen = M.ansi_len(s)
+  if vlen > n then
+    return s:gsub("\027%[[%d;]*m", ""):sub(1, n)
+  end
+  return s .. string.rep(" ", n - vlen)
+end
+
+function M.color_keyword(s)     return M.color(config.color_keyword,    s) end
+function M.color_string(s)      return M.color(config.color_string,     s) end
+function M.color_number(s)      return M.color(config.color_number,     s) end
+function M.color_operator(s)    return M.color(config.color_operator,   s) end
+function M.color_comment(s)     return M.color(config.color_comment,    s) end
+function M.color_current(s)     return M.color(config.color_current,    s) end
+function M.color_breakpoint (s) return M.color(config.color_breakpoint, s) end
+function M.color_dim(s)         return M.color(config.color_dim,        s) end
+function M.color_header(s)      return M.color(config.color_header,     s) end
+function M.color_linenumber(s)  return M.color(config.color_linenumber, s) end
+function M.color_watch(s)       return M.color(config.color_watch,      s) end
+function M.color_value(s)       return M.color(config.color_value,      s) end
+function M.color_info(s)        return M.color(config.color_info,       s) end
+function M.color_error(s)       return M.color(config.color_error,      s) end
+function M.color_prompt(s)      return M.color(config.color_prompt,     s) end
+function M.color_bold(s)     	  return M.color("1",                  s) end
+
+return M
diff --git a/lua_src/sild/commands.lua b/lua_src/sild/commands.lua
new file mode 100644
index 0000000..98e74fd
--- /dev/null
+++ b/lua_src/sild/commands.lua
@@ -0,0 +1,278 @@
+local ansi = require("sild.ansi")
+local eval = require("sild.eval")
+local ui = require("sild.ui")
+local session = require("sild.session")
+local debugger = require("sild.debugger")
+local highlight = require("sild.highlight")
+
+local M = {}
+
+local function print_help()
+  local function row(cmd, desc)
+    print("  " .. ansi.color_value(ansi.fit(cmd, 28)) .. "  " .. desc)
+  end
+  -- TODO: Fit to screen width
+  print()
+  print(ansi.color_bold("-- Navigation ------------------"))
+  row("n / <enter>", "step to next line")
+  row("c", "continue freely (honours breakpoints)")
+  row("c <line>", "continue until line (one-shot)")
+  row("finish", "run until current function returns")
+  print(ansi.color_bold("-- Inspect ---------------------"))
+  row("vars", "list locals in scope with types")
+  row("eval <expr>", "evaluate a Lua expression")
+  row("dump <tbl> [depth]", "dump table recursively (default depth 3)")
+  row("bt", "stack traceback")
+  row("calls", "show function call counts")
+  print(ansi.color_bold("-- Modify ----------------------"))
+  row("set <name> <expr>", "set a local variable by name")
+  print(ansi.color_bold("-- Watches ---------------------"))
+  row("watch <expr>", "watch expression, printed each step")
+  row("unwatch <expr>", "remove a watch")
+  row("watches", "list active watches")
+  print(ansi.color_bold("-- Breakpoints -----------------"))
+  row("bp <line>", "set breakpoint at line")
+  row("bp <line> if <expr>", "conditional breakpoint")
+  row("bpdel <line>", "remove breakpoint at line")
+  row("bplist", "list all breakpoints")
+  print(ansi.color_bold("-- Other -----------------------"))
+  row("q", "quit sild")
+  row("? / h / help", "show this help")
+  print()
+end
+
+local function fire_actions(src_key, line)
+  local acts = session.actions[src_key] and session.actions[src_key][line]
+
+  if not acts then return end
+  session.actions[src_key][line] = nil
+
+  for _, act in ipairs(acts) do
+    if act.kind == "watch" then
+      local already = false
+      for _, w in ipairs(session.watches) do
+        if w == act.expr then
+          already = true; break
+        end
+      end
+      if not already then
+        session.watches[#session.watches + 1] = act.expr
+        print(ansi.color_info("auto-watch: ") .. act.expr)
+      end
+    elseif act.kind == "unwatch" then
+      for i, w in ipairs(session.watches) do
+        if w == act.expr then
+          table.remove(session.watches, i); break
+        end
+      end
+    end
+  end
+end
+
+local function print_watches(env)
+  if #session.watches == 0 then return end
+
+  for _, expr in ipairs(session.watches) do
+    local v, ok, err = eval.eval(expr, env)
+    local vs = ok and ansi.color_value(tostring(v))
+        or ansi.color_error("err: " .. tostring(err))
+    io.write(ansi.color_watch("watch ") .. expr .. " = " .. vs .. "\n")
+  end
+  io.write("\n")
+end
+
+local function normalise(input)
+  return (input == "next" or input == "step") and "n"
+      or (input == "quit" or input == "exit") and "q"
+      or input == "continue" and "c"
+      or input == "backtrace" and "bt"
+      or input == "break" and "bp"
+      or input:match("^break%s+%d+") and input:gsub("^break%s*", "bp ", 1)
+      or input
+end
+
+local function handle_navigation(input, src_key)
+  if input == "n" or input == "" then
+    return false
+  elseif input == "c" then
+    debugger.install_hook(true, M.run)
+    return false
+  elseif input:match("^c%s+(%d+)$") then
+    local target = tonumber(input:match("^c%s+(%d+)$"))
+    session.continue_until = { source = src_key, line = target }
+    print(ansi.color_info("-> running until line " .. target))
+    debugger.install_hook(true, M.run)
+    return false
+  elseif input == "finish" then
+    debugger.finish(M.run)
+    return false
+  end
+
+  return nil
+end
+
+local function handle_breakpoints(input, src_key)
+  if input:match("^bp%s+(%d+)$") then
+    local n = tonumber(input:match("^bp%s+(%d+)$"))
+    session.breakpoints[src_key] = session.breakpoints[src_key] or {}
+    session.breakpoints[src_key][n] = true
+    print(ansi.color_info("breakpoint set at line " .. n))
+  elseif input:match("^bp%s+(%d+)%s+if%s+(.+)$") then
+    local n, cond = input:match("^bp%s+(%d+)%s+if%s+(.+)$")
+    session.breakpoints[src_key] = session.breakpoints[src_key] or {}
+    session.breakpoints[src_key][tonumber(n)] = cond
+    print(ansi.color_info("conditional breakpoint at line " .. n))
+  elseif input:match("^bpdel%s+(%d+)$") then
+    local n = tonumber(input:match("^bpdel%s+(%d+)$"))
+    if session.breakpoints[src_key] then
+      session.breakpoints[src_key][n] = nil
+    end
+    print(ansi.color_dim("breakpoint removed at line " .. n))
+  elseif input == "bplist" then
+    local any = false
+    for sk, tbl in pairs(session.breakpoints) do
+      for ln, cond in pairs(tbl) do
+        any = true
+        local cs = cond == true and "" or ansi.color_dim(" if " .. tostring(cond))
+        print("  " .. ansi.color_header(sk) .. ":" .. ansi.color_linenumber(tostring(ln)) .. cs)
+      end
+    end
+    if not any then print(ansi.color_dim("no breakpoints set")) end
+  else
+    return false
+  end
+
+  return true
+end
+
+local function handle_inspect(input, locals_snapshot, env)
+  if input == "vars" then
+    if #locals_snapshot == 0 then
+      print(ansi.color_dim("no locals in scope"))
+    else
+      for _, v in ipairs(locals_snapshot) do
+        local vs
+        if type(v.value) == "string" then
+          vs = ansi.color_string('"' .. tostring(v.value) .. '"')
+        elseif type(v.value) == "boolean" then
+          vs = ansi.color_keyword(tostring(v.value))
+        else
+          vs = ansi.color_value(tostring(v.value))
+        end
+        print("  " .. ansi.color_header(v.name) .. " = " .. vs ..
+          "  " .. ansi.color_dim("(" .. type(v.value) .. ")"))
+      end
+    end
+  elseif input:match("^eval%s+(.+)$") then
+    local expr = input:match("^eval%s+(.+)$")
+    local v, ok, err = eval.eval(expr, env)
+    if ok then
+      print(ansi.color_value(tostring(v)))
+    else
+      print(ansi.color_error("error: " .. tostring(err)))
+    end
+  elseif input:match("^dump%s+") then
+    local rest = input:match("^dump%s+(.+)$")
+    local expr, ds = rest:match("^(.-)%s+(%d+)$")
+    if not expr then expr = rest end
+    eval.do_dump(expr, env, tonumber(ds) or 3)
+  elseif input == "bt" then
+    print(debug.traceback("Stack trace:", 2))
+  elseif input == "calls" then
+    local any = false
+    for fn, n in pairs(session.calls) do
+      any = true
+      print("  " .. ansi.color_header(fn) .. " called " .. ansi.color_value(tostring(n)) .. "x")
+    end
+    if not any then print(ansi.color_dim("no call data yet")) end
+  else
+    return false
+  end
+
+  return true
+end
+
+local function handle_watches(input)
+  if input:match("^watch%s+(.+)$") then
+    local expr = input:match("^watch%s+(.+)$")
+    local already = false
+    for _, w in ipairs(session.watches) do
+      if w == expr then
+        already = true; break
+      end
+    end
+    if already then
+      print(ansi.color_dim("already watching: " .. expr))
+    else
+      session.watches[#session.watches + 1] = expr
+      print(ansi.color_watch("watching: ") .. expr)
+    end
+  elseif input:match("^unwatch%s+(.+)$") then
+    local expr = input:match("^unwatch%s+(.+)$")
+    local removed = false
+    for i, w in ipairs(session.watches) do
+      if w == expr then
+        table.remove(session.watches, i); removed = true; break
+      end
+    end
+    print(removed and ansi.color_dim("removed watch: " .. expr)
+      or ansi.color_error("not watching: " .. expr))
+  elseif input == "watches" then
+    if #session.watches == 0 then
+      print(ansi.color_dim("no active watches"))
+    else
+      for _, w in ipairs(session.watches) do print("  " .. ansi.color_watch(w)) end
+    end
+  else
+    return false
+  end
+
+  return true
+end
+
+local function input_loop(src_key, locals_snapshot, env)
+  while true do
+    io.write("(sild) ")
+    io.flush()
+    local raw = io.read()
+    if raw == nil then os.exit(0) end
+    local input = normalise(raw:match("^%s*(.-)%s*$"))
+
+    local nav = handle_navigation(input, src_key)
+    if nav == false then return end
+
+    if handle_breakpoints(input, src_key) then
+    elseif handle_inspect(input, locals_snapshot, env) then
+    elseif handle_watches(input) then
+    elseif input:match("^set%s+(%a[%w_]*)%s+(.+)$") then
+      local name, expr = input:match("^set%s+(%a[%w_]*)%s+(.+)$")
+      debugger.write(locals_snapshot, env, name, expr)
+    elseif input == "q" then
+      os.exit(0)
+    elseif input == "?" or input == "h" or input == "help" then
+      print_help()
+    else
+      print(ansi.color_dim("unknown command - ? for help"))
+    end
+  end
+end
+
+function M.run(info, locals_snapshot)
+  local src     = info.short_src
+  local line    = info.currentline
+  local src_key = info.source
+  local path    = src_key:sub(1, 1) == "@" and src_key:sub(2) or nil
+  local text    = path and (highlight.read_file(path)[line] or ""):gsub("%s+$", "") or ""
+
+  fire_actions(src_key, line)
+
+  session.history[#session.history + 1] = { src = src, line = line, text = text }
+  ui.draw(info)
+
+  local env = debugger.build_env(locals_snapshot)
+  print_watches(env)
+
+  input_loop(src_key, locals_snapshot, env)
+end
+
+return M
diff --git a/lua_src/sild/config.lua b/lua_src/sild/config.lua
new file mode 100644
index 0000000..0091bce
--- /dev/null
+++ b/lua_src/sild/config.lua
@@ -0,0 +1,173 @@
+local M = {}
+
+-- not in table means faster local lookup
+local config = {
+  -- layout
+  auto_size        = true,
+  code_lines       = 15,
+  code_inner       = 56,
+  hist_width       = 40,
+  -- colours (ANSI Codes)
+  color_keyword    = "1;34",
+  color_string     = "32",
+  color_number     = "35",
+  color_operator   = "33",
+  color_comment    = "2",
+  color_current    = "33",
+  color_breakpoint = "31",
+  color_dim        = "2",
+  color_header     = "36",
+  color_linenumber = "33",
+  color_watch      = "36",
+  color_value      = "33",
+  color_info       = "36",
+  color_error      = "31",
+  color_prompt     = "33",
+}
+M.config = config
+
+local config_warning_str = "WARNING: config line "
+
+-- TODO: Replace all this if-elseing with tables of handlers
+function M.parse_file(path)
+  local f = io.open(path, "r")
+  if not f then return end
+  local line_number = 0
+  for line in f:lines() do
+    line_number = line_number + 1
+    line = line:gsub("#.*$", ""):match("^%s*(.-)%s*$")
+    if line ~= "" then
+      local key, val = line:match("^([%a_][%w_]*)%s*=%s*(.+)$")
+      if not key then
+        io.stderr:write(config_warning_str .. line_number .. ": malformed entry\n")
+      elseif config[key] == nil then
+        io.stderr:write(config_warning_str .. line_number .. ": unknown key '" .. key .. "'\n")
+      else
+        local t = type(config[key])
+        if t == "number" then
+          local n = tonumber(val)
+          if n then
+            config[key] = n
+          else
+            io.stderr:write(config_warning_str .. line_number .. ": '" .. key .. "' expects a number\n")
+          end
+        elseif t == "boolean" then
+          if val == "true" then
+            config[key] = true
+          elseif val == "false" then
+            config[key] = false
+          else
+            io.stderr:write(config_warning_str .. line_number .. ": '" .. key .. "' expects true or false\n")
+          end
+        else
+          val = val:match('^"(.*)"$') or val:match("^'(.*)'$") or val
+          config[key] = val
+        end
+      end
+    end
+  end
+  f:close()
+end
+
+function M.parse_cli(arg)
+  local cli = { breaks = {}, config_file = nil, script_path = nil, script_args = {} }
+  local i = 1
+  while i <= #arg do
+    local a = arg[i]
+    if a == "-h" or a == "--help" then
+      M.print_help()
+      os.exit(0)
+    elseif a == "-v" or a == "--version" then
+      print("sild " .. SILD_VERSION)
+      os.exit(0)
+    elseif a == "-b" or a == "--break" then
+      i = i + 1
+      local n = tonumber(arg[i])
+      if not n then
+        io.stderr:write("ERROR: --break requires a line number\n")
+        os.exit(1)
+      end
+      cli.breaks[#cli.breaks + 1] = math.floor(n)
+    elseif a == "-c" or a == "--config" then
+      i = i + 1
+      cli.config_file = arg[i]
+      if not cli.config_file then
+        io.stderr:write("ERROR: --config requires a filename\n")
+        os.exit(1)
+      end
+    elseif a == "-l" or a == "--lines" then
+      i = i + 1
+      local n = tonumber(arg[i])
+      if not n then
+        io.stderr:write("ERROR: --lines requires a number\n")
+        os.exit(1)
+      end
+      config.code_lines = math.floor(n)
+      config.auto_size  = false
+    elseif a == "-C" or a == "--columns" then
+      i = i + 1
+      local n = tonumber(arg[i])
+      if not n then
+        io.stderr:write("ERROR: --columns requires a number\n")
+        os.exit(1)
+      end
+      config.code_inner = math.floor(n)
+      config.auto_size  = false
+    elseif a == "-W" or a == "--width" then
+      i = i + 1
+      local n = tonumber(arg[i])
+      if not n then
+        io.stderr:write("ERROR: --width requires a number\n")
+        os.exit(1)
+      end
+      config.hist_width = math.floor(n)
+      config.auto_size  = false
+    elseif a:sub(1, 1) == "-" then
+      io.stderr:write("ERROR: unknown option '" .. a .. "' (try --help)\n")
+      os.exit(1)
+    else
+      cli.script_path = a
+      for j = i + 1, #arg do
+        cli.script_args[#cli.script_args + 1] = arg[j]
+      end
+      break
+    end
+    i = i + 1
+  end
+  return cli
+end
+
+local function row(flags, desc)
+  print(string.format("  %-28s %s", flags, desc))
+end
+local function annotations(tag, desc)
+  print(string.format("  %-30s %s", tag, desc))
+end
+
+function M.print_help(version)
+  -- Placed here to avoid circular require
+	local ansi = require("sild.ansi")
+  print(ansi.color_bold("sild " .. (version or "?") .. " – The Small Interactive Lua Debugger"))
+  print()
+  print("Usage: sild [options] <script.lua> [script args...]")
+  print()
+  print("Options:")
+  row("-b, --break <line>", "set a breakpoint at <line> in the target script")
+  row("-c, --config <file>", "load config from <file> instead of default")
+  row("-l, --lines <n>", "number of source lines shown in code view (disables auto_size)")
+  row("-C, --columns <n>", "inner width of the code box in characters (disables auto_size)")
+  row("-W, --width <n>", "history column width in characters (disables auto_size)")
+  row("-h, --help", "show this help and exit")
+  row("-v, --version", "show version and exit")
+  print()
+  print("Inline annotations (place in your script, act on the following line):")
+  annotations("--- BREAK", "unconditional breakpoint")
+  annotations("--- BREAK if <expr>", "conditional breakpoint")
+  annotations("--- WATCH <expr>", "auto-register a watch expression")
+  annotations("--- UNWATCH <expr>", "remove a watch expression")
+  print()
+  print("Default config location: ~/.config/sild/sild.cfg")
+  print()
+end
+
+return M
diff --git a/lua_src/sild/debugger.lua b/lua_src/sild/debugger.lua
new file mode 100644
index 0000000..54b3682
--- /dev/null
+++ b/lua_src/sild/debugger.lua
@@ -0,0 +1,151 @@
+local session = require("sild.session")
+local ansi = require("sild.ansi")
+
+local M = {}
+
+local function is_internal(source)
+  return source:sub(1, 7) == "@sild."
+end
+
+local pending_writes = {}
+
+function M.build_env(locals_snapshot)
+  local env = setmetatable({}, { __index = _G })
+  for _, v in ipairs(locals_snapshot) do
+    env[v.name] = v.value
+  end
+  return env
+end
+
+function M.write(locals_snapshot, env, name, expr)
+  local eval = require("sild.eval")
+  local newval, ok, err = eval.eval(expr, env)
+  if not ok then
+    print(ansi.color_error("eval error: " .. tostring(err)))
+    return
+  end
+  local found = false
+  for _, v in ipairs(locals_snapshot) do
+    if v.name == name then
+      found = true; break
+    end
+  end
+  if not found then
+    print(ansi.color_error("no local '" .. name .. "' in scope"))
+    return
+  end
+  env[name] = newval
+  pending_writes[#pending_writes + 1] = { name = name, value = newval }
+  local vs = type(newval) == "string"
+      and ansi.color_string('"' .. newval .. '"')
+      or ansi.color_value(tostring(newval))
+  print(ansi.color_header(name) .. " <- " .. vs)
+end
+
+local function snapshot()
+  local locals, seen = {}, {}
+  local i = 1
+  while true do
+    local name, val = debug.getlocal(3, i)
+    if not name then break end
+    if name:sub(1, 1) ~= "(" and not seen[name] then
+      locals[#locals + 1] = { name = name, value = val, idx = i }
+      seen[name] = true
+    end
+    i = i + 1
+  end
+  return locals
+end
+
+function M.install_hook(bp_only, run_commands)
+  debug.sethook(function(event)
+    if event == "call" then
+      local fi = debug.getinfo(2, "Sn")
+      if not fi or is_internal(fi.source) then return end
+      local fn = fi.name or "?"
+      session.calls[fn] = (session.calls[fn] or 0) + 1
+      return
+    end
+
+    if event ~= "line" then return end
+
+    local info = debug.getinfo(2, "Sl")
+    if not info or is_internal(info.source) then return end
+
+    if #pending_writes > 0 then
+      local i = 1
+      while true do
+        local n = debug.getlocal(2, i)
+        if not n then break end
+        for _, pw in ipairs(pending_writes) do
+          if pw.name == n then debug.setlocal(2, i, pw.value) end
+        end
+        i = i + 1
+      end
+      pending_writes = {}
+    end
+
+    if session.continue_until then
+      if info.source == session.continue_until.source then
+        if info.currentline == session.continue_until.line then
+          session.continue_until = nil
+          M.install_hook(false, run_commands)
+          run_commands(info, snapshot())
+        end
+      end
+      return
+    end
+
+    local bps = session.breakpoints[info.source]
+    if bps then
+      local cond = bps[info.currentline]
+      if cond then
+        local fire = true
+        if type(cond) == "string" then
+          local snap = snapshot()
+          local ev = require("sild.eval")
+          local env = M.build_env(snap)
+          local v, ok = ev.eval(cond, env)
+
+          fire = ok and (v ~= false and v ~= nil)
+
+          if fire then
+            print(ansi.color_prompt("\n*** breakpoint at line " .. info.currentline .. " ***"))
+            M.install_hook(false, run_commands)
+            run_commands(info, snap)
+            return
+          end
+        elseif cond == true then
+          print(ansi.color_prompt("\n*** breakpoint at line " .. info.currentline .. " ***"))
+          M.install_hook(false, run_commands)
+          run_commands(info, snapshot())
+          return
+        end
+      end
+    end
+
+    if not bp_only then
+      run_commands(info, snapshot())
+    end
+  end, "cl")
+end
+
+function M.finish(run_commands)
+  debug.sethook(function(event)
+    if event ~= "return" then return end
+
+    local return_info = debug.getinfo(2, "S")
+    if return_info and is_internal(return_info.source) then return end
+
+    debug.sethook(function(next_event)
+      if next_event ~= "line" then return end
+
+      local caller_info = debug.getinfo(2, "Sl")
+      if caller_info and not is_internal(caller_info.source) then
+        M.install_hook(false, run_commands)
+      end
+    end, "l")
+  end, "r")
+end
+
+return M
diff --git a/lua_src/sild/eval.lua b/lua_src/sild/eval.lua
new file mode 100644
index 0000000..618d3cb
--- /dev/null
+++ b/lua_src/sild/eval.lua
@@ -0,0 +1,71 @@
+local ansi = require("sild.ansi")
+
+local M = {}
+
+function M.eval(expr, env)
+  -- Use 'return' for expressions like 'x + 1'
+  local chunk, err = load("return " .. expr, "=eval", "t", env or _G)
+  if not chunk then
+    -- Raw evaluate for expressions like 'x = 1'
+    chunk, err = load(expr, "=eval", "t", env or _G)
+  end
+  if not chunk then return nil, false, err end
+  local ok, result = pcall(chunk)
+  if ok then
+    return result, true, nil
+  else
+    return nil, false, tostring(result)
+  end
+end
+
+local function dump_table(tbl, indent, depth, max_depth)
+  local pad  = string.rep("  ", indent)
+  local keys = {}
+
+  for key in pairs(tbl) do
+    keys[#keys + 1] = key
+  end
+
+  -- Table is sorted alphanumerically to make output deterministic
+  table.sort(keys, function(a, b)
+    if type(a) == "number" and type(b) == "number" then
+      return a < b
+    end
+    return tostring(a) < tostring(b)
+  end)
+
+  for _, key in ipairs(keys) do
+    local value = tbl[key]
+    local key_str = ansi.color_header(tostring(key))
+    if type(value) == "table" and depth < max_depth then
+      print(pad .. key_str .. " = {")
+      dump_table(value, indent + 1, depth + 1, max_depth)
+      print(pad .. "}")
+    else
+      local value_str
+      if type(value) == "string" then
+        value_str = ansi.color_string('"' .. value .. '"')
+      elseif type(value) == "boolean" then
+        value_str = ansi.color_keyword(tostring(value))
+      else
+        value_str = ansi.color_value(tostring(value))
+      end
+      print(pad .. key_str .. " = " .. value_str .. "  " .. ansi.color_dim("(" .. type(value) .. ")"))
+    end
+  end
+end
+
+function M.do_dump(expr, env, max_depth)
+  local tbl, ok, err = M.eval(expr, env)
+  if not ok then
+    print(ansi.color_error("eval error: " .. tostring(err))); return
+  end
+  if type(tbl) ~= "table" then
+    print(ansi.color_error(expr .. " is not a table")); return
+  end
+  print(ansi.color_header(expr) .. " = {")
+  dump_table(tbl, 1, 1, max_depth)
+  print("}")
+end
+
+return M
diff --git a/lua_src/sild/highlight.lua b/lua_src/sild/highlight.lua
new file mode 100644
index 0000000..3f8cbb0
--- /dev/null
+++ b/lua_src/sild/highlight.lua
@@ -0,0 +1,84 @@
+local ansi = require("sild.ansi")
+
+local M = {}
+
+local file_cache = {}
+
+function M.read_file(path)
+  if file_cache[path] then return file_cache[path] end
+  local lines = {}
+  local f = io.open(path, "r")
+  if f then
+    for l in f:lines() do lines[#lines + 1] = l end
+    f:close()
+  else
+    lines[1] = "-- FILE NOT AVAILABLE: " .. path
+  end
+  file_cache[path] = lines
+  return lines
+end
+
+local KEYWORDS = {}
+for _, k in ipairs({
+  "and",
+  "break",
+  "do",
+  "else",
+  "elseif",
+  "end",
+  "false",
+  "for",
+  "function",
+  "goto",
+  "if",
+  "in",
+  "local",
+  "nil",
+  "not",
+  "or",
+  "repeat",
+  "return",
+  "then",
+  "true",
+  "until",
+  "while",
+}) do
+  -- Table placement is irrelevant so use k,v boolean checking
+  KEYWORDS[k] = true
+end
+
+function M.highlight(line)
+  local out = {}
+  local i = 1
+
+  while i <= #line do
+    local char = line:sub(i, i)
+    -- Comment Matching
+    if line:sub(i, i + 1) == "--" then
+      out[#out + 1] = ansi.color_comment(line:sub(i))
+      break
+    -- String Matching
+    elseif char == '"' or char == "'" then
+      local j = line:find(char, i + 1, true)
+      out[#out + 1] = ansi.color_string(line:sub(i, j or #line))
+      i = (j or #line) + 1
+    -- Number Matching
+    elseif char:match("%d") or (char == "." and line:sub(i + 1, i + 1):match("%d")) then
+      local str = line:match("^[%d%.]+", i)
+      out[#out + 1] = ansi.color_number(str); i = i + #str
+    -- Keyword Matching
+    elseif char:match("[%a_]") then
+      local str = line:match("^[%w_]+", i)
+      out[#out + 1] = KEYWORDS[str] and ansi.color_keyword(str) or str
+      i = i + #str
+    -- Operator Matching
+    elseif char:match("[%+%-%*/%%#%^&|~<>=]") then
+      out[#out + 1] = ansi.color_operator(char); i = i + 1
+    else
+      out[#out + 1] = char; i = i + 1
+    end
+  end
+  return table.concat(out)
+end
+
+return M
diff --git a/lua_src/sild/parse.lua b/lua_src/sild/parse.lua
new file mode 100644
index 0000000..1757658
--- /dev/null
+++ b/lua_src/sild/parse.lua
@@ -0,0 +1,44 @@
+local highlight = require("sild.highlight")
+
+local M = {}
+
+function M.annotations(path)
+  local breakpoints, actions = {}, {}
+  -- Use highlight's read_file for caching purposes
+  local lines = highlight.read_file(path)
+
+  local function add_action(line_number, action_table)
+    actions[line_number] = actions[line_number] or {}
+    actions[line_number][#actions[line_number] + 1] = action_table
+  end
+
+  for i, line in ipairs(lines) do
+      local next_line = i + 1
+
+      local stripped = line:match("^%s*(.-)%s*$")
+
+      if stripped:sub(1, 3) == "---" then
+          local rest = stripped:sub(4):match("^%s*(.-)%s*$")
+
+          if rest:sub(1, 8) == "BREAK if" then
+              breakpoints[next_line] = rest:sub(9):match("^%s*(.-)%s*$")
+          elseif rest == "BREAK" then
+              breakpoints[next_line] = true
+          elseif rest:sub(1, 5) == "WATCH" then
+              local condition = rest:sub(6):match("^%s*(.-)%s*$")
+              if condition ~= "" then
+                  add_action(next_line, { type = "watch", condition = condition })
+              end
+          elseif rest:sub(1, 7) == "UNWATCH" then
+              local condition = rest:sub(8):match("^%s*(.-)%s*$")
+              if condition ~= "" then
+                  add_action(next_line, { type = "unwatch", condition = condition })
+              end
+          end
+      end
+  end
+
+  return breakpoints, actions
+end
+
+return M
diff --git a/lua_src/sild/session.lua b/lua_src/sild/session.lua
new file mode 100644
index 0000000..258116c
--- /dev/null
+++ b/lua_src/sild/session.lua
@@ -0,0 +1,11 @@
+local M = {}
+
+-- Global Session States
+M.history        = {}
+M.watches        = {}
+M.breakpoints    = {}
+M.actions        = {}
+M.continue_until = nil
+M.calls          = {}
+
+return M
diff --git a/lua_src/sild/ui.lua b/lua_src/sild/ui.lua
new file mode 100644
index 0000000..2c8df39
--- /dev/null
+++ b/lua_src/sild/ui.lua
@@ -0,0 +1,109 @@
+local ansi = require("sild.ansi")
+local highlight = require("sild.highlight")
+local session = require("sild.session")
+local config = require("sild.config").config
+
+local M = {}
+
+-- TODO: Make part of config
+local SPLIT_POSITION = 0.35
+
+do
+  if config.auto_size then
+    local f = io.popen("stty size 2>/dev/null")
+    if f then
+      local out = f:read("*l")
+      f:close()
+      if out then
+        local rows, cols = out:match("^(%d+)%s+(%d+)$")
+        rows, cols = tonumber(rows), tonumber(cols)
+        if rows and cols then
+          config.code_lines = math.max(5, rows - 8)
+          config.hist_width = math.max(20, math.floor(cols * SPLIT_POSITION))
+          config.code_inner = math.max(20, cols - config.hist_width - 4)
+        end
+      end
+    end
+  end
+end
+
+local function layout()
+  return config.code_lines, config.code_inner, config.hist_width,
+      math.floor(config.code_lines / 2)
+end
+
+function M.fmt_header(src, line)
+  return ansi.color_header("[") .. src .. ansi.color_header(":") ..
+      ansi.color_linenumber(tostring(line)) .. ansi.color_header("]")
+end
+
+function M.draw(info)
+  io.write("\027[2J\027[H")
+
+  local CODE_LINES, CODE_INNER, HIST_WIDTH, HALF = layout()
+  local cur                                      = info and info.currentline or 0
+  local path                                     = info and info.source:sub(1, 1) == "@" and info.source:sub(2) or nil
+  local src_key                                  = info and info.source
+
+  local code_rows                                = {}
+  if path then
+    local all = highlight.read_file(path)
+    local last = math.min(#all, cur + HALF)
+    local first = math.max(1, math.min(cur - HALF, last - CODE_LINES + 1))
+
+    for i = first, last do
+      local is_cur = (i == cur)
+      local has_bp = session.breakpoints[src_key] and session.breakpoints[src_key][i]
+      local num_plain = string.format("%4d", i)
+      local avail = CODE_INNER - 8
+      local raw = (all[i] or ""):gsub("%s+$", "")
+      local code_plain = raw:sub(1, avail)
+      local plain_len = 4 + 1 + 2 + 1 + #code_plain
+
+      local num_col = has_bp and ansi.color_breakpoint(num_plain) or ansi.color_dim(num_plain)
+      local mark_col = is_cur and ansi.color_current(">>") or ansi.color_dim("  ")
+      local code_col = highlight.highlight(code_plain)
+      if is_cur then code_col = ansi.color_bold(code_col) end
+
+      code_rows[#code_rows + 1] = {
+        colored   = num_col .. " " .. mark_col .. " " .. code_col,
+        plain_len = plain_len,
+      }
+    end
+  end
+  while #code_rows < CODE_LINES do
+    code_rows[#code_rows + 1] = { colored = "", plain_len = 0 }
+  end
+
+  local entries_visible = math.floor(CODE_LINES / 2)
+  local start = math.max(1, #session.history - entries_visible + 1)
+
+  local hist_lines = {}
+  for i = start, #session.history do
+    local h = session.history[i]
+    local trimmed = h.text:match("^%s*(.-)%s*$")
+    hist_lines[#hist_lines + 1] = M.fmt_header(h.src, h.line)
+    hist_lines[#hist_lines + 1] = "  " .. ansi.color_dim(">") .. " " .. trimmed
+  end
+  while #hist_lines < CODE_LINES do
+    hist_lines[#hist_lines + 1] = ""
+  end
+
+  local top = "╭" .. string.rep("─", CODE_INNER) .. "╮"
+  local bot = "╰" .. string.rep("─", CODE_INNER) .. "╯"
+
+  local pad = string.rep(" ", HIST_WIDTH + 2)
+  io.write(pad .. top .. "\n")
+  for i = 1, CODE_LINES do
+    local hl = hist_lines[i] or ""
+    local cr = code_rows[i]
+    local spaces = math.max(0, CODE_INNER - cr.plain_len)
+    io.write(
+      ansi.fit(hl, HIST_WIDTH) .. "  │" ..
+      cr.colored .. string.rep(" ", spaces) .. "│\n"
+    )
+  end
+  io.write(pad .. bot .. "\n\n")
+end
+
+return M
diff --git a/rust_src/main.rs b/rust_src/main.rs
new file mode 100755
index 0000000..b73b49f
--- /dev/null
+++ b/rust_src/main.rs
@@ -0,0 +1,65 @@
+use std::{arch, fs::read_to_string};
+
+use mlua::prelude::*;
+
+const SILD_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+const MAIN_LUA: &str = include_str!("../lua_src/main.lua");
+
+include!(concat!(env!("OUT_DIR"), "/bundled_modules.rs"));
+
+fn main() -> LuaResult<()> {
+  let lua = unsafe { Lua::unsafe_new() };
+  {
+    let package: LuaTable = lua.globals().get("package")?;
+
+    let searchers: LuaTable = package.get("searchers")?;
+
+    let registry = lua.create_table()?;
+    for (name, src) in BUNDLED_MODULES {
+      registry.set(*name, *src)?;
+    }
+
+    let searcher = lua.create_function(move |lua, module_name: String| {
+      match registry.get::<Option<String>>(module_name.as_str())? {
+        Some(src) => {
+          let loader = lua
+            .load(&src)
+            .set_name(format!("@{module_name}"))
+            .into_function()?;
+          Ok(LuaMultiValue::from_vec(vec![LuaValue::Function(loader)]))
+        }
+        None => Ok(LuaMultiValue::from_vec(vec![LuaValue::String(
+          lua.create_string(format!("\n\tMLUA: no bundled module '{module_name}'"))?,
+        )])),
+      }
+    })?;
+
+    let len = searchers.raw_len();
+    for i in (2..=len).rev() {
+      let v: LuaValue = searchers.get(i)?;
+      searchers.set(i + 1, v)?;
+    }
+
+    // Setting in position 2 gives priority over filesystem search
+    searchers.set(2, searcher)?;
+  }
+
+  {
+    let args: Vec<String> = std::env::args().collect();
+    let binary = args.first().map(|s| s.as_str()).unwrap_or("");
+    let arg_table = lua.create_table()?;
+    arg_table.raw_set(-1i32, binary)?;
+    arg_table.raw_set(0i32, binary)?;
+    for (i, val) in args.iter().skip(1).enumerate() {
+      arg_table.raw_set((i + 1) as i32, val.as_str())?;
+    }
+    lua.globals().set("arg", arg_table)?;
+  }
+
+  lua.globals().set("SILD_VERSION", SILD_VERSION)?;
+
+  lua.load(MAIN_LUA).set_name("@main.lua").exec()?;
+
+  Ok(())
+}