Skip to content

debugger

stable

Runtime inspection and assertion utilities for Zolo programs, including value introspection, deep equality, table diffing, breakpoint management, and variable watching.

use plugin debugger::{type_of, inspect, pretty_print, …}
33 functions Observability
/ filter jk navigate Esc clear
Functions (33)
  1. type_of Returns the runtime type name of a value
  2. inspect Returns a detailed debug string representation
  3. pretty_print Pretty-formats a value with indentation
  4. deep_equal Deep structural equality check
  5. table_diff Computes added/removed/changed entries between two tables
  6. sizeof_estimate Estimates heap size in bytes
  7. assert_equal Asserts two values are deeply equal
  8. assert_true Asserts a value is boolean true
  9. assert_false Asserts a value is boolean false
  10. assert_not_nil Asserts a value is not nil
  11. assert_type Asserts a value has the expected type
  12. dump Dumps all arguments with index labels
  13. snapshot Creates a labeled snapshot table of a value
  14. table_keys Returns a table of all keys in a table
  15. table_values Returns a table of all values in a table
  16. table_len Returns the number of entries in a table
  17. Inspector.new Creates a new value-watching inspector
  18. Inspector.watch Records a value under a name
  19. Inspector.get Returns the latest recorded value for a name
  20. Inspector.history Returns all recorded values for a name
  21. Inspector.unwatch Stops tracking a name
  22. Inspector.clear Clears all watched values
  23. Inspector.watched_names Lists all tracked names
  24. BreakpointManager.new Creates a new breakpoint manager
  25. BreakpointManager.add Adds an enabled named breakpoint
  26. BreakpointManager.remove Removes a named breakpoint
  27. BreakpointManager.enable Enables a named breakpoint
  28. BreakpointManager.disable Disables a named breakpoint
  29. BreakpointManager.is_enabled Checks whether a breakpoint is enabled
  30. BreakpointManager.hit Records a hit, returns whether it was enabled
  31. BreakpointManager.hit_count Returns the recorded hit count for a name
  32. BreakpointManager.list Lists all breakpoints with state and hits
  33. BreakpointManager.clear Removes all breakpoints

Overview

The debugger plugin is a toolkit for inspecting program state at runtime and writing lightweight, dependency-free tests. It groups three kinds of helpers: free functions for value introspection (type_of, inspect, pretty_print, sizeof_estimate), structural comparison and assertions (deep_equal, table_diff, assert_*), and table utilities (table_keys, table_values, table_len).

It also exposes two handle-based, stateful classes. An Inspector records the full history of named values as they change over time, so you can watch a variable evolve across a loop or a sequence of mutations. A BreakpointManager tracks named breakpoints with per-name enable/disable flags and hit counters, letting you gate and count execution paths without a real debugger attached.

Reach for the free functions when you want a quick, stateless snapshot or an inline invariant check, and for the classes when you need to accumulate observations across many calls. The assertions raise an error on failure, making them suitable for tests and runtime guards alike.

Common patterns

Inline assertions for a quick test:

use plugin debugger::{assert_equal, assert_true, assert_type}

fn add(a, b) {
  return a + b
}

assert_equal(add(2, 3), 5)
assert_true(add(2, 3) > 0)
assert_type(add(2, 3), "integer")
print("all assertions passed")

Diff two snapshots of a config and report what changed:

use plugin debugger::{snapshot, table_diff, pretty_print}

let before = #{"host": "localhost", "port": 8080}
let after = #{"host": "localhost", "port": 9090, "tls": true}

let snap = snapshot("config_before", before)
print("captured: {snap["label"]}")

let diffs = table_diff(before, after)
for d in diffs {
  print("{d["key"]}: {d["status"]}")
}
print(pretty_print(after, 0))

Watch a value evolve across a loop and replay its history:

use plugin debugger::{Inspector, table_len}

let insp = Inspector.new()
let total = 0
for i in 1..5 {
  total = total + i
  insp.watch("total", total)
}
print("latest: {insp.get("total")}")
print("steps: {table_len(insp.history("total"))}")

Returns the runtime type name of a value

Returns the runtime type name of any value as a string. Possible values: "nil", "bool", "number", "integer", "string", "handle", "table", "bytes", "function".

use plugin debugger::{type_of}

print(type_of(42))       // "integer"
print(type_of("hello"))  // "string"
print(type_of(nil))      // "nil"

Use it to branch on a value's shape before processing it:

use plugin debugger::{type_of}

fn describe(v) {
  if type_of(v) == "table" {
    print("a table")
  } else {
    print("a {type_of(v)}")
  }
}

describe(#{"a": 1})
describe(3.14)

Returns a detailed debug string representation

Returns a detailed debug representation of a value, showing its type and content (e.g. Integer(1), String("x"), Table({...})). Useful for logging complex tables or unexpected values where the plain printed form would be ambiguous.

use plugin debugger::{inspect}

let data = #{"x": 1, "y": 2}
print(inspect(data))
// Table({String("x"): Integer(1), String("y"): Integer(2)})

Because it tags every value with its type, inspect distinguishes look-alikes that print would render identically:

use plugin debugger::{inspect}

print(inspect(42))    // Integer(42)
print(inspect(42.0))  // Number(42.000000)
print(inspect("42"))  // String("42")

Pretty-formats a value with indentation

Formats a value with human-readable indentation. indent sets the initial indentation column and defaults to 0. Ideal for logging deeply nested tables in a readable, multi-line form.

use plugin debugger::{pretty_print}

let config = #{"host": "localhost", "port": 8080}
print(pretty_print(config, 0))

Nested tables are expanded recursively, with child entries indented two spaces deeper:

use plugin debugger::{pretty_print}

let server = #{"name": "api", "limits": #{"max": 100, "timeout": 30}}
print(pretty_print(server, 0))

Deep structural equality check

Performs a recursive structural equality check on any two values. Unlike a shallow ==, this compares tables entry by entry, so two distinct tables with the same contents are considered equal.

use plugin debugger::{deep_equal}

let a = #{"x": 1}
let b = #{"x": 1}
print(deep_equal(a, b))          // true
print(deep_equal(a, #{"x": 2}))  // false

It recurses through nested structures as well:

use plugin debugger::{deep_equal}

let a = #{"pos": #{"x": 1, "y": 2}}
let b = #{"pos": #{"x": 1, "y": 2}}
print(deep_equal(a, b))  // true

Computes added/removed/changed entries between two tables

Compares two tables and returns a list of differences. Each entry is a table with key, status ("added", "removed", or "changed"), old_value, and new_value. Keys present only in b are "added", keys present only in a are "removed", and keys whose values differ are "changed".

use plugin debugger::{table_diff}

let old = #{"name": "Alice", "age": 30}
let new = #{"name": "Alice", "age": 31, "city": "NY"}
let diffs = table_diff(old, new)
print(diffs[1]["status"])  // "changed"

Walk the full diff to build a change report:

use plugin debugger::{table_diff}

let old = #{"a": 1, "b": 2}
let new = #{"a": 1, "c": 3}
for d in table_diff(old, new) {
  print("{d["key"]} -> {d["status"]}")
}

Estimates heap size in bytes

Returns an estimated heap size in bytes for a value. Strings and byte buffers count their length plus overhead, and tables sum the estimated size of every key and value. Useful for rough memory profiling of large structures.

use plugin debugger::{sizeof_estimate}

let data = #{"key": "some long string value"}
print(sizeof_estimate(data))

Compare relative sizes to find which structure dominates:

use plugin debugger::{sizeof_estimate}

let small = #{"id": 1}
let big = #{"id": 1, "blob": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}
print("small: {sizeof_estimate(small)}")
print("big: {sizeof_estimate(big)}")

Asserts two values are deeply equal

Asserts that two values are deeply equal (using the same comparison as deep_equal). Raises an error with a descriptive message if they differ. Use in tests and invariant checks.

use plugin debugger::{assert_equal}

let result = 2 + 2
assert_equal(result, 4)

It works on tables too, so you can assert whole structures at once:

use plugin debugger::{assert_equal}

fn make_point(x, y) {
  return #{"x": x, "y": y}
}

assert_equal(make_point(1, 2), #{"x": 1, "y": 2})

Asserts a value is boolean true

Asserts that a value is the boolean true. Raises an error if the value is false or is not a bool at all.

use plugin debugger::{assert_true}

assert_true(1 < 2)

Asserts a value is boolean false

Asserts that a value is the boolean false. Raises an error if the value is true or is not a bool.

use plugin debugger::{assert_false}

assert_false(1 > 2)

Asserts a value is not nil

Asserts that a value is not nil. Raises an error if it is. Handy for guarding the result of a lookup that may return nothing.

use plugin debugger::{assert_not_nil}

let x = "hello"
assert_not_nil(x)

Asserts a value has the expected type

Asserts that a value's runtime type (as reported by type_of) matches expected_type (e.g. "string", "integer", "table"). Raises an error naming both the expected and actual type on mismatch.

use plugin debugger::{assert_type}

assert_type(42, "integer")
assert_type("hi", "string")

Dumps all arguments with index labels

Formats every provided argument with its zero-based index label, one per line, using the same detailed representation as inspect. Useful for quickly logging several values at once.

use plugin debugger::{dump}

let msg = dump(1, "hello", true)
print(msg)
// [0] Integer(1)
// [1] String("hello")
// [2] Bool(true)

Creates a labeled snapshot table of a value

Creates a labeled snapshot table with fields label, value, and type. Useful for capturing state at a point in time and stashing it for later comparison.

use plugin debugger::{snapshot}

let snap = snapshot("before_update", #{"count": 5})
print(snap["label"])  // "before_update"
print(snap["type"])   // "table"

The captured value field holds the original value, so a snapshot can be fed straight back into table_diff or deep_equal:

use plugin debugger::{snapshot, deep_equal}

let snap = snapshot("state", #{"n": 1})
print(deep_equal(snap["value"], #{"n": 1}))  // true

Returns a table of all keys in a table

Returns a table (array) of all keys from the given table, in insertion order.

use plugin debugger::{table_keys}

let data = #{"a": 1, "b": 2}
let keys = table_keys(data)
print(keys[1])  // "a"

Returns a table of all values in a table

Returns a table (array) of all values from the given table, in insertion order.

use plugin debugger::{table_values}

let data = #{"x": 10, "y": 20}
let vals = table_values(data)
print(vals[1])  // 10

Returns the number of entries in a table

Returns the number of entries in a table.

use plugin debugger::{table_len}

let data = #{"a": 1, "b": 2, "c": 3}
print(table_len(data))  // 3

Creates a new value-watching inspector

Creates a new, empty Inspector. The inspector keeps an ordered history for each watched name, so repeated calls to watch accumulate rather than overwrite.

use plugin debugger::{Inspector}

let insp = Inspector.new()
insp.watch("score", 0)
insp.watch("score", 10)
print(insp.get("score"))  // 10

Records a value under a name

Records value in the history for name, appending it after any previously recorded values. Call it each time the value changes to build a timeline.

use plugin debugger::{Inspector, table_len}

let insp = Inspector.new()
insp.watch("score", 0)
insp.watch("score", 25)
print(table_len(insp.history("score")))  // 2

Returns the latest recorded value for a name

Returns the most recently recorded value for name, or nil if nothing has been watched under that name.

use plugin debugger::{Inspector}

let insp = Inspector.new()
insp.watch("hp", 100)
insp.watch("hp", 80)
print(insp.get("hp"))     // 80
print(insp.get("mana"))   // nil

Returns all recorded values for a name

Returns a table (array) of every value recorded for name, in the order they were watched. Returns an empty table if the name was never watched.

use plugin debugger::{Inspector}

let insp = Inspector.new()
insp.watch("step", 1)
insp.watch("step", 2)
insp.watch("step", 3)
let hist = insp.history("step")
print(hist[1])  // 1
print(hist[3])  // 3

Stops tracking a name

Stops tracking name and discards its recorded history. Other watched names are unaffected.

use plugin debugger::{Inspector}

let insp = Inspector.new()
insp.watch("temp", 20)
insp.unwatch("temp")
print(insp.get("temp"))  // nil

Clears all watched values

Removes all watched names and their histories, resetting the inspector to empty.

use plugin debugger::{Inspector}

let insp = Inspector.new()
insp.watch("a", 1)
insp.watch("b", 2)
insp.clear()
print(insp.get("a"))  // nil

Lists all tracked names

Returns a sorted table (array) of all names currently being watched.

use plugin debugger::{Inspector}

let insp = Inspector.new()
insp.watch("alpha", 1)
insp.watch("beta", 2)
let names = insp.watched_names()
print(names[1])  // "alpha"
print(names[2])  // "beta"

Creates a new breakpoint manager

Creates a new, empty BreakpointManager. Breakpoints are identified by name, start enabled when added, and each carries an independent hit counter.

use plugin debugger::{BreakpointManager}

let bp = BreakpointManager.new()
bp.add("loop_entry")
print(bp.is_enabled("loop_entry"))  // true

Adds an enabled named breakpoint

Adds a named breakpoint in the enabled state and initializes its hit count to zero. Adding an existing name re-enables it.

use plugin debugger::{BreakpointManager}

let bp = BreakpointManager.new()
bp.add("entry")
bp.add("exit")
print(bp.is_enabled("exit"))  // true

Removes a named breakpoint

Removes a named breakpoint along with its hit count. Removing an unknown name is a no-op.

use plugin debugger::{BreakpointManager}

let bp = BreakpointManager.new()
bp.add("entry")
bp.remove("entry")
print(bp.is_enabled("entry"))  // false

Enables a named breakpoint

Enables a previously added breakpoint so that hit will count for it again. Has no effect on names that were never added.

use plugin debugger::{BreakpointManager}

let bp = BreakpointManager.new()
bp.add("entry")
bp.disable("entry")
bp.enable("entry")
print(bp.is_enabled("entry"))  // true

Disables a named breakpoint

Disables a breakpoint without removing it. While disabled, hit returns false and does not increment the counter, but the breakpoint and its accumulated count are preserved.

use plugin debugger::{BreakpointManager}

let bp = BreakpointManager.new()
bp.add("entry")
bp.disable("entry")
print(bp.hit("entry"))  // false

Checks whether a breakpoint is enabled

Returns true if the named breakpoint exists and is enabled, otherwise false (including for unknown names).

use plugin debugger::{BreakpointManager}

let bp = BreakpointManager.new()
bp.add("entry")
print(bp.is_enabled("entry"))    // true
print(bp.is_enabled("missing"))  // false

Records a hit, returns whether it was enabled

Records a hit on the named breakpoint. If the breakpoint is enabled, its hit count is incremented and true is returned; if it is disabled or unknown, the count is unchanged and false is returned. Use the return value to decide whether to break.

use plugin debugger::{BreakpointManager}

let bp = BreakpointManager.new()
bp.add("loop")
for i in 1..3 {
  if bp.hit("loop") {
    print("break at iteration {i}")
  }
}

Returns the recorded hit count for a name

Returns the number of times the named breakpoint has been hit while enabled. Returns 0 for unknown names. Disabled hits never advance the count.

use plugin debugger::{BreakpointManager}

let bp = BreakpointManager.new()
bp.add("loop_entry")
bp.hit("loop_entry")
bp.hit("loop_entry")
print(bp.hit_count("loop_entry"))  // 2
bp.disable("loop_entry")
bp.hit("loop_entry")
print(bp.hit_count("loop_entry"))  // still 2

Lists all breakpoints with state and hits

Returns a sorted table (array) of all breakpoints. Each entry is a table with name, enabled, and hit_count fields.

use plugin debugger::{BreakpointManager}

let bp = BreakpointManager.new()
bp.add("a")
bp.add("b")
bp.hit("a")
for entry in bp.list() {
  print("{entry["name"]}: hits={entry["hit_count"]} enabled={entry["enabled"]}")
}

Removes all breakpoints

Removes every breakpoint and resets all hit counts, returning the manager to its empty state.

use plugin debugger::{BreakpointManager, table_len}

let bp = BreakpointManager.new()
bp.add("a")
bp.add("b")
bp.clear()
print(table_len(bp.list()))  // 0
enespt-br