about summary refs log tree commit diff
path: root/lua_src
diff options
context:
space:
mode:
authorvenomade <venomade@venomade.com>2026-05-21 20:34:45 +0100
committervenomade <venomade@venomade.com>2026-05-21 20:34:45 +0100
commit637409d951e9dd1a2c29cd424bd41ff8c14b6d88 (patch)
tree2d41be117f6a9f62562c7b54f06a1b1780c62a3b /lua_src
Initial Commit main
Diffstat (limited to 'lua_src')
-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
10 files changed, 1010 insertions, 0 deletions
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