diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | Cargo.lock | 286 | ||||
| -rw-r--r-- | Cargo.toml | 18 | ||||
| -rw-r--r-- | README.md | 276 | ||||
| -rw-r--r-- | build.rs | 97 | ||||
| -rw-r--r-- | config/sild/sild.cfg | 90 | ||||
| -rw-r--r-- | flake.lock | 161 | ||||
| -rw-r--r-- | flake.nix | 169 | ||||
| -rw-r--r-- | lua_src/main.lua | 48 | ||||
| -rw-r--r-- | lua_src/sild/ansi.lua | 41 | ||||
| -rw-r--r-- | lua_src/sild/commands.lua | 278 | ||||
| -rw-r--r-- | lua_src/sild/config.lua | 173 | ||||
| -rw-r--r-- | lua_src/sild/debugger.lua | 151 | ||||
| -rw-r--r-- | lua_src/sild/eval.lua | 71 | ||||
| -rw-r--r-- | lua_src/sild/highlight.lua | 84 | ||||
| -rw-r--r-- | lua_src/sild/parse.lua | 44 | ||||
| -rw-r--r-- | lua_src/sild/session.lua | 11 | ||||
| -rw-r--r-- | lua_src/sild/ui.lua | 109 | ||||
| -rwxr-xr-x | rust_src/main.rs | 65 |
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(()) +} |
