# Nybl complete documentation > LLM-oriented Markdown export of the Nybl 0.4 language, standard library, tools, and Rust embedding documentation. This file is generated from the documentation sources selected by `docs/data/navigation.json`. For the concise index and integration rules, see https://nybl-lang.com/llms.txt. --- Source: https://nybl-lang.com/docs/ # Welcome to Nybl Nybl is a small, dynamically-typed programming language built to be **embedded inside other Rust programs** and run untrusted code safely. It ships with zero runtime dependencies, three interchangeable execution engines, first-class `no_std` and wasm support, and a resource-bounded sandbox that treats step count, memory, and call depth as first-class invariants — not features bolted on after the fact. If you're here to embed Nybl in a host application, jump to [Embedding Nybl](https://nybl-lang.com/docs/embedding/index.html.md) and start there. The rest of this site is the language guide and reference. Nybl can be compiled to Rust as well (without the sandbox) if you're not running untrusted code and need more performance. Or, you could just use Rust ;) ## Quick example ```nybl // Sum the numbers from 1 to 10 let total = 0 for i in range(1, 11) { total += i } print("Sum: {total}") ``` That's a complete Nybl program — no imports, no boilerplate, no semicolons. The syntax is intentionally familiar: curly braces, `let`, `fn`, pattern matching, modules. Skills transfer directly to Rust, JavaScript, Python. ## Why Nybl? - **Embedded-first design.** The entire language core lives in one crate (`nybl-lang`) with no runtime deps and a minimal API surface. Host interaction goes through a single `NyblHost` trait — you wire up exactly the functions and state you want exposed; Nybl can't reach anything else. - **Sandboxed by default.** No filesystem, no network, no clock, no ambient I/O of any kind. Every side-effecting operation is host-mediated. The sandbox also caps three things the language itself can't escape: steps executed (against runaway loops), bytes allocated (against memory bombs), and fn-call depth (against deep recursion). See [Error Handling → Fatal vs non-fatal](https://nybl-lang.com/docs/errors/index.html.md#fatal-vs-non-fatal). - **Three engines, one language.** Same parser, same semantics, pick the right executor per workload: - **Tree-walker** — fast to start, great diagnostics, zero build step. Ideal for short-lived scripts and REPLs. - **Bytecode VM** — compiles once, runs many times. Best for programs that loop or re-enter a hot path. - **AOT Rust transpiler** — emits plain Rust source that links against `nybl-lang`'s runtime. Closest to native speed; useful when you want compiled artifacts or you're already shipping a Rust build pipeline. All three are wire-compatible on `Value` and `NyblError`, and the test suite pins them to byte-for-byte output agreement via a three-way differential harness. - **`no_std` and wasm.** The core crate compiles for `wasm32-unknown-unknown` and bare-metal targets. Disable default features and enable `no_std` for a `libm`-backed math facade. The default Rust `std` feature wins if Cargo unifies both modes, preserving host diagnostics. - **Friendly errors.** Parse and runtime errors both include the source snippet, a caret under the offending column, and a `hint:` line when the parser or runtime can guess what you meant (`"I don't know what 'pritn' is — did you mean 'print'?"`). Imported-module failures retain their owning module and source. Designed to be read by humans *and* by automated callers that need to correct themselves. - **Explicit mutation.** [`ref` parameters](https://nybl-lang.com/docs/functions/reference-parameters/index.html.md) make caller updates visible at both the declaration and call, then commit all changed targets together only when the function returns normally. - **Small, stable grammar.** Variables, loops, functions, arrays, dicts, structs, enums, pattern matching, modules, `Result` / `Iter` built-ins — that's close to the whole surface. Everything else (math, JSON, iteration helpers, string utilities, test assertions) lives in the bundled `std.*` modules or as methods on the value types. The shape is deliberately small so it stays consistent across versions. ## Where to start - **Embedding Nybl in a host** — [Embedding Nybl](https://nybl-lang.com/docs/embedding/index.html.md) walks through the `NyblHost` trait, resource limits, module resolution, and picking an engine. - **Learning the language** — start with the [Language Guide](https://nybl-lang.com/docs/basics/syntax/index.html.md) and work through `Basics` → `Control Flow` → `Data` → `Functions` → `Modules` → `Error Handling`. - **Looking up a specific thing** — read [Reference Parameters](https://nybl-lang.com/docs/functions/reference-parameters/index.html.md) for `ref`; the [Reference](https://nybl-lang.com/docs/reference/operators/index.html.md) covers [Operators](https://nybl-lang.com/docs/reference/operators/index.html.md), [Built-in Functions](https://nybl-lang.com/docs/reference/builtins/index.html.md), [Methods](https://nybl-lang.com/docs/reference/methods/index.html.md), and the [Grammar](https://nybl-lang.com/docs/reference/grammar/index.html.md). The [Standard Library](https://nybl-lang.com/docs/stdlib/index.html.md) section documents every `std.*` module. - **Trying it interactively** — [`nybl repl`](https://nybl-lang.com/docs/repl/index.html.md) opens a persistent REPL with multi-line input, history, and tab completion. - **Upgrading from 0.3** — [What's new in Nybl 0.4](https://nybl-lang.com/docs/whats-new-0-4/index.html.md) covers every new language and embedding feature plus the breaking syntax changes. --- Source: https://nybl-lang.com/docs/whats-new-0-4/ # What's new in Nybl 0.4 Nybl 0.4 is the first coordinated release after 0.3. All five crates move together to `0.4.1`: ```toml [dependencies] nybl = { package = "nybl-lang", version = "0.4" } nybl-vm = "0.4" # optional bytecode engine nybl-compile = "0.4" # optional AOT transpiler nybl-sys = "0.4" # optional OS-backed host ``` The release makes Nybl substantially more useful as an embedded plugin language: programs can stay alive across host calls, modules have explicit namespaces and type identities, Rust values cross the host boundary through checked conversions, and the walker, VM, and AOT engines expose the same language. ## Persistent programs The walker and VM now expose `NyblInstance`. Load a program once, inspect its direct root-level `pub fn` entries, then call those entries without resetting program state: ```nybl let total = 0 pub fn add(value) { total += value return total } ``` Globals, loaded modules, functions, closures, returned callbacks, user types, methods, and random-number state all remain live. Sandboxed AOT library output generates the same `load`, `entry_points`, `call`, and `call_value` surface. Callbacks are bound to the instance that created them, instances reject re-entry, and failed calls do not reset the program. See [Stateful instances](https://nybl-lang.com/docs/embedding/instances/index.html.md) for the complete walker, VM, and AOT lifecycle. ## Checked Rust value conversions Hosts no longer need to hand-match every nested `Value`. `Value::to_rust`, `FromValue`, and `IntoValue` cover strict numeric scalars, borrowed and owned strings, `Vec`, `Option`, `Result`, and deterministic `BTreeMap` dictionaries. Conversion errors report the nested path that failed, such as `$[0]["stats"]["hp"]`. The fallible `nybl_value!` macro builds JSON-like arrays and dictionaries while preserving Nybl's maximum value-depth invariant: ```rust let request = nybl::nybl_value!({ "name": "Ada", "scores": [10, 20, 30], "nickname": none, })?; ``` See [Typed `Value` conversions](https://nybl-lang.com/docs/embedding/index.html.md#typed-value-conversions). ## A complete module system `use` replaces the old `import` keyword and has four forms: ```nybl use app.config use app.config.{HOST, port} use app.config as config use app.config.{HOST, port} as config ``` Aliased modules expose live value bindings, callable exports, and namespaced types. Types retain the identity of the module that declared them, including through re-exports, so two same-shaped structs from different modules remain distinct. Glob collisions produce warnings and keep the first binding; selective and aliased forms make deliberate conflicts explicit. Modules may now declare an exact allow-list with `pub { name, Type }`. It filters values, functions, types, aliases, and re-exports while keeping private implementation bindings available to exported functions. Modules without a list retain the legacy underscore/selective behavior. Imported parse and runtime errors now render against the source module that owns the failure, including through transitive calls. Read [Modules](https://nybl-lang.com/docs/modules/index.html.md) for resolution, visibility, namespaced construction and patterns, type identity, re-exports, and cycles. ## Constants and naming rules `const` creates a binding that cannot be reassigned or mutated through a container: ```nybl const MAX_RETRIES = 3 const DEFAULTS = ["safe", "fast"] ``` Name shapes are checked at parse time: - values, functions, parameters, fields, aliases, loop variables, and pattern bindings start lowercase or with `_`; - constants are `ALL_CAPS`; - structs, enums, and variants start uppercase. Diagnostics suggest the corrected spelling. See [Variables → Constants](https://nybl-lang.com/docs/basics/variables/index.html.md#constants) and [Name shapes](https://nybl-lang.com/docs/basics/variables/index.html.md#name-shapes-are-checked). ## Transactional `ref` parameters User-defined functions can explicitly update mutable caller variables. The `ref` marker is required in both the parameter declaration and the call: ```nybl fn swap(ref left, ref right) { let old = left left = right right = old } let first = 1 let second = 2 swap(ref first, ref second) print([first, second]) // [2, 1] ``` References are second-class parameters rather than general aliasing values. Each target must be a distinct mutable place rooted in a `let` binding; constants, temporary roots, and captured bindings are rejected. Fields and indexes can be chained to any depth. The callee works on staged copies, then commits every target together only after a normal return. Runtime and fatal sandbox errors roll the call back, including errors caught by `try_call`; a returned `Result::Err` is an ordinary return and commits. Reference parameters work through first-class function aliases, may be forwarded into another ref call, and are supported by the walker, VM, and AOT engines. Built-in and host functions remain value-only, as do Rust `NyblInstance::call` and `call_value` arguments. User-defined methods can declare `ref self` to update a mutable place receiver and can place explicit refs after it. Ordinary `self` receivers are read-only, so attempting to mutate one is a parse error rather than a silently discarded change. Built-in array mutators use the same transaction model implicitly, including nested field and index receivers. Read [Reference Parameters](https://nybl-lang.com/docs/functions/reference-parameters/index.html.md) for target rules, evaluation order, forwarding, rollback, methods, and embedding boundaries. ## Methods replace utility globals Operations that belong to a value now use method syntax: ```nybl value.type() value.to_str() text.to_int() items.len() (-5).abs() (9).sqrt() ``` The complete tables cover universal, numeric, boolean, string, array, dict, `Result`, `Iter`, user-type, and module methods. See [Methods](https://nybl-lang.com/docs/reference/methods/index.html.md). ## Built-in `Result` and recoverable errors `Result` and `RuntimeError` are engine built-ins, so they work without an stdlib import. `Ok(value)` and `Err(error)` are shorthand in both expressions and patterns. Results support `.is_ok()`, `.is_err()`, `.unwrap()`, `.expect()`, `.unwrap_or()`, `.map()`, `.map_err()`, and `.and_then()`. `try` propagates an `Err` from a function. `try_call(callback)` catches a non-fatal runtime error as `Err(RuntimeError)`, while `panic(message)` raises one deliberately. Resource-limit failures remain fatal and cannot be caught. See [Error Handling](https://nybl-lang.com/docs/errors/index.html.md). ## `none`, dictionaries, and lazy iteration Two universal helpers make optional values easy to read: `value.is_none()` and `value.is_some()`. Looking up a missing dictionary key now returns `none`, so optional data can be queried without raising. Arrays, strings, dictionaries, and iterators implement a lazy protocol: ```nybl let it = [10, 20, 30].iter() print(it.next()) // Iter::Next(10) print(it.next()) // Iter::Next(20) ``` `.next()` returns `Iter::Next(value)` or `Iter::Done`, and `for` uses the same protocol. User-defined types can participate by defining `.iter()` and, for a stateful iterator, `.next()`. See [Iter methods](https://nybl-lang.com/docs/reference/methods/index.html.md#iter-methods-iter). ## Multiline expressions and comments Newlines inside `()` and `[]` no longer end a statement, and a line beginning with `.` continues the previous value. This makes calls, conditions, arrays, indexes, and method chains readable across lines. `//` is the line-comment marker. The old integer-division spelling is gone: `/` always produces a `number`; use `(a / b).to_int()` when truncating integer division is intended. A leading `#` is now an error. See [Syntax](https://nybl-lang.com/docs/basics/syntax/index.html.md) and [Automatic semicolons](https://nybl-lang.com/docs/reference/grammar/index.html.md#automatic-semicolons). ## First-class functions and matching Function expressions can be stored, passed, returned, and captured as closures. A final `..rest` parameter collects extra arguments into an array on named functions, closures, and methods. Match expressions support literals, bindings, structs, enum variants, namespaced types, arrays with rests, or-patterns, and guards. Exhaustiveness diagnostics understand local and imported enum declarations. See [Defining Functions](https://nybl-lang.com/docs/functions/defining-functions/index.html.md) and [Pattern Matching](https://nybl-lang.com/docs/control-flow/match/index.html.md). ## Stateful REPL and CLI The REPL now retains declarations between submissions, echoes bare expressions, accepts multiline input, completes keywords and live bindings, and persists command history. `:vars`, `:reset`, `:help`, and `:quit` manage the session. Piped input uses the same submission model and keeps processing after a recoverable error. `nybl run` uses the VM by default (`--novm` selects the walker), while `nybl compile` builds a native executable or emits Rust source. See [REPL](https://nybl-lang.com/docs/repl/index.html.md) and [Command-line interface](https://nybl-lang.com/docs/cli/index.html.md). ## Engine parity, diagnostics, and safety The VM and AOT transpiler now cover the same public language as the walker. The differential suite compares all three engines across modules, closures, methods, matching, iteration, errors, warnings, and limits. Rust-facing additions include bytecode validation before VM execution, copy-on-write container values, safer mutation rules for nested and constant containers, bounded ranges and parser nesting, and hardened VM scope/control flow handling. Diagnostics now carry source columns more consistently, offer targeted hints for names, ranges, match arms, `try`, and shadowing, and retain the correct source context across module calls. ## Migrating from 0.3 Make these mechanical changes: | 0.3 | 0.4 | |-----|-----| | `import foo` | `use foo` | | `# comment` | `// comment` | | `type(x)` | `x.type()` | | `str(x)` | `x.to_str()` | | `int(x)` / `float(x)` | `x.to_int()` / `x.to_float()` | | `len(x)` | `x.len()` | | `a // b` | `(a / b).to_int()` | | standalone `nybl-std` dependency | `nybl-lang`'s default `nybl-std` feature | The repository's `CHANGELOG.md` also lists runtime hardening and the crates.io publishing order. --- Source: https://nybl-lang.com/docs/basics/syntax/ # Syntax Nybl's syntax is deliberately simple. If you've seen Python or a C-family language, most of it will look familiar — curly braces for blocks, `//` for comments, newlines (rather than semicolons) terminating statements. ## Blocks Code blocks use curly braces `{ }` and only appear after control-flow or declaration keywords (`if`, `else`, `while`, `for`, `repeat`, `fn`, `struct`, `enum`, `match`): ```nybl if count > 3 { print("That's a lot!") } ``` > **Important:** The opening `{` must be on the same line as its keyword. Nybl automatically inserts semicolons at the end of lines, so putting `{` on the next line would cause a parse error. ```nybl // Good if count > 3 { print("Nice!") } // Bad — will cause an error if count > 3 { print("Nice!") } ``` ## Statements Statements end with a newline. Nybl automatically inserts semicolons after lines ending in: - An identifier or literal - `true`, `false`, `none` - `break`, `continue`, `return` - `)`, `]`, `}` You can put multiple statements on one line with an explicit semicolon: ```nybl let x = 1; let y = 2 ``` ## Comments Line comments start with `//`. Everything after `//` on that line is ignored: ```nybl // This is a comment let x = 5 // So is this ``` There is no block-comment syntax. `#` is **not** a comment leader — a stray `#` produces a lexer error. ## Identifiers Variable and function names start with a letter or underscore and can contain letters, digits, and underscores: ```nybl let my_var = 5 let _count = 0 let item3 = "hello" ``` ### Case conventions are enforced Nybl checks the *shape* of every declared name at parse time and rejects mismatches with a suggestion: | Declaration | Required shape | Examples | |-------------|----------------|----------| | `let`, `fn`, parameters, fields, aliases, `for`-loop vars, match bindings | starts with a lowercase letter or `_` | `let x`, `fn double(n)`, `for i in …` | | `const` | ALL_CAPS (+ digits / `_`) | `const PI = 3.14`, `const MAX_SIZE = 100` | | `struct`, `enum`, variant names | starts with an uppercase letter | `struct Point`, `enum Shape { Circle, Rect }` | Single-letter types like `enum Dir { N, E, S, W }` are fine — the rule is "starts with an uppercase letter", not "must have a lowercase character somewhere." A leading underscore (`_foo`, `_Internal`, `_DEBUG`) marks a name as "private by convention" — glob `use` imports skip them. The wildcard `_` on its own is used in `let _ = foo()` (explicitly ignore) and in patterns (match anything). ## Whitespace Spaces and tabs are insignificant — use whatever indentation style you like. Only newlines matter (they end statements). ## Keywords These words are reserved and can't be used as identifiers: ``` let const pub fn return if else while for in repeat break continue use as struct enum match try true false none ``` There are no word-spelled logical operators — use `&&`, `||`, `!` rather than `and`, `or`, `not`. --- Source: https://nybl-lang.com/docs/basics/types/ # Types Nybl is dynamically typed — variables can hold any type, and types are checked at runtime. Every value has a `.type()` method that returns its type name: | `.type()` returns | Description | Literal examples | |-------------------|-------------|------------------| | `"int"` | 64-bit signed integer | `0`, `42`, `-7` | | `"number"` | 64-bit floating point | `3.14`, `-0.5`, `4.0` | | `"string"` | UTF-8 text | `"hello"`, `"got {n} items"` | | `"bool"` | Boolean | `true`, `false` | | `"none"` | Absence of a value | `none` | | `"array"` | Ordered, mutable collection | `[1, 2, 3]`, `[]` | | `"dict"` | String-keyed map | `{"x": 10, "name": "Alice"}` | | `"fn"` | First-class function / closure | `fn(x) { return x + 1 }` | | `"struct"` | User-defined struct instance | `Point { x: 3, y: 4 }` | | `"enum"` | User-defined enum variant | `Color::Red` | | `"module"` | Aliased module namespace | result of `use foo as m` | | `"iter"` | Lazy iterator | `[1, 2, 3].iter()`, `"abc".iter()` | | host-defined name | Opaque host resource or capability | returned by a host function | ```nybl let x = 42 print(x.type()) // "int" let y = 3.14 print(y.type()) // "number" let s = "hello" print(s.type()) // "string" ``` An embedding host can return an opaque host value with its own static type name. For example, a file handle created as `Value::new_host("file", value)` reports `"file"` from `.type()` and displays as ``. Nybl can retain, pass, compare, and invoke host-provided methods on the handle, but cannot inspect its Rust payload. See [Opaque host values and methods](https://nybl-lang.com/docs/embedding/index.html.md#opaque-host-values-and-methods). ## Integers and floats Integer literals (`42`, `-7`) produce `int` values; anything with a decimal point (`3.14`, `4.0`) produces `number`. There's no exponent-shaped literal — write the full decimal or build large values by multiplication. The two numeric types coexist: arithmetic widens to `number` on mixed operands, and `==` compares numerically across the split: ```nybl print(1 + 1) // 2 (int + int → int) print(1 + 1.0) // 2 (int + number → number, prints as whole) print(1 == 1.0) // true (cross-type numeric equality) ``` Ints cover the full signed 64-bit range, from `-9223372036854775808` through `9223372036854775807`. The minimum value is written with unary `-` directly before its magnitude (spaces or comments may separate the tokens); the positive magnitude `9223372036854775808` is out of range. Integer literals never silently become `number` values when they are too large. Use `.to_int()` to truncate a number to an integer (toward zero), and `.to_float()` to widen an int to a number: ```nybl print((3.7).to_int()) // 3 print((-2.7).to_int()) // -2 print((5).to_float()) // 5 (now a number internally) ``` Number literals need parens before a method call (otherwise `3.7.to_int()` looks like a decimal followed by a field). Variables don't: `let x = 3.7; print(x.to_int())`. ### Division `/` always produces a `number`, even for `int / int`: ```nybl print(7 / 2) // 3.5 print(6 / 2) // 3 (whole value — still a number) print((6 / 2).type()) // "number" ``` This sidesteps the classic "1 / 2 == 0" footgun that trips beginners in C / Rust / Java. When you *do* want an integer result (index math, bucketing, etc.), coerce the quotient back with `.to_int()`: ```nybl print((7 / 2).to_int()) // 3 print((-7 / 2).to_int()) // -3 print((7 / 2).to_int().type()) // "int" ``` `.to_int()` truncates toward zero. `/` raises a runtime error on division by zero. ## Strings Strings use double quotes only. Supported escape sequences: `\"`, `\\`, `\n`, `\t`, `\r`, `\{`, `\}`. ```nybl let greeting = "Hello, world!" let with_newline = "Line 1\nLine 2" ``` Strings are indexable and iterable, but immutable — you can read characters but not change them in place: ```nybl let s = "hello" print(s[0]) // "h" print(s[-1]) // "o" for ch in s { print(ch) // "h", "e", "l", "l", "o" } ``` ### String interpolation Use `{variable}` inside a string to insert a variable's value. Only variable names are allowed inside `{}` — not expressions: ```nybl let name = "Alice" let count = 5 print("Hello, {name}! You have {count} items.") ``` For computed values, store the result in a variable first: ```nybl let doubled = count * 2 print("Double: {doubled}") // Or use concatenation: print("Double: " + (count * 2).to_str()) ``` To include a literal `{` or `}` in a string, escape it with a backslash: ```nybl print("Use \{name\} for interpolation") // prints: Use {name} for interpolation ``` ### String concatenation `+` joins two strings, or a string and a number (the number is converted first): ```nybl print("Score: " + (42).to_str()) // "Score: 42" print("n=" + 7) // "n=7" (int auto-stringified) ``` ## Booleans `true` and `false`. Used in conditions and comparisons: ```nybl let found = true if found { print("Got it!") } ``` ## None `none` represents the absence of a value. Functions that don't explicitly return a value return `none`. It's also what you get from any operation designed to signal "no value here" (e.g. `first_or_none` helpers, optional return values): ```nybl fn first_or_none(arr) { if arr.len() == 0 { return none } return arr[0] } let r = first_or_none([]) print(r) // none ``` ### Checking for `none` Two equivalent ways: ```nybl if r.is_none() { print("empty") } if r == none { print("empty") } // same thing if r.is_some() { print("got one") } // inverse — every non-none value is "some" ``` `.is_none()` / `.is_some()` are [universal methods](https://nybl-lang.com/docs/reference/methods/index.html.md#methods-on-every-value) — they work on any value, not just optional-shaped ones. In a dynamically-typed language every variable can hold `none`, so the check is always available. Don't confuse falsy-ness with none-ness: `false`, `0`, `""`, `[]`, and `{}` are all falsy in `if` conditions but they're **not** `none`. `none.is_none()` is `true`; `false.is_none()` is `false`. ## User-defined types Nybl also lets you declare your own struct and enum types with methods — see [Structs & Enums](https://nybl-lang.com/docs/data/structs-and-enums/index.html.md). --- Source: https://nybl-lang.com/docs/basics/variables/ # Variables Variables store values that you can use and change throughout your program. ## Declaring variables Use `let` to create a new variable: ```nybl let x = 5 let name = "Alice" let found = true let items = [1, 2, 3] let config = {"width": 10, "height": 5} ``` `let` is required the first time — using an undeclared variable is an error. This catches typos early: ```nybl let count = 5 conut = 10 // Error: I don't know what 'conut' is — did you mean 'count'? ``` ## Constants Use `const` for values that won't change: ```nybl const PI = 3.14 const MAX_SIZE = 100 ``` Reassigning a constant is rejected at parse time — the compiler sees that the left-hand side is an all-caps identifier and refuses: ```nybl const MAX_SIZE = 100 MAX_SIZE = 200 // Error: can't reassign a constant ``` Index and field assignments respect the same rule. An assignment cannot reach through a constant array, dict, or struct binding to change part of its value: ```nybl const ORIGIN = [0, 0] ORIGIN[0] = 10 // Error: can't reassign `ORIGIN` — it's a constant const OPTIONS = {"retries": 3} OPTIONS["retries"] += 1 // Same error ``` Built-in mutating array methods are assignments through their receiver, so they cannot change a constant either: ```nybl const SCORES = [3, 1, 2] SCORES.sort() // Error: can't reassign `SCORES` — it's a constant ``` Read-only methods remain valid. User-defined methods are value calls, even when their names happen to match an array mutator. Constants must be **all-caps** (with digits / underscores allowed). `const Pi = 3.14` is rejected: the parser will suggest `const PI = 3.14` instead. ## Reassignment After declaration, reassign with just `=`: ```nybl let score = 0 score = 10 score += 5 // score is now 15 ``` Compound assignment operators: `+=`, `-=`, `*=`, `/=`, `%=`. ```nybl let x = 10 x += 3 // x = x + 3 → 13 x -= 1 // x = x - 1 → 12 x *= 2 // x = x * 2 → 24 ``` ## Name shapes are checked Nybl enforces case conventions at declaration sites so intent is visible at a glance: | Declaration | Required shape | |-------------|----------------| | `let x`, `fn foo(param)`, struct fields, match bindings, `for` variables, aliases | starts with lowercase or `_` | | `const FOO` | all caps (+ digits / `_`) | | `struct Point`, `enum Shape`, enum variants | starts with uppercase | Mis-shaped declarations parse-error with a suggestion: ```nybl let Count = 5 // Error: names bound by `let` start with a lowercase letter. Try `count`? const pi = 3.14 // Error: `const` names are SCREAMING_SNAKE_CASE. Try `PI`? struct point {} // Error: type names start with an uppercase letter. Try `Point`? ``` A leading underscore marks a name as "private by convention." It doesn't change the shape check — `_count` is still a lowercase-starting name — but glob `use` imports skip names that start with `_` (see [Modules](https://nybl-lang.com/docs/modules/index.html.md)). ## Block scoping Variables are block-scoped — a variable declared inside `{ }` is not visible outside: ```nybl let x = 1 if true { let y = 2 // y only exists inside this block print(y) // 2 } // print(y) // Error: I don't know what 'y' is ``` ## Shadowing You can re-declare a variable with `let` in an inner block. The inner variable "shadows" the outer one: ```nybl let x = 1 if true { let x = 2 // shadows outer x x = 3 // reassigns inner x print(x) // 3 } print(x) // 1 — outer x is unchanged ``` ## Copying and passing values Arrays, dicts, structs, and enum variants have **value semantics**. When you assign one, pass it to a function, or return it, the destination behaves like an independent copy. Changing either value never affects the other. Nybl implements these copies with copy-on-write storage: the operation itself is constant-time and shares the existing container safely. The backing storage is copied only if one of the values is later mutated. This is an implementation detail — programs observe the same independent values without paying for an eager deep copy. ### Assignment copies ```nybl let a = [1, 2, 3] let b = a // b is a separate copy b.push(4) print(a) // [1, 2, 3] — unchanged print(b) // [1, 2, 3, 4] ``` ### Function arguments are copies When you pass a value to a function, the function gets its own copy. Modifying it inside the function has no effect on the caller's variable: ```nybl fn try_to_modify(items) { items.push(99) print(items) // [1, 2, 3, 99] } let original = [1, 2, 3] try_to_modify(original) print(original) // [1, 2, 3] — unchanged ``` To get a modified value out of a function, `return` it: ```nybl fn add_item(items, val) { items.push(val) return items } let original = [1, 2, 3] original = add_item(original, 99) print(original) // [1, 2, 3, 99] ``` The same ordinary value behavior applies to numbers, strings, and bools. Closures and modules are shared handles, while iterators intentionally share their cursor: advancing one iterator handle advances its aliases too. When a function is intentionally designed to update a caller variable, declare and call an explicit [`ref` parameter](https://nybl-lang.com/docs/functions/reference-parameters/index.html.md). References are second-class transactional parameters, not general values or aliases: ordinary assignment and ordinary function arguments keep the value semantics described above. ## Dynamic typing Variables can hold any type. You can even change the type of a variable by reassigning it: ```nybl let val = 42 print(val.type()) // "int" val = "hello" print(val.type()) // "string" ``` This flexibility is useful but can be surprising — the error only surfaces when some later operation expects the original type. Case conventions help: a `count`-like variable holding a string usually means the wrong thing landed in it upstream. --- Source: https://nybl-lang.com/docs/control-flow/if-else/ # if / else The `if` statement lets your program make decisions based on conditions. ## Basic if ```nybl if count > 3 { print("That's a lot!") } ``` The condition does **not** need parentheses, but they're allowed: `if (x > 3) { ... }`. Braces are always required. ## if / else ```nybl if temperature > 30 { print("It's hot!") } else { print("Not too bad.") } ``` ## if / else if / else Chain multiple conditions with `else if`: ```nybl if score > 90 { print("Excellent!") } else if score > 70 { print("Good job!") } else if score > 50 { print("Not bad!") } else { print("Keep trying!") } ``` Only the first matching branch runs. If none match and there's an `else`, that branch runs. ## if as an expression `if/else` can produce a value when used in expression position (e.g., after `=`): ```nybl let label = if count > 3 { "lots" } else { "few" } print("You have {label} of items") ``` When used as an expression, both `if` and `else` branches are required. The last expression in each branch is the value. ```nybl let message = if x > 0 { "positive" } else { "non-positive" } print(message) ``` ## Common patterns ### Guard clause ```nybl fn process(value) { if value == none { return } print("Processing: " + value.to_str()) } ``` ### Classify a value ```nybl fn classify(n) { if n > 0 { return "positive" } else if n < 0 { return "negative" } else { return "zero" } } ``` ### Combine conditions with `&&` and `||` ```nybl if age >= 18 && has_ticket { print("Welcome in!") } if x < 0 || x > 100 { print("Out of range!") } ``` --- Source: https://nybl-lang.com/docs/control-flow/loops/ # Loops Loops let you repeat actions. Nybl has three kinds: `repeat`, `while`, and `for...in`. ## repeat The simplest loop — just "do this N times." No loop variable, no fuss: ```nybl repeat 4 { print("Hello!") } ``` The count can be any expression: ```nybl let times = 3 repeat times { print("Again!") } ``` `repeat` is perfect when you just need repetition without tracking a counter. ## while Loops as long as a condition is true: ```nybl let n = 1 while n <= 100 { n *= 2 } print(n) // 128 ``` A `while true` loop runs forever (until you `break` out of it or hit the step limit): ```nybl let total = 0 let i = 1 while true { total += i if total > 100 { break } i += 1 } print("Sum exceeded 100 at i=" + i.to_str()) ``` ### Counting example ```nybl // Count how many numbers under 50 are divisible by 7 let count = 0 let n = 1 while n < 50 { if n % 7 == 0 { count += 1 } n += 1 } print("Found " + count.to_str()) ``` ## for...in Iterates over ranges, arrays, or dictionary keys. ### Ranges ```nybl for i in range(5) { print(i.to_str()) // 0, 1, 2, 3, 4 } ``` With a start value: ```nybl for i in range(2, 8) { print(i.to_str()) // 2, 3, 4, 5, 6, 7 } ``` ### Arrays ```nybl let fruits = ["apple", "banana", "cherry"] for fruit in fruits { print(fruit) } ``` ### Dictionary keys ```nybl let scores = {"Alice": 95, "Bob": 87, "Charlie": 92} for name in scores { let s = scores[name].to_str() print(name + ": " + s) } ``` ### Iterators and user-defined containers `for x in v` works on anything that participates in the [iterator protocol](https://nybl-lang.com/docs/reference/methods/index.html.md#iter-methods-iter): arrays, strings, dicts, explicit iterators (`arr.iter()`), and user types that implement `.iter()`: ```nybl struct Bag { items } fn bag_of(arr) { return Bag { items: arr } } fn Bag.iter(self) { return self.items.iter() } let b = bag_of([10, 20, 30]) for v in b { print(v) } // 10 20 30 ``` That's the structural-typing story: no trait declaration, no ceremony — if `v.iter()` returns something iterable, `for x in v` just works. ### Strings You can iterate over the characters of a string: ```nybl let word = "hello" for ch in word { print(ch) // "h", "e", "l", "l", "o" } ``` ## Nesting loops Loops can be nested. This is useful for working with grids or combinations: ```nybl for row in range(3) { for col in range(4) { print("(" + row.to_str() + ", " + col.to_str() + ")") } } ``` --- Source: https://nybl-lang.com/docs/control-flow/break-continue/ # break & continue `break` and `continue` give you finer control over loops. ## break Exits the loop immediately. Execution continues after the loop: ```nybl let i = 0 while true { if i >= 10 { break } i += 1 } print("Stopped at " + i.to_str()) ``` ### Searching for something ```nybl let numbers = [4, 8, 15, 16, 23, 42] let found = false for n in numbers { if n > 20 { found = true break } } if found { print("Found a number greater than 20!") } else { print("No number greater than 20.") } ``` ## continue Skips the rest of the current iteration and jumps to the next one: ```nybl for i in range(10) { if i % 2 == 0 { continue } print(i.to_str()) // 1, 3, 5, 7, 9 } ``` ### Filter and process ```nybl let words = ["hello", "", "world", "", "nybl"] for word in words { if word == "" { continue } print(word.upper()) } ``` ## Which loops support break and continue? All three loop types — `while`, `for...in`, and `repeat` — support both `break` and `continue`. ```nybl repeat 10 { let n = rand(100) if n < 10 { break } print(n.to_str()) } ``` > **Note:** `break` and `continue` only affect the innermost loop. If you have nested loops and want to exit the outer one, use a variable flag or a function with `return`. --- Source: https://nybl-lang.com/docs/control-flow/match/ # Pattern Matching `match` is an expression: it evaluates a value and runs the first arm whose pattern matches, binding any captured names along the way. ```nybl let shape = Shape::Circle(3) let label = match shape { Shape::Circle(r) => "circle with radius {r}", Shape::Rectangle { w, h } => "rectangle {w}x{h}", Shape::Empty => "empty", } print(label) ``` Arms are tried top to bottom; the first matching arm wins. A match with no matching arm at runtime raises an error — the checker warns at parse time when an enum match misses variants (see [Exhaustiveness](#exhaustiveness)). ## Patterns ### Literal Matches a specific value: ```nybl match n { 0 => "zero", 1 => "one", 42 => "the answer", _ => "something else", } ``` Literal patterns use the same cross-type numeric rule as `==` — `1` matches both `Int(1)` and `Number(1.0)`. ### Wildcard `_` Matches anything, binds nothing. Typical "default" arm. ### Bindings A lowercase identifier captures the scrutinee value under that name for the arm's guard and body: ```nybl match request { request => handle(request), // `request` is bound here } ``` Inside nested patterns, bindings capture the piece they sit at: ```nybl match pair { [first, second] => first + second, } ``` ### Struct patterns Match a user struct with an exact type identity. Each field pattern runs against the corresponding field's value: ```nybl struct Point { x, y } let p = Point { x: 3, y: 4 } match p { Point { x: 0, y: 0 } => "origin", Point { x, y } => "at ({x}, {y})", } ``` Field patterns can be bindings (`x`), literals (`x: 0`), or any nested pattern. ### Enum variant patterns ```nybl match shape { Shape::Circle(r) => r * r, Shape::Rectangle { w, h } => w * h, Shape::Empty => 0, } ``` Unit, tuple, and struct variants all work. Like struct patterns, field / tuple entries can themselves be patterns. ### Namespaced patterns Types imported through an aliased `use` must be matched through the same namespace: ```nybl use paint as p match c { p.Color::Red => "stop", p.Color::Green => "go", _ => "?", } ``` The matcher compares the value's full `(module_path, type_name)` identity against what the alias resolves to — `p.Color::Red` only matches values that came from the `paint` module. ### Array patterns ```nybl match items { [] => "empty", [only] => "one: {only}", [a, b] => "two: {a} and {b}", [head, ..] => "starts with {head}", [head, ..tail] => "{head} then {tail}", } ``` - `[..]` — matches any array, captures nothing. - `[..name]` — captures the trailing elements as an array. - Patterns before the rest must all match; the rest is optional. ### Or-patterns `p1 | p2 | p3` — matches if *any* of the alternatives matches. Each alternative must bind the same set of names so the arm body has a consistent view: ```nybl match day { "Sat" | "Sun" => "weekend", _ => "weekday", } ``` ## Guards An arm can add a boolean guard after `if`. The arm only fires when the pattern matches **and** the guard is true: ```nybl match n { x if x < 0 => "negative", 0 => "zero", x if x < 10 => "small positive", _ => "big positive", } ``` Guards can see any names the pattern bound. ## As an expression `match` is an expression — every arm's body is an expression, and the whole thing evaluates to the winning arm's body: ```nybl let grade = match score { s if s >= 90 => "A", s if s >= 80 => "B", s if s >= 70 => "C", _ => "F", } ``` All arms should produce compatible types if you rely on the result — Nybl is dynamically typed, so heterogeneous arms aren't a parse error, but they usually signal a bug. ## Exhaustiveness Nybl's static checker warns when a `match` over an enum misses variants: ```nybl enum Color { Red, Green, Blue } let _ = match Color::Red { Color::Red => "r", Color::Green => "g", // warning: missing variant `Color::Blue` } ``` A wildcard or a bare-name catch-all (`_` or `other`) marks the match as exhaustive. Guards don't count toward coverage — a guarded arm covers only the guarded subset, so a partially-guarded match still needs a catch-all. The checker follows `use` statements when the embedder supplies a module resolver, so imported enums aren't opaque — missing-variant warnings fire on them too. Exhaustiveness analysis follows the same source-ordered lexical declaration model as execution. A type is not visible before its declaration, declarations inside one branch or callable do not leak into siblings, and shadowed or ambiguous type/module bindings suppress the advisory instead of risking a false warning. This check is a warning only; a `match` with no matching arm still raises a runtime error. --- Source: https://nybl-lang.com/docs/data/arrays/ # Arrays Arrays are ordered, mutable collections that can hold any mix of types. ## Creating arrays ```nybl let items = [1, 2, 3] let empty = [] let mixed = [1, "two", true, none] ``` ## Accessing elements Arrays are 0-indexed. Negative indices count from the end: ```nybl let items = [10, 20, 30] print(items[0]) // 10 print(items[2]) // 30 print(items[-1]) // 30 (last element) print(items[-2]) // 20 ``` Out-of-bounds access produces an error. ## Modifying elements ```nybl let items = [10, 20, 30] items[0] = 99 print(items) // [99, 20, 30] ``` ## Methods Mutating methods such as `push`, `pop`, `insert`, `remove`, `truncate`, `clear`, `reverse`, and `sort` write their updated array back to a mutable variable receiver using the same transactional copy-in/copy-out mechanism as a [`ref` parameter](https://nybl-lang.com/docs/functions/reference-parameters/index.html.md). Method arguments run before the receiver snapshot. Nested index and field receivers are write-back places too: `dict["items"].push(value)` and `holder.items.sort()` evaluate the receiver projections once and commit the updated leaf atomically through the root — if the method errors, the whole root remains unchanged. True temporary receivers remain valid. `[1, 2].push(3)` mutates the temporary, discards it, and returns `none`. | Method | Returns | Description | |--------|---------|-------------| | `arr.len()` | int | Number of elements | | `arr.push(val)` | none | Append to end | | `arr.pop()` | value | Remove and return last element | | `arr.has(val)` | bool | Whether the array contains the value | | `arr.index_of(val)` | int | Index of first occurrence, or `-1` | | `arr.insert(i, val)` | none | Insert at a signed index, shifting right. Negative indices count from the end; `len` appends | | `arr.remove(i)` | value | Remove at a signed index. Negative indices count from the end | | `arr.truncate(n)` | none | Shorten to at most `n` elements, dropping the tail. Negative lengths count from the end like a `slice` bound | | `arr.clear()` | none | Remove every element | | `arr.slice(start, end)` | array | Half-open sub-array. Negative bounds count from the end; out-of-range bounds clamp | | `arr.reverse()` | none | Reverse in place | | `arr.sort()` | none | Sort in place | | `arr.join(sep)` | string | Join elements into a string | Plus the universal `arr.type()`, `arr.to_str()`, `arr.inspect()`. ## Practical examples ### Building a list ```nybl let squares = [] for i in range(1, 6) { squares.push(i * i) } print(squares) // [1, 4, 9, 16, 25] ``` ### Filtering values ```nybl let numbers = [3, 7, 1, 9, 4, 6, 2, 8] let big = [] for n in numbers { if n > 5 { big.push(n) } } print(big) // [7, 9, 6, 8] ``` ### Checking membership ```nybl let allowed = ["admin", "editor", "viewer"] let role = "editor" if allowed.has(role) { print("Access granted") } else { print("Access denied") } ``` ### Sorting and joining ```nybl let scores = [42, 17, 85, 3] scores.sort() print(scores) // [3, 17, 42, 85] let names = ["Charlie", "Alice", "Bob"] names.sort() print(names.join(", ")) // "Alice, Bob, Charlie" ``` --- Source: https://nybl-lang.com/docs/data/strings/ # Strings Strings are immutable sequences of characters. All string methods return new strings — the original is never modified. ## Creating strings ```nybl let s = "hello world" let empty = "" let escaped = "Line 1\nLine 2" ``` Supported escape sequences: `\"`, `\\`, `\n`, `\t`, `\r`, `\{`, `\}`. Any other `\x` escape is a lexer error. ## Indexing ```nybl let s = "hello" print(s[0]) // "h" print(s[-1]) // "o" ``` Each index returns a single-character string (there's no separate character type). ## String interpolation Insert variable values with `{name}` inside a string: ```nybl let name = "Alice" let count = 5 print("Hello, {name}! You have {count} items.") ``` Only variable names are allowed inside `{}`. For expressions, use a temporary variable: ```nybl let total = (count * 2).to_str() print("Double: {total}") ``` Or use concatenation: ```nybl print("Double: " + (count * 2).to_str()) ``` To include a literal `{` or `}` in a string, escape it with `\{` and `\}`: ```nybl print("Use \{name\} for interpolation") // prints: Use {name} for interpolation ``` ## Concatenation Use `+` to join strings: ```nybl let full = "Hello" + ", " + "world!" print(full) // "Hello, world!" ``` Numbers must be converted with `.to_str()` first: ```nybl let msg = "Score: " + (42).to_str() ``` ## Methods | Method | Returns | Description | |--------|---------|-------------| | `s.len()` | int | Number of characters | | `s.contains(sub)` | bool | Whether the string contains `sub` | | `s.starts_with(prefix)` | bool | Whether it starts with `prefix` | | `s.ends_with(suffix)` | bool | Whether it ends with `suffix` | | `s.index_of(sub)` | int | Index of first occurrence, or `-1` | | `s.split(sep)` | array | Split into array of strings on `sep` | | `s.replace(old, new)` | string | Replace all occurrences | | `s.upper()` | string | Uppercase copy | | `s.lower()` | string | Lowercase copy | | `s.trim()` | string | Copy with leading/trailing whitespace removed | | `s.slice(start, end)` | string | Half-open substring by code-point index. Negative bounds count from the end; out-of-range bounds clamp | | `s.to_int()` | int | Parse. `"3.7".to_int()` → `3` (float-then-truncate). Raises on junk. | | `s.to_float()` | number | Parse. Raises on junk. | Plus the universal `s.type()`, `s.to_str()`, `s.inspect()`. ## Practical examples ### Parsing CSV data ```nybl let input = "Alice,95,A" let parts = input.split(",") print(parts[0]) // "Alice" print(parts[1]) // "95" ``` ### Checking prefixes ```nybl let filename = "report.csv" if filename.ends_with(".csv") { print("CSV file detected") } ``` ### Building a formatted string ```nybl let items = ["apple", "banana", "cherry"] let count = items.len().to_str() let list = items.join(", ") print("Found {count} items: {list}") ``` --- Source: https://nybl-lang.com/docs/data/dictionaries/ # Dictionaries Dictionaries (dicts) are key-value stores. Keys are always strings; values can be any type. ## Creating dictionaries ```nybl let person = {"name": "Alice", "age": 30, "active": true} let empty = {} ``` ## Accessing values Use bracket notation with a string key: ```nybl let name = person["name"] // "Alice" let age = person["age"] // 30 ``` Accessing a missing key returns `none` (no error): ```nybl let email = person["email"] print(email) // none if email.is_none() { print("no email on file") } ``` Two caveats: - A key whose value is explicitly `none` is **present** — `d.has(k)` returns `true` for it, even though `d[k]` and `d["absent_key"]` are both `none`. Use `d.has(k)` when you need to distinguish "unset" from "set to none". - If you want a read to *fail* on a missing key, check `d.has(key)` first and raise explicitly — `d[key]` itself always succeeds. ## Modifying values ```nybl person["age"] = 31 // update existing key person["email"] = "a@b.com" // add new entry let removed = person.remove("email") // delete a key, returning its value ``` `remove` returns `none` when the key is absent. Together with `clear`, it mutates its receiver with the same write-back rules as the [mutating array methods](https://nybl-lang.com/docs/data/arrays/index.html.md#methods): mutable places rooted in a `let` binding write back atomically (including nested places like `state["session"].remove("token")`), constants are rejected, and a genuine temporary is mutated and then discarded. Because they are mutating methods, they also work through `ref` parameters and `ref self` — `fn wipe(ref d) { d.clear() }` empties the caller's dict, where `d = {}` would only rebind the callee's local. ## Methods | Method | Returns | Description | |--------|---------|-------------| | `d.len()` | int | Number of entries | | `d.keys()` | array | Array of all keys | | `d.values()` | array | Array of all values | | `d.has(key)` | bool | Whether the key exists | | `d.remove(key)` | value | Remove `key`, returning its value, or `none` when absent. The key must be a string | | `d.clear()` | none | Remove every entry | Plus the universal `d.type()`, `d.to_str()`, `d.inspect()`. ## Practical examples ### Counting occurrences ```nybl let words = ["apple", "banana", "apple", "cherry", "banana", "apple"] let counts = {} for word in words { if counts.has(word) { counts[word] += 1 } else { counts[word] = 1 } } for key in counts { print(key + ": " + counts[key].to_str()) } ``` ### Storing structured data ```nybl let point = {"x": 10, "y": 20} let x = point["x"].to_str() let y = point["y"].to_str() print("Position: ({x}, {y})") ``` ### Iterating over entries ```nybl let config = {"width": 800, "height": 600, "title": "My App"} for key in config { let val = config[key].to_str() print(key + ": " + val) } ``` ### Checking for a key before using it ```nybl let settings = {"volume": 80} if settings.has("volume") { let v = settings["volume"].to_str() print("Volume is {v}") } else { print("Using default volume") } ``` --- Source: https://nybl-lang.com/docs/data/structs-and-enums/ # Structs & Enums Nybl supports user-defined **struct** and **enum** types, with methods attached via `fn Type.method(self, ...)`. They give you type names in error messages, structural pattern matching, and an identity-aware equality. ## Structs A `struct` is a named record — a set of fields in a declared order. Field names are the part that matters; types aren't declared. ```nybl struct Point { x, y } struct Player { name, hp, inventory } ``` Create a value with `TypeName { field: value, ... }`: ```nybl let p = Point { x: 3, y: 4 } print(p) // Point { x: 3, y: 4 } print(p.x) // 3 print(p.type()) // "struct" ``` Construction is **strict**: fields you provide must match the declaration exactly (no unknown fields, no duplicates, no missing ones). Extra fields or typos become parse or runtime errors with a "did you mean?" suggestion. ### Field access and assignment Read with `.field`, write with `.field = value` or any compound assignment: ```nybl let c = Counter { n: 10 } c.n += 5 // works c.n *= 2 // works print(c.n) // 30 ``` The field has to already exist; assigning to an undeclared field is an error. ### Passing structs Structs follow Nybl's copy-by-value rule: passing one to a function, returning it, or assigning it to another variable makes an independent copy. Mutating the copy leaves the original alone. ```nybl fn grow(p) { p.x += 10; return p } let a = Point { x: 1, y: 2 } let b = grow(a) print(a) // Point { x: 1, y: 2 } print(b) // Point { x: 11, y: 2 } ``` ## Enums An `enum` is a tagged union — one of several named variants, each with an optional payload: ```nybl enum Shape { Circle(r), Rectangle { w, h }, Empty, } ``` Variants come in three shapes: | Shape | Declaration | Construction | |-------|-------------|--------------| | Unit | `Empty` | `Shape::Empty` | | Tuple | `Circle(r)` | `Shape::Circle(5)` | | Struct | `Rectangle { w, h }` | `Shape::Rectangle { w: 4, h: 3 }` | ```nybl let a = Shape::Circle(5) let b = Shape::Rectangle { w: 4, h: 3 } let c = Shape::Empty print(a.type()) // "enum" ``` Variants with a struct payload expose their fields via `.field` just like structs: ```nybl let r = Shape::Rectangle { w: 4, h: 3 } print(r.w * r.h) // 12 ``` Short-name variants like `enum Dir { N, E, S, W }` are accepted — the case rule is "starts with an uppercase letter", not "must contain a lowercase". ## Methods Attach a method to a type with `fn Type.method(self, ...) { ... }`. The receiver arrives as the first parameter (called `self` by convention; any name works). ```nybl struct Point { x, y } fn Point.sum(self) { return self.x + self.y } fn Point.moved(self, dx, dy) { return Point { x: self.x + dx, y: self.y + dy } } let p = Point { x: 3, y: 4 } print(p.sum()) // 7 print(p.moved(1, 1)) // Point { x: 4, y: 5 } ``` For enums, methods dispatch on the enum type — not per-variant: ```nybl enum Shape { Circle(r), Rectangle { w, h } } fn Shape.area(self) { return match self { Shape::Circle(r) => 3.14159 * r * r, Shape::Rectangle { w, h } => w * h, } } print(Shape::Circle(3).area()) // 28.27431 print(Shape::Rectangle { w: 4, h: 3 }.area()) // 12 ``` A user-declared method with the same name as a builtin (`len`, `keys`, etc.) **wins** over the builtin for receivers of that type — the precedence matches the walker, VM, and AOT. ### Methods must live in the type's own module Methods can only be declared on structs and enums you own — that is, types declared in the *same module* as the method. Concretely: - You **cannot** extend a type imported from another module with new methods. If `paint` declares `struct Color { ... }`, then `fn Color.brighten(self)` must also live in `paint`, not in a consumer. - You **cannot** add methods to the built-in types (`int`, `number`, `string`, `bool`, `array`, `dict`, `fn`, `module`, `iter`) or to the engine-registered types `Result`, `RuntimeError`, `Iter`. Declarations that violate this rule parse fine but never dispatch — the method registers against `(your_module, TypeName)`, while values of that type carry their original home module in their identity, so lookups miss. If you catch yourself wanting to "just add a helper to `Array`" or "extend `Result` with a domain combinator," write a free function that takes the value as an argument instead: ```nybl // Not this (declared in some consumer module): // fn Result.tag(self, label) { ... } // ghost method — never fires // Do this: fn tag(r, label) { return match r { Ok(v) => "{label}: ok {v}", Err(e) => "{label}: err {e}", } } ``` This is the same discipline Go enforces: a type's behaviour lives where the type is declared. It keeps dispatch ownership coherent at the cost of the extensions you'd get in Swift / Kotlin / Ruby. Within the owning module, execution order still determines when a method becomes available. ### Value and mutable receivers An ordinary `self` receiver is a read-only value snapshot. Use it for queries and functional transformations that return a new value: ```nybl fn Point.shift(self, dx, dy) { return Point { x: self.x + dx, y: self.y + dy } } let p = Point { x: 0, y: 0 } p = p.shift(3, 4) // p is now Point { x: 3, y: 4 } ``` This plays well with fluent chains: ```nybl let final = Point { x: 0, y: 0 } .shift(1, 0) .shift(0, 2) .shift(5, 5) ``` To update the caller's binding in place, declare the first parameter as `ref self`: ```nybl fn Point.shift_in_place(ref self, dx, dy) { self.x += dx self.y += dy } let p = Point { x: 0, y: 0 } p.shift_in_place(3, 4) print(p) // Point { x: 3, y: 4 } ``` The call site does not write another `ref`: method syntax supplies the receiver implicitly. A mutable receiver may be a field/index place rooted in a `let` binding, and it uses the same transactional copy-in/copy-out rules as an explicit [`ref` parameter](https://nybl-lang.com/docs/functions/reference-parameters/index.html.md). A normal return commits it; an error rolls it back. Assigning to `self`, `self.field`, or `self[index]` in a value-receiver method is a parse error with a hint to use `ref self`. This prevents a method from silently changing a discarded copy. ## Equality Two struct or enum values are equal when their **full type identity** — the module they were declared in plus the type name — *and* every payload matches structurally: ```nybl let p = Point { x: 1, y: 2 } let q = Point { x: 1, y: 2 } print(p == q) // true (structural) let r = Point { x: 1, y: 3 } print(p == r) // false (field differs) ``` Two types with the same name declared in different modules are **distinct** — see [Modules](https://nybl-lang.com/docs/modules/index.html.md#type-identity). ## Redeclaring the same shape is fine Declaring the exact same struct or enum twice inside one module is a no-op — matches the "idempotent re-import" rule `use` already follows. Declaring two different shapes with the same name in the same module is a hard error. ## Declarations execute in source order Struct, enum, and method declarations take effect when execution reaches their statement; they are not hoisted. A declaration in a branch that is not taken does not run, and a declaration inside a function does not run until that function is called: ```nybl struct Box { value } let box = Box { value: 4 } if true { fn Box.read(self) { return self.value } } print(box.read()) ``` Nested type names follow ordinary lexical scope. Runtime validation also happens when the declaration executes, so an invalid declaration in dead code does not fail a program that never reaches it. Methods are installed in their type's declaring module when their declaration executes. A method declared by a called function remains installed after that function returns, and the last executed declaration for the same method name determines its body and arity. This is intentionally dynamic; use top-level declarations for the least surprising API. --- Source: https://nybl-lang.com/docs/functions/defining-functions/ # Defining Functions Functions let you name a sequence of actions and reuse it. Nybl has first-class functions — you can store them in variables, pass them to other functions, return them from other functions, and stash them in arrays. ## Declaring a function ```nybl fn greet() { print("Hello!") } greet() // "Hello!" greet() // "Hello!" ``` ### Public host entry points At the direct root of a program, `pub fn` marks a function as callable through a stateful embedder's `NyblInstance` ABI: ```nybl let visits = 0 pub fn visit(name) { visits += 1 return "hello {name}; visit {visits}" } ``` `pub` does not change how Nybl code resolves or calls the function. It is metadata for the host API, and is rejected on nested functions, methods, and function expressions. Only declarations that execute during instance loading are exposed; see [Stateful instances](https://nybl-lang.com/docs/embedding/instances/index.html.md#declaring-host-entry-points) for redeclaration and entry-order rules. The host API passes owned values, not Nybl variable bindings. A public function with a `ref` parameter can still be called normally from Nybl source, but `NyblInstance::call` rejects it because the host cannot supply a referenceable target. The same value-only rule applies when the host invokes a returned function through `call_value`. ### Shadowing engine builtins An executed lexical function declaration shadows an engine builtin with the same name, just like a `let`-bound callable does: ```nybl fn rand(max) { return 0 } print(rand(10)) // 0, from the user function ``` This applies to `range`, `rand`, `print`, `try_call`, and `panic`. Declarations take effect when execution reaches them, so an earlier call still resolves to the builtin. A host deny list therefore allows a shadowing user function but still rejects any source-ordered call that actually reaches the disabled builtin. ## Parameters Functions can take parameters — values you pass in when calling: ```nybl fn repeat_string(text, times) { let result = "" repeat times { result += text } return result } print(repeat_string("ha", 3)) // "hahaha" ``` Parameters are positional. There are no default values or type annotations. ### Rest parameters A final `..name` parameter collects any remaining positional arguments into an array. It accepts zero or more values and works on named functions, function expressions, and methods: ```nybl fn collect(first, ..rest) { return [first, rest] } print(collect(1)) // [1, []] print(collect(1, 2, 3)) // [1, [2, 3]] ``` The rest parameter must be last and cannot be `ref`. Fixed `ref` parameters may precede it, but every collected argument is value-only. Public instance entry points with a rest parameter expose their fixed parameter count as the minimum accepted arity. ### Reference parameters Use a `ref` parameter when a function should replace one of the caller's variables. The marker is required in both the declaration and the call, so the mutation is visible at each site: ```nybl fn grow(ref items, count) { repeat count { items.push(0) } } let values = [] grow(ref values, 3) print(values) // [0, 0, 0] ``` Refs use transactional copy-in/copy-out rather than observable aliases. Read [Reference Parameters](https://nybl-lang.com/docs/functions/reference-parameters/index.html.md) for valid targets, atomic commit and rollback, forwarding, evaluation order, closure and method rules, diagnostics, and the value-only Rust host boundary. ## Return values Use `return` to send a value back from the function: ```nybl fn double(x) { return x * 2 } let result = double(5) print(result) // 10 ``` `return` with no value (or reaching the end of the function) returns `none`: ```nybl fn do_something() { print("Working...") // no return — returns none } let result = do_something() print(result) // none ``` ## Early return `return` exits the function immediately, even from inside loops or conditionals: ```nybl fn find_first_big(numbers, threshold) { for n in numbers { if n > threshold { return n } } return none } let result = find_first_big([3, 7, 1, 15, 4], 10) print(result) // 15 ``` ## Calling functions Parentheses are always required, even with no arguments: ```nybl greet() // correct // greet // error — 'greet' is a function, call it with greet() ``` ## Practical example: sum of squares ```nybl fn sum_of_squares(n) { let total = 0 for i in range(1, n + 1) { total += i * i } return total } let result = sum_of_squares(5) print("Sum of squares: {result}") // Sum of squares: 55 ``` ## Recursion Functions can call themselves. Nybl caps recursion depth to prevent runaway stacks (also bounded by the step limit): ```nybl fn factorial(n) { if n <= 1 { return 1 } return n * factorial(n - 1) } print(factorial(5)) // 120 ``` ## First-class functions A named `fn` is a value just like anything else — you can assign it to a variable, pass it as an argument, return it from another function, or store it in a collection: ```nybl fn double(x) { return x * 2 } let f = double print(f(7)) // 14 fn apply(f, x) { return f(x) } print(apply(double, 21)) // 42 ``` ### Function expressions (lambdas) `fn(...) { ... }` — without a name — is an expression that produces a function value. Use it when you want a one-off function inline: ```nybl let square = fn(x) { return x * x } print(square(6)) // 36 let mul = fn(a, b) { return a * b } print([mul(2, 3), mul(4, 5)]) // [6, 20] ``` ### Closures Function expressions capture lexical locals from the enclosing scope. Those local captures are a **snapshot** taken when the closure is built — mutating a captured local afterwards doesn't change what the closure sees: ```nybl let n = 5 let add_n = fn(x) { return x + n } n = 100 print(add_n(3)) // 8, not 103 ``` The classic "factory returning a specialised function" pattern works: ```nybl fn make_adder(n) { return fn(x) { return x + n } } let add5 = make_adder(5) let add10 = make_adder(10) print(add5(3)) // 8 print(add10(3)) // 13 ``` A function expression created directly at the program root also snapshots the root bindings it uses. There is one important stateful-embedding distinction: a callback created while a named function is running keeps snapshot-captured locals, but names resolved from that function's defining module remain live. That lets a callback returned from `NyblInstance::call` observe later updates to the same instance's module globals: ```nybl let count = 0 pub fn make_counter(step) { return fn() { count += step return count } } ``` Here `step` is the callback's captured local and `count` is live instance state. The callback must be invoked through the same instance that created it. ### Recursion in lambdas An anonymous `fn(...)` can't see itself by name. If a lambda needs to recurse, assign it to a named `fn` instead — named fns are visible inside their own body: ```nybl fn fib(n) { if n < 2 { return n } return fib(n - 1) + fib(n - 2) } print(fib(10)) // 55 ``` --- Source: https://nybl-lang.com/docs/functions/reference-parameters/ # Reference Parameters Nybl normally passes values independently: changing a function parameter does not change the caller's variable. Use a `ref` parameter when the function is specifically designed to replace a mutable variable in its caller. `ref` is required in both the function declaration and the call: ```nybl fn grow(ref items, count) { repeat count { items.push(0) } } let values = [] grow(ref values, 3) print(values) // [0, 0, 0] ``` The two markers make mutation part of the function's visible API. Omitting `ref` for a reference parameter, or adding it to an ordinary value parameter, is an error with a hint that identifies the argument. ## Copy-in/copy-out, not aliasing A reference parameter is a staged local value, not an observable alias into the caller's scope: 1. Nybl snapshots the caller place when the call begins. 2. The function reads and changes its staged parameter. 3. A normal return writes every staged reference parameter back to its caller variable. 4. An error discards every staged change. This is also called **copy-in/copy-out** or **call by value-result**. Nybl's copy-on-write containers keep the initial snapshot cheap while preserving ordinary value semantics. Reaching the end of the function and an explicit `return` are both normal returns. A language-level `Result::Err` is also an ordinary returned value, so it commits: ```nybl fn validate(ref attempts) { attempts += 1 return Err("not accepted") } let attempts = 0 let result = validate(ref attempts) print(result.is_err(), attempts) // true 1 ``` By contrast, `panic`, another runtime error, or a fatal step/memory/call-depth limit failure rolls the call back. Rollback happens before `try_call` catches a non-fatal error: ```nybl fn update_then_fail(ref items) { items.push(2) panic("not committed") } let items = [1] fn attempt() { update_then_fail(ref items) } let result = try_call(attempt) print(result.is_err(), items) // true [1] ``` ## Valid reference targets An explicit `ref` argument must be a mutable place rooted in a `let` binding. Fields and indexes can be chained to any depth: ```nybl let value = 1 set(ref value) // valid set(ref (value)) // also valid; grouping is transparent let rows = [[1, 2]] set(ref rows[0][1]) // valid nested place let record = Record { items: [1] } set(ref record.items[0]) ``` These are not valid targets: ```nybl const FIXED = 1 // set(ref FIXED) // constant // set(ref 1) // literal or other expression // set(ref make_record().field) // temporary root ``` A variable captured by a closure cannot be a reference target. Pass it through an explicit reference parameter instead. A reference parameter itself also cannot be captured by a nested function or lambda. The same root binding cannot fill two reference positions in one call, even when the projections differ: ```nybl fn pair(ref left, ref right) {} let value = 1 // pair(ref value, ref value) // error let values = [1, 2] // pair(ref values[0], ref values[1]) // same root, also an error ``` Use distinct variables. This fence prevents observable aliasing and lets all targets commit as one transaction. ## Multiple targets and forwarding All reference parameters in one call commit together. If the function fails after changing any of them, none are written back: ```nybl fn replace(ref left, ref right, should_fail) { left = 10 right = 20 if should_fail { panic("roll back both") } } ``` A function can forward its reference parameter into another reference call: ```nybl fn inner(ref value) { value += 1 } fn outer(ref value) { inner(ref value) value *= 2 } let score = 3 outer(ref score) print(score) // 8 ``` The inner call commits into `outer`'s staged local. The original `score` changes only when `outer` returns normally; a later failure in `outer` still rolls the whole operation back. ## Evaluation and preflight order Calls use a deterministic order: 1. evaluate the callee expression once; 2. check that it is callable and verify arity, argument modes, and reference target shapes that can be rejected immediately; 3. evaluate ordinary argument expressions from left to right; 4. evaluate index expressions and snapshot reference places in parameter order; 5. execute the function. Mode or target-shape errors therefore prevent ordinary argument side effects. Changes made by valid ordinary arguments are visible in the later reference snapshots. This contract also applies when a function is called through an alias, closure, module export, or other first-class function value. Parameter modes travel with the callable. ## Methods and mutating receivers User-defined methods can use either a read-only value receiver or a mutable reference receiver. An ordinary `self` is a value snapshot. Assigning to `self`, one of its fields, or one of its indexes is a parse error instead of a silent mutation of a discarded copy. Declare `ref self` when a method should update its caller: ```nybl struct Counter { amount } fn Counter.add(ref self, amount) { self.amount += amount } let counter = Counter { amount: 3 } counter.add(4) print(counter.amount) // 7 ``` Method-call syntax supplies the receiver reference implicitly, so the call is `counter.add(4)`, not `ref counter.add(4)`. The receiver may be any mutable field/index place rooted in a `let` binding. Constants, temporary roots, and captured bindings are rejected. A method may also declare explicit reference parameters after the receiver: ```nybl fn Counter.transfer(ref self, ref total) { total += self.amount self.amount = 0 } let counter = Counter { amount: 3 } let total = 4 counter.transfer(ref total) print(counter.amount, total) // 0 7 ``` Receiver index expressions are evaluated once before call preflight and ordinary arguments. The mutable receiver and explicit targets are snapshotted in parameter order after ordinary arguments run. They must identify distinct roots and commit together on a normal return; any runtime or resource error rolls all of them back. Built-in mutating array methods use the same transaction model implicitly for a mutable place receiver. You do not write `ref` before the receiver: ```nybl let items = [1] items.push(2) let groups = [[1]] groups[0].push(2) // writes back through the index ``` Method arguments run before Nybl snapshots the receiver. A true temporary may be mutated, but its mutation is discarded after the method returns: `([1, 2]).pop()` returns `2`, while `[1, 2].push(3)` returns `none`. Nested receiver updates rebuild and commit the root atomically; an error leaves the complete root unchanged. See [Methods → Mutating receivers](https://nybl-lang.com/docs/reference/methods/index.html.md#mutating-receivers) for the built-in behavior. ## Built-ins, host functions, and instances Explicit reference parameters belong to user-defined Nybl functions. Built-in functions, built-in method arguments, and functions supplied by `NyblHost` accept value arguments only. Rust's `NyblInstance::call` and `call_value` APIs also accept owned `Value` arguments rather than Nybl binding locations. They reject a ref-bearing entry or callback before its body executes. Keep host-facing `pub fn` entries value-only and call reference-based helpers from inside Nybl: ```nybl fn increment(ref value) { value += 1 } let count = 0 pub fn next() { increment(ref count) return count } ``` The tree-walker, bytecode VM, and AOT-generated runtime implement the same reference semantics and diagnostics. --- Source: https://nybl-lang.com/docs/modules/ # Modules A Nybl program can be split across multiple files — or in-memory source strings, asset bundles, anywhere the embedding host can return Nybl source. The `use` statement pulls another module's public surface into the current scope. ## The four forms of `use` ```nybl use path // glob: everything public use path.{a, b, Type} // selective: just the listed items use path as m // aliased: binds `m` as a Value::Module use path.{a, b} as m // aliased + selective ``` Paths are dot-joined identifiers: `std.math`, `game.entity.player`. How the host resolves a path is up to the embedder — `nybl-sys`'s `StandardHost::with_module_root` maps `foo.bar` to `/foo/bar.nybl`, in-memory hosts can look up a string table, a web host can fetch a URL. See [Embedding](https://nybl-lang.com/docs/embedding/index.html.md#resolve_module-custom-use-resolution). ## Explicit public surfaces A module can declare an export allow-list with `pub { ... }`: ```nybl let visible = 1 let implementation_detail = 2 let _explicitly_public = 3 struct Widget { value } fn helper() { return implementation_detail } pub { visible, _explicitly_public, Widget, helper } ``` When at least one list appears, only listed values, functions, types, and re-exports can cross the module boundary. Multiple lists are unioned, and `pub {}` exports nothing. Listed underscore-prefixed names are public even to a glob import. Private implementation bindings remain available to exported functions inside their defining module. Without a `pub { ... }` list, modules keep the legacy convention described below: glob imports omit `_` names, while aliases and selective imports may reach them. This compatibility rule lets existing modules adopt explicit surfaces incrementally. `pub { ... }` is separate from root-level `pub fn`: the former controls Nybl module imports, while the latter declares a host-callable [`NyblInstance`](https://nybl-lang.com/docs/embedding/instances/index.html.md) entry point. A surface statement in the directly executed root has no effect. ## Glob `use` Brings every public export of a module into the current scope as a bare name: ```nybl use std.math print(PI) // constant from std.math print(factorial(5)) // fn from std.math → 120 ``` Names that start with `_` are considered **private by convention** and glob imports skip them: ```nybl // In module `foo`: fn _helper() { return 42 } fn public() { return _helper() } // Elsewhere: use foo print(public()) // 42 // print(_helper()) // error: `_helper` not in scope ``` Glob is idempotent at the injection site — running `use foo` twice in the same scope is a no-op (matches Python's `import foo; import foo`). When two glob imports would introduce the same name, the first wins and the second emits a runtime warning — explicit selective imports are the way to disambiguate. ## Selective `use` Pick exactly which names you want: ```nybl use std.math.{PI, factorial} print(PI) print(factorial(4)) // print(clamp(1, 0, 10)) // error — not imported ``` In a legacy module without an explicit public surface, selective imports can reach private names explicitly: ```nybl use foo.{_helper} print(_helper()) // ok — explicit opt-in ``` If a listed name doesn't exist in the target module, you get a clear error pointing at the `use` site. ## Aliased `use` Binds the whole module as a single value under the alias: ```nybl use std.math as m print(m.PI) print(m.factorial(5)) ``` `m` is a `Value::Module` — `m.type()` is `"module"`. You access its exports via the `.` operator. Methods on aliased modules (`m.helper(...)`) work the same way they would on a bare imported fn. Combine with selective to shrink the alias's surface: ```nybl use std.math.{PI, factorial} as m print(m.PI) print(m.factorial(5)) // print(m.clamp(1, 0, 10)) // error — `clamp` wasn't imported ``` ## Namespaced types User-defined `struct` and `enum` types can be constructed and pattern-matched through the alias: ```nybl // In `paint.nybl`: enum Color { Red, Green, Blue } struct Point { x, y } // In main: use paint as p let c = p.Color::Red let origin = p.Point { x: 0, y: 0 } print(match c { p.Color::Red => "stop", p.Color::Green => "go", p.Color::Blue => "cool", }) ``` The namespace is required — bare `Color::Red` inside the main file wouldn't find the type unless you also imported `paint.{Color}` by bare name. ## Type identity Types carry their declaring module as part of their identity. Two modules can declare a type with the same name; values from them are **distinct types** — equality is always `false` across the module boundary, and patterns only match values from the module the pattern named. ```nybl // paint.nybl: enum Color { Red, Blue } // other.nybl: enum Color { Red, Green, Yellow } use paint as p use other as o let a = p.Color::Red let b = o.Color::Red print(a == b) // false — different `Color` types print(a == a) // true ``` A pattern over an aliased module's type only fires for values from that module: ```nybl fn label(c) { return match c { p.Color::Red => "paint-red", o.Color::Red => "other-red", _ => "something else", } } print(label(p.Color::Red)) // "paint-red" print(label(o.Color::Red)) // "other-red" ``` This is Nybl's answer to the "same-named type, different shape, in different modules" problem. No renames required. ## Re-exports are transitive A module re-exports the bindings that its own `use` statements introduce. If `a` does `use b` and `b` declares `fn foo()`, then `use a` in the top-level program makes `foo` visible too. Selective imports re-export only their selected names, including a private name selected explicitly. Aliased imports re-export only the alias: if `a` does `use b as dep`, an importer of `a` can reach `dep`, but does not receive `b`'s exports as bare names. The same shape rules apply to types, and glob privacy filtering is applied at every module boundary. ## Builtin types `Result` and `RuntimeError` are engine built-ins. They're always in scope — you don't need any `use` to write `Result::Ok(v)` or to match on `RuntimeError { message, line }`. The combinators (`unwrap`, `map`, `and_then`, …) live as [methods on the `Result` type](https://nybl-lang.com/docs/reference/methods/index.html.md#result-methods-result), also always available. See [Error Handling](https://nybl-lang.com/docs/errors/index.html.md). ## Cycles Circular imports (`a` uses `b` which uses `a`) are detected at load time and raise a clear error naming the cycle path. Restructure the code so the cycle breaks — usually by pulling shared definitions into a third module that neither circular node depends on. ## Inside a function body Aliased modules and bare-imported types remain visible inside function bodies declared in the same module: ```nybl use paint as p fn describe(c) { return match c { p.Color::Red => "red", p.Color::Blue => "blue", _ => "other", } } ``` The `p` alias doesn't need to be a parameter — module-level aliases persist across function call boundaries so patterns inside fn bodies can resolve them. --- Source: https://nybl-lang.com/docs/errors/ # Error Handling Nybl uses a `Result`-shaped value model for recoverable errors, with two language features that make it ergonomic: - `try` — unwrap an `Ok(v)` or propagate an `Err(e)` up to the enclosing function. - `try_call(f)` — run a zero-arg callable, catch any runtime error, and return the outcome as a `Result`. Both `Result` and `RuntimeError` are **engine built-ins** — always in scope, no import required. The combinators (`is_ok`, `unwrap`, `map`, `and_then`, …) are [methods on the `Result` type](https://nybl-lang.com/docs/reference/methods/index.html.md#result-methods-result), also always available. ## The `Result` type ``` enum Result { Ok(value), Err(error), } ``` By convention, `Ok(v)` carries the successful value and `Err(e)` carries whatever describes the failure — a string, a struct, anything. Values from fallible operations are the typical shape: ```nybl fn parse_positive(s) { let n = s.to_int() if n <= 0 { return Err("must be positive, got {n}") } return Ok(n) } print(parse_positive("42")) // Result::Ok(42) print(parse_positive("-3")) // Result::Err("must be positive, got -3") ``` ### `Ok` / `Err` shorthand `Ok(x)` and `Err(e)` are parser-level sugar for `Result::Ok(x)` and `Result::Err(e)`. The rewrite applies in both expression and pattern position, so you can write: ```nybl fn classify(n) { if n > 0 { return Ok(n) } return Err("non-positive") } print(match classify(5) { Ok(v) => "ok: {v}", Err(e) => "err: {e}", }) // ok: 5 ``` Nybl's case rules already reserve uppercase identifiers for types and variants, so `Ok` and `Err` can't collide with a user fn or variable. The long form (`Result::Ok(v)`, `Result::Err(e)`) still works — pick whichever reads better. If a different enum happens to have its own `Ok` / `Err` variants, use the qualified `MyEnum::Ok(x)` form for those. The bare sugar always means `Result::Ok` / `Result::Err`. ## The `try` operator `try expr` evaluates `expr` and: - If the result is `Result::Ok(v)`, unwraps it to `v`. - If the result is `Result::Err(e)`, immediately returns `e` from the enclosing function as-is (wrapped in the same `Err` variant the caller will see). - If the result is anything else (not `Result`-shaped), raises a runtime error. ```nybl fn pipeline(s) { let n = try parse_positive(s) // Err propagates; Ok unwraps to `n` let doubled = try double_checked(n) return Ok(doubled) } print(pipeline("21")) // Result::Ok(42) print(pipeline("-3")) // Result::Err("must be positive, got -3") ``` Because `try` propagates by returning from the *enclosing function*, it only works inside fn bodies. A top-level `try` that hits an `Err` raises a runtime error — wrap the call site in a fn. ### Unit-Ok `try Result::Ok` with no payload (or `try Result::Ok` where `Ok` is a unit variant) yields `none`. Mostly relevant for APIs where the success case carries no meaningful value. ## `try_call(f)` Catch runtime errors from a zero-arg callable. Returns `Result::Ok(value)` on success or `Result::Err(RuntimeError { message, line })` on a caught error. ```nybl let r = try_call(fn() { return 1 / 0 }) print(match r { Result::Ok(v) => "got {v}", Result::Err(RuntimeError { message, line }) => "failed at line {line}: {message}", }) // failed at line 1: Division by zero ``` `try_call` is Nybl's answer to exception-like error handling without exceptions. It *only* catches **non-fatal** errors. Fatal conditions — step-budget exhaustion, memory-limit violation, host `on_tick` returning `NyblError::fatal` — are **not** caught. That keeps the sandbox invariant intact: a runaway loop can't wrap itself in `try_call` and keep going. ### `RuntimeError` — the caught error shape ``` struct RuntimeError { message, // string line, // int — 1-indexed source line of the failing expression } ``` You can construct one explicitly (it's a regular struct), but most of the time you'll see them as the payload inside `Result::Err(...)` returned from `try_call`. ## Combinators — methods on `Result` Every `Result` value has a small set of always-available methods. No import needed — `Result` is a built-in type and its combinators are engine-level methods. ```nybl print(Ok(1).is_ok()) // true print(Err("oops").is_err()) // true // unwrap_or — default on Err print(Ok(10).unwrap_or(0)) // 10 print(Err("fail").unwrap_or(0)) // 0 // map — transform the Ok payload, pass Err through print(Ok(5).map(fn(n) { return n * n })) // Result::Ok(25) print(Err("x").map(fn(n) { return n * n })) // Result::Err("x") // and_then — monadic bind (for chaining fallible steps) fn halve(x) { if x % 2 == 0 { return Ok((x / 2).to_int()) } return Err("odd") } print(Ok(8).and_then(halve).and_then(halve)) // Result::Ok(2) print(Ok(7).and_then(halve)) // Result::Err("odd") ``` Available: `is_ok`, `is_err`, `unwrap`, `expect`, `unwrap_or`, `map`, `map_err`, `and_then`. See [Methods → Result](https://nybl-lang.com/docs/reference/methods/index.html.md#result-methods-result) for the full reference. `unwrap()` and `expect(msg)` raise a runtime error on `Err` — use sparingly, and prefer `try` or pattern matching in production code. ## When to use which | Situation | Use | |-----------|-----| | Writing a fallible function | Return `Result::Ok(v)` / `Result::Err(e)` | | Chaining several fallible calls | `try` inside a fn, or `r.and_then(f)` | | Running user-supplied code with a safety net | `try_call(fn() { ... })` | | Handling every `Err` case explicitly | `match` | | You know it's `Ok` and want the value | `r.unwrap()` / `r.expect("...")` (sparingly) | | Supplying a default on `Err` | `r.unwrap_or(default)` | ## Fatal vs non-fatal - **Non-fatal** (catchable by `try_call`): division by zero, "variable not found", type mismatches, host-raised errors via `NyblError::runtime`, wrong arg count, missing field, etc. - **Fatal** (not catchable): step-budget exceeded, memory-limit exceeded, fn-call-depth exceeded, host-raised `NyblError::fatal`. A script can observe whether an error was fatal by inspecting whether `try_call` caught it — fatal errors propagate past `try_call` to the host. --- Source: https://nybl-lang.com/docs/repl/ # REPL The `nybl` CLI ships an interactive REPL that carries state across submissions, echoes bare-expression results, supports multi-line input, and persists history across sessions. ``` $ nybl repl > let x = 5 > let f = fn(n) { return n * x } > f(7) 35 > :quit ``` ## Starting the REPL ``` nybl # default subcommand is `repl` nybl repl ``` Ctrl-D on an empty prompt exits. Ctrl-C clears the current line without touching the session. ## What persists across submissions Everything that shows up in the program's scope: - `let` / `const` bindings. - `fn` declarations (named and anonymous-then-let-bound). - `struct` / `enum` / method declarations. - `use` imports and module aliases. - The `rand()` seed — same starting seed, same sequence (helpful for reproducing bugs). Resource limits (`NyblLimits::standard()` by default) reset per submission, so the step budget doesn't accumulate across lines. ## Bare expressions echo When the last statement in a submission is a bare expression, the REPL prints its value: ``` > 1 + 2 3 > let x = 5 // `let` has no value — no echo > x 5 ``` `print(...)` returns `none`; the REPL suppresses `none` echoes so `print(42)` only shows `42` once (from the host), not "42" followed by "none". ## Multi-line input When a line parses with "end of code" — unclosed brace, trailing `+`, unfinished `match` — the REPL keeps the buffer open and prompts for more. Paste a multi-line block and it runs as one submission: ``` > fn greet(name) { ... return "hi " + name ... } > greet("Nybl") hi Nybl ``` A different parse error (typo, unexpected token) submits immediately so you can see the error rather than hunting through a stale buffer. ## Tab completion Hit Tab on an identifier prefix to see matches from: - Nybl keywords (`let`, `fn`, `match`, `use`, …) - Built-in functions (`print`, `range`, `rand`, `try_call`, `panic`) - Names currently in the session (`let my_var = …` shows up after declaration) - Identifiers the REPL has seen you type in previous submissions (covers fn parameters, struct field names) ## Meta-commands Lines starting with `:` are REPL commands, not Nybl code: | Command | Action | |---------|--------| | `:help` | Print the meta-command list | | `:vars` | List all currently-bound names (sorted) | | `:reset` / `:clear` | Drop every binding and start fresh | | `:quit` / `:q` / `:exit` | Exit the REPL | Unknown commands surface a friendly "try `:help`" hint rather than being silently ignored. ## History Arrow keys browse history. `~/.nybl_history` (`$USERPROFILE\.nybl_history` on Windows) persists history across sessions. History save on exit is best-effort — the REPL doesn't error if it can't write the file. ## Error handling Runtime and parse errors render with the same source-snippet + caret as errors from `nybl run`: ``` > let f = fn(n) { return missing(n) } > f(5) error: I don't know what 'missing' is --> line 1:23 | 1 | let f = fn(n) { return missing(n) } | ^ hint: Did you forget to create it with `let`? ``` The error doesn't reset the session — subsequent submissions still see the prior bindings. ## Piped / non-TTY input When stdin isn't a terminal (piped, heredoc, test harness), the REPL accumulates stdin line by line using the same incomplete-input heuristic, so a script like: ```bash nybl repl <` | Greater than | `bool` | | `<=` | Less or equal | `bool` | | `>=` | Greater or equal | `bool` | ### Equality (`==`, `!=`) Works on every type. Arrays and dicts compare structurally (element-wise, then entry-wise). User-defined struct and enum values compare by full type identity `(declaring module, type name)` plus their payloads — two structs with the same name declared in different modules are *not* equal even with matching field values. Opaque host values compare by handle identity. Copies of the same host handle are equal; separately constructed handles are unequal even when their hidden Rust payloads would compare equal. ```nybl print(5 == 5) // true print(5 == "5") // false (different types) print(1 == 1.0) // true (int ↔ number cross-type) print([1, 2] == [1, 2]) // true (structural) print({"a": 1} == {"a": 1}) // true ``` ### Ordering (`<`, `>`, `<=`, `>=`) Numeric (`int` + `number`, with cross-type widening) and strings (lexicographic) only. Applying an ordering operator to anything else raises a runtime error. ```nybl print(3 < 5) // true print(1 > 0.5) // true (int > number) print("abc" < "def") // true (lexicographic) // print([1, 2] < [3]) // error — can't use `<` with array ``` ## Boolean | Operator | Name | Notes | |----------|------|-------| | `&&` | And | Short-circuits | | `\|\|` | Or | Short-circuits | | `!` | Not | Unary prefix | There are no word-spelled aliases — `and`, `or`, `not` are not keywords. Short-circuiting means the second operand isn't evaluated if the first determines the result: ```nybl // && stops at the first false if x > 0 && x < 100 { print("In range") } // || stops at the first true if name == "" || name == none { print("No name provided") } // ! inverts a boolean if !found { print("Still searching...") } ``` ### Truthiness `false` and `none` are falsy; every other value (including `0`, `""`, `[]`, `{}`) is truthy. ## Assignment | Operator | Equivalent to | |----------|--------------| | `=` | Assign | | `+=` | `x = x + ...` | | `-=` | `x = x - ...` | | `*=` | `x = x * ...` | | `/=` | `x = x / ...` | | `%=` | `x = x % ...` | ```nybl let score = 0 score += 10 // 10 score -= 3 // 7 score *= 2 // 14 ``` Assignment targets can be: - Bare identifiers: `x = ...` - Index positions: `items[0] = ...`, `dict["key"] = ...` - Struct fields: `point.x = ...` Reassigning an all-caps identifier is a parse error ("can't reassign a constant"). The rule follows the base binding through index and field targets, so `VALUES[0] = ...` and `CONFIG.retries += ...` are also rejected when `VALUES` or `CONFIG` is a constant. ## Field access / method call | Operator | What it does | |----------|-------------| | `.field` | Read a struct field or enum struct-variant payload field | | `.method(...)` | Call a builtin method (on arrays / strings / dicts) or a user-declared method | | `[idx]` | Index into an array / string / dict | ```nybl let p = Point { x: 3, y: 4 } print(p.x) // 3 print(p.sum()) // calls user method `fn Point.sum` print([1, 2, 3].len()) // 3 print("hello".upper()) // "HELLO" ``` ## `try` `try expr` is a unary prefix that unwraps `Result::Ok(v)` to `v` or propagates `Err(e)` to the enclosing function's caller. See [Error Handling](https://nybl-lang.com/docs/errors/index.html.md). ```nybl fn parse_and_double(s) { let n = try string_to_int(s) // returns Err early on failure return Result::Ok(n * 2) } ``` ## Conditional expressions Nybl has no ternary operator (`?:`). Use `if/else` as an expression: ```nybl let label = if count > 3 { "lots" } else { "few" } ``` Both branches are required when `if/else` is used as an expression. The last expression in each branch is the value. ## Precedence From highest (evaluated first) to lowest: | Priority | Operators | |----------|-----------| | 1 | `.field`, `.method(...)`, `[idx]`, `(args)`, postfix | | 2 | `!`, `-` (unary), `try` | | 3 | `*`, `/`, `%` | | 4 | `+`, `-` | | 5 | `<`, `>`, `<=`, `>=` | | 6 | `==`, `!=` | | 7 | `&&` | | 8 | `\|\|` | | 9 | `=`, `+=`, `-=`, `*=`, `/=`, `%=` | Parentheses override precedence: ```nybl let result = (1 + 2) * 3 // 9, not 7 ``` --- Source: https://nybl-lang.com/docs/reference/builtins/ # Built-in Functions Nybl ships a very small set of built-in functions that are always in scope. They can't be shadowed by user-defined fns. Host-backed builtins (I/O, time) are provided separately by the embedding host — see `nybl-sys`'s `StandardHost` for the reference implementation. Anything math- or conversion-shaped lives as a [method on a value](https://nybl-lang.com/docs/reference/methods/index.html.md), not as a global — `(-5).abs()`, `"42".to_int()`, `[1, 2, 3].len()`. The global list is deliberately short: variadic (`print`), constructor-shaped (`range`), session-stateful (`rand`), callable-taking (`try_call`), and the shared error-signalling primitive (`panic`). ## `print(args...)` Prints values to the host's stdout, separated by spaces. Returns `none`. ```nybl print("hello") // hello print("x =", 42) // x = 42 print(1, "plus", 2) // 1 plus 2 ``` Accepts any number of arguments (including zero). Each argument is converted to its string representation (Display) automatically — you don't need `.to_str()` first. ## `range(n)` / `range(start, end)` / `range(start, end, step)` Builds an array of `int` values. ```nybl range(5) // [0, 1, 2, 3, 4] range(2, 6) // [2, 3, 4, 5] range(0, 10, 2) // [0, 2, 4, 6, 8] range(5, 0) // [5, 4, 3, 2, 1] (auto-detects direction) range(10, 0, -3) // [10, 7, 4, 1] ``` - With 1 arg: `range(n)` → `[0, 1, ..., n-1]`. - With 2 args: `range(start, end)` auto-detects direction. - With 3 args: explicit `step` (error if `step == 0`). - All arguments must be `int` — floats are rejected. - Maximum 10,000 elements. Bigger ranges raise a fatal resource error before allocating, so they cannot be silently truncated or hidden with `try_call`. ## `rand(n)` Returns a random integer from `0` to `n - 1`, inclusive. `n` must be a positive integer. ```nybl rand(6) // 0..=5 (die roll) rand(2) // 0 or 1 (coin flip) ``` Uses a deterministic PRNG seeded per-session. The same inputs produce the same sequence — handy for tests, surprising for crypto (don't use it for that). ## `try_call(callable)` Runs `callable` with no arguments and catches any non-fatal runtime error: ```nybl let r = try_call(fn() { return 1 / 0 }) print(match r { Ok(v) => v, Err(e) => "caught: " + e.message, }) // caught: Division by zero ``` On success returns `Result::Ok(value)`; on a non-fatal error returns `Result::Err(RuntimeError { message, line })`. The `Ok(v)` / `Err(e)` pattern shorthand desugars to `Result::Ok(v)` / `Result::Err(e)` — see [Error Handling](https://nybl-lang.com/docs/errors/index.html.md#ok-err-shorthand). Fatal errors (step-limit / memory-limit / fn-call-depth) are *not* caught — they propagate past `try_call` unchanged so the sandbox invariant holds. See [Error Handling](https://nybl-lang.com/docs/errors/index.html.md) for the full story on `try`, `try_call`, `Result`, and `RuntimeError`. ## `panic(message)` Raise a non-fatal runtime error carrying `message`. Useful inside stdlib helpers (`unwrap`, `expect`, `assert_eq`, …) and user code that needs to bail with a readable error from an expression position where a plain `return` isn't enough. ```nybl fn take_positive(n) { if n <= 0 { panic("n must be positive, got {n}") } return n * 2 } print(take_positive(3)) // 6 // print(take_positive(-1)) // runtime error: n must be positive, got -1 ``` Non-fatal, so `try_call` catches it: ```nybl let r = try_call(fn() { panic("deliberate") }) print(match r { Ok(_) => "ok?", Err(e) => e.message, }) // deliberate ``` Non-string arguments are stringified via Display, so `panic(42)` or `panic(RuntimeError { message: "x", line: 0 })` also works without an explicit `.to_str()`. ## Everything else lives on a value Category | Was | Now --- | --- | --- Introspection | `type(x)`, `inspect(x)` | `x.type()`, `x.inspect()` Conversion | `str(x)`, `int(x)`, `float(x)` | `x.to_str()`, `x.to_int()`, `x.to_float()` Length | `len(x)` | `x.len()` (arrays, strings, dicts) Absolute / min / max | `abs(x)`, `min(a, b)`, `max(a, b)` | `x.abs()`, `a.min(b)`, `a.max(b)` Trig / roots | `sqrt(x)`, `sin(x)`, `cos(x)`, `tan(x)` | `x.sqrt()`, `x.sin()`, `x.cos()`, `x.tan()` Rounding | `floor(x)`, `ceil(x)`, `round(x)` | `x.floor()`, `x.ceil()`, `x.round()` Power / log / exp | `pow(b, e)`, `log(x)`, `exp(x)` | `b.pow(e)`, `x.log()`, `x.exp()` See [Methods](https://nybl-lang.com/docs/reference/methods/index.html.md) for the full per-type method catalogue. --- Source: https://nybl-lang.com/docs/reference/methods/ # Methods Nybl dispatches methods with `.name(args...)`. Every built-in method on primitives, arrays, strings, and dicts is listed here. User-defined methods on structs use the same syntax — see [Structs & Enums](https://nybl-lang.com/docs/data/structs-and-enums/index.html.md) for the `fn Type.method(self, ...)` form. ## Mutating receivers Array methods such as `push`, `pop`, `insert`, `remove`, `reverse`, and `sort` mutate a mutable place rooted in a `let` binding using the same transactional copy-in/copy-out model as a [`ref` parameter](https://nybl-lang.com/docs/functions/reference-parameters/index.html.md). Method arguments run first, then Nybl snapshots the receiver, and a normal return writes the updated value back. A genuine temporary is allowed and its ordinary result is preserved, but its mutation has nowhere to be stored: `([1, 2]).pop()` returns `2`, while `[1, 2].push(3)` returns `none`. Index and field receivers are write-back places, so `groups[0].push(x)` and `record.items.push(x)` update their root binding atomically. User-defined methods choose their receiver mode explicitly. An ordinary `self` is a read-only value snapshot; assigning through it is a parse error. Declare `ref self` when the method should update the caller's binding: ```nybl struct Counter { amount } fn Counter.add(ref self, amount) { self.amount += amount } let counter = Counter { amount: 3 } counter.add(4) print(counter.amount) // 7 ``` The receiver marker appears only in the declaration: `counter.add(4)`, not `ref counter.add(4)`. The receiver must be a mutable field/index place rooted in a `let` binding. It is snapshotted after ordinary arguments, commits on a normal return, and rolls back on an error. A method may also declare explicit `ref` parameters after its receiver; all receiver and argument targets must be distinct and commit as one transaction. ## Opaque host methods An embedder can return an opaque `HostValue` and implement its methods through `NyblHost::call_method`. These calls use the same `value.method(args...)` syntax, but their effects belong to the host: they do not write a new receiver back into a Nybl variable and are not rolled back by `ref` transactions or runtime errors. Arguments are value-only. Host values have a host-defined type name and a fixed, payload-hiding display: `handle.type()` may return `"file"`, while `handle.to_str()` and `handle.inspect()` return `""`. Separate handles compare by identity, not by their hidden payload. See the [Rust embedding example](https://nybl-lang.com/docs/embedding/index.html.md#opaque-host-values-and-methods). ## Methods on every value Five methods work on any value — introspection, stringification, and optional-value checks. They're dispatched before the type-specific and host method tables, so they're always available: | Method | Returns | Notes | |--------|---------|-------| | `x.type()` | string | A built-in type name, a declared struct/enum name, or an opaque host value's host-defined name | | `x.to_str()` | string | Display repr — same as what `print(x)` would emit for a single arg | | `x.inspect()` | string | Debug repr — strings are wrapped in `"..."`, nested strings stay quoted inside arrays / dicts | | `x.is_none()` | bool | `true` iff `x` is the `none` value. Equivalent to `x == none`. | | `x.is_some()` | bool | Inverse of `.is_none()` — `true` for every value except `none`. | ```nybl print((42).type()) // "int" print("hi".to_str()) // "hi" print("hi".inspect()) // "hi" (quoted) print([1, "two"].inspect()) // [1, "two"] print(none.is_none()) // true print((0).is_none()) // false — `0` is falsy but not `none` print(first_result().is_some()) // check an optional return without `== none` ``` > `.is_none()` / `.is_some()` cover Nybl's "any variable can be `none`" story — they work on every receiver, not just `Option`-shaped ones (Nybl doesn't have `Option`). Equivalent to `x == none` / `x != none`, but reads better in method chains. ### Parens around numeric literals Number literals need parens before a method call because `.` is otherwise a decimal point: ```nybl // print(42.type()) // parse error — `42.t…` looks like a decimal print((42).type()) // "int" print((-5).abs()) // 5 ``` Identifiers don't have this problem: `x.type()`, `count.to_str()`, etc. ## Numeric methods — `int` and `number` All of these work on both `int` and `number` receivers. Return type is noted per method; most math operations always widen to `number`. | Method | Returns | Description | |--------|---------|-------------| | `x.abs()` | int / number | Absolute value. Preserves receiver type. `(-5).abs()` → `5` (int), `(-2.7).abs()` → `2.7` (number). Integer overflow on `(i64::MIN).abs()` is a runtime error. | | `x.sqrt()` | number | Square root. | | `x.sin()`, `x.cos()`, `x.tan()` | number | Trig. Angles in radians. | | `x.exp()` | number | `e^x`. | | `x.log()` | number | Natural log. | | `x.pow(e)` | number | `x` raised to `e`. | | `x.floor()`, `x.ceil()`, `x.round()` | int / number | Round toward `-∞`, `+∞`, or nearest (ties away from zero). Returns `int` when the rounded result fits in `i64`, `number` otherwise (so rounding a number that overflows `i64` stays a `number` instead of raising). Int receivers pass through unchanged. | | `a.min(b)`, `a.max(b)` | int / number | Pair-wise. Preserves type when both sides match; widens to `number` on mixed int/number. | | `x.to_int()` | int | Truncates toward zero. `(3.7).to_int()` → `3`, `(-2.7).to_int()` → `-2`. | | `x.to_float()` | number | Widens `int` → `number`; `number` passes through. | ```nybl print((9).sqrt()) // 3 print((0).cos()) // 1 print((2).pow(10)) // 1024 print((3).min(7)) // 3 print((1).max(2.5)) // 2.5 (widened) print((3.7).floor()) // 3 (int) print((3.7).floor().type()) // "int" ``` ## Boolean methods — `bool` | Method | Returns | Description | |--------|---------|-------------| | `b.to_int()` | int | `true.to_int()` → `1`, `false.to_int()` → `0`. | | `b.to_float()` | number | `true.to_float()` → `1` (as number), `false.to_float()` → `0`. | Plus the universal `type` / `to_str` / `inspect`. ## String methods — `string` See [Strings](https://nybl-lang.com/docs/data/strings/index.html.md) for worked examples. | Method | Returns | Description | |--------|---------|-------------| | `s.len()` | int | Number of Unicode code points. | | `s.contains(sub)` | bool | Whether `sub` appears anywhere. | | `s.starts_with(prefix)` | bool | | | `s.ends_with(suffix)` | bool | | | `s.index_of(sub)` | int | Byte index of first occurrence, or `-1` if not found. | | `s.split(sep)` | array | Split into an array of strings on `sep`. | | `s.replace(old, new)` | string | Replace every occurrence. | | `s.upper()`, `s.lower()` | string | Case conversion. | | `s.trim()` | string | Strip leading / trailing whitespace. | | `s.slice(start, end)` | string | Half-open substring by code-point index. Negative bounds count from the end; out-of-range bounds clamp. | | `s.to_int()` | int | Parse. `"3.7".to_int()` parses as float then truncates → `3`. Raises on junk. | | `s.to_float()` | number | Parse. Raises on junk. | | `s.iter()` | iter | Lazy iterator over Unicode code points. See [Iter methods](#iter-methods-iter). | ## Array methods — `array` See [Arrays](https://nybl-lang.com/docs/data/arrays/index.html.md) for worked examples. Mutating methods write back when the receiver is a mutable place rooted in a `let` binding (`items.push(x)`, `groups[0].push(x)`, or `holder.items.pop()`). Calling one on a genuine temporary, such as `[1].push(2)` or `make_items().pop()`, is legal; the temporary is mutated and then discarded. Nested receiver projections are evaluated once and the updated leaf is written back atomically through the root. If the method errors, the whole root remains unchanged. | Method | Returns | Description | |--------|---------|-------------| | `arr.len()` | int | Number of elements. | | `arr.push(v)` | none | Append. | | `arr.pop()` | value | Remove and return the last element. | | `arr.has(v)` | bool | Structural equality check. | | `arr.index_of(v)` | int | Index of first match, or `-1`. | | `arr.insert(i, v)` | none | Insert at a signed index, shifting right. Negative indices count from the end; `len` appends. | | `arr.remove(i)` | value | Remove at a signed index, returning the removed value. Negative indices count from the end. | | `arr.truncate(n)` | none | Shorten to at most `n` elements, dropping the tail. Negative lengths count from the end like a `slice` bound; no-op when already short enough. | | `arr.clear()` | none | Remove every element. | | `arr.slice(start, end)` | array | Half-open sub-array. Negative bounds count from the end; out-of-range bounds clamp. | | `arr.reverse()` | none | In-place. | | `arr.sort()` | none | In-place, numeric or lexicographic depending on element types. | | `arr.join(sep)` | string | Join after stringifying each element. | | `arr.iter()` | iter | Lazy iterator over the elements. See [Iter methods](#iter-methods-iter). | ## Dict methods — `dict` See [Dictionaries](https://nybl-lang.com/docs/data/dictionaries/index.html.md) for worked examples. `d.remove(key)` and `d.clear()` mutate their receiver with the same write-back rules as the mutating array methods above: mutable places rooted in a `let` binding write back atomically, constants are rejected, and a genuine temporary is mutated and then discarded. Because they are mutating methods, they also work through `ref` parameters and `ref self`, where reassigning the callee's binding would not. | Method | Returns | Description | |--------|---------|-------------| | `d.len()` | int | Number of entries. | | `d.keys()` | array | All keys as strings. | | `d.values()` | array | All values. | | `d.has(key)` | bool | Whether `key` exists. | | `d.remove(key)` | value | Remove `key`, returning its value, or `none` when absent. The key must be a string. | | `d.clear()` | none | Remove every entry. | | `d.iter()` | iter | Lazy iterator over keys, in declaration order. See [Iter methods](#iter-methods-iter). | ## Result methods — `Result` `Result` is an [engine built-in](https://nybl-lang.com/docs/errors/index.html.md). All combinators are methods on the built-in type — no import required. `Ok(x)` and `Err(e)` are [parser-level shorthand](https://nybl-lang.com/docs/errors/index.html.md#ok-err-shorthand) for `Result::Ok(x)` / `Result::Err(e)` in both expression and pattern position. | Method | Returns | Description | |--------|---------|-------------| | `r.is_ok()` | bool | `true` when `r` is `Result::Ok(_)`. | | `r.is_err()` | bool | `true` when `r` is `Result::Err(_)`. | | `r.unwrap()` | value | Payload on `Ok`; raises a runtime error on `Err` (message includes the `.inspect()` of the payload). | | `r.expect(msg)` | value | Payload on `Ok`; raises with `msg` on `Err`. | | `r.unwrap_or(default)` | value | Payload on `Ok`; `default` on `Err`. | | `r.map(f)` | Result | `Ok(v)` → `Ok(f(v))`; `Err(e)` passes through. | | `r.map_err(f)` | Result | `Err(e)` → `Err(f(e))`; `Ok(v)` passes through. | | `r.and_then(f)` | Result | `Ok(v)` → `f(v)` (expected to return a Result); `Err(e)` passes through. | ```nybl print(Ok(5).is_ok()) // true print(Err("bad").unwrap_or(0)) // 0 print(Ok(5).map(fn(v) { return v * 2 })) // Result::Ok(10) print(Err("x").map(fn(v) { return v * 2 })) // Result::Err("x") fn halve(x) { if x % 2 == 0 { return Ok((x / 2).to_int()) } return Err("odd") } print(Ok(8).and_then(halve).and_then(halve)) // Result::Ok(2) ``` ## Iter methods — `iter` An `iter` is Nybl's lazy iterator. Values you can iterate over — arrays, strings, dicts, built-in iterators, and user-defined containers — all participate in the same protocol: 1. `v.iter()` returns an iterator. 2. `it.next()` advances it, returning `Iter::Next(value)` or `Iter::Done`. `for x in v` uses this protocol, so anything with a working `.iter()` method works with `for`. | Method | Returns | Description | |--------|---------|-------------| | `it.next()` | `Iter::Next(v)` / `Iter::Done` | Advance by one. Cloning an iterator shares its cursor (like Python / Rust / JS) — two names pointing at the same iterator advance together. | | `it.iter()` | iter | Returns the same iterator. Makes `for x in it` work whether `it` is already an iterator or a fresh iterable. | ```nybl let it = [10, 20, 30].iter() print(it.type()) // "iter" print(it.next()) // Iter::Next(10) print(it.next()) // Iter::Next(20) for x in it { print(x) } // 30 (picks up from the current cursor) ``` ### User-defined iterables A struct can participate in the iterator protocol by implementing `.iter()` (and, if it's its own iterator, `.next()`): ```nybl struct Bag { items } fn bag_of(arr) { return Bag { items: arr } } fn Bag.iter(self) { return self.items.iter() } // delegate to the backing array let b = bag_of(["x", "y", "z"]) for v in b { print(v) } // x y z ``` That's the minimal shape. A container that wraps an array and delegates `.iter()` is the 80% case. User types with genuine internal state (like a lazy counter) work the same way — define `fn Counter.iter(self)` to return an iterator (either the backing data's iterator, or `self` if `self` also has `.next()`). ### The `Iter` enum `.next()` returns one of two variants of the built-in `Iter` enum — always in scope, no `use` required: ``` enum Iter { Next(value), Done, } ``` Pattern-match directly: ```nybl let it = [1, 2].iter() let r = it.next() print(match r { Iter::Next(v) => "got: " + v.to_str(), Iter::Done => "exhausted", }) // got: 1 ``` ## Struct / enum methods User-declared. See [Structs & Enums](https://nybl-lang.com/docs/data/structs-and-enums/index.html.md). Method dispatch on a struct tries the universal common methods first, then looks up `fn TypeName.method` declared in the same module. > **Same-module rule**: user-declared methods must live in the module that declares the type. You can't extend a type imported from another module, and you can't add methods to the built-ins (`int`, `string`, `array`, `Result`, `Iter`, …). See [Methods must live in the type's own module](https://nybl-lang.com/docs/data/structs-and-enums/index.html.md#methods-must-live-in-the-types-own-module) for the rationale and the "use a free function instead" workaround. ## Module methods If you `use path as m`, `m` is a `Value::Module`. `m.type()` → `"module"`, `m.inspect()` → `""`. Otherwise `.` on a module accesses its exports: ```nybl use std.math as m print(m.PI) // exported constant print(m.type()) // "module" (universal method, not an export) ``` --- Source: https://nybl-lang.com/docs/reference/grammar/ # Grammar An informal grammar for the Nybl language, plus the complete list of reserved words. ## Reserved words ``` let const pub ref fn return if else while for in repeat break continue use as struct enum match try true false none ``` These can't be used as variable or function names. There are no word-spelled logical operators — use `&&`, `||`, `!` rather than `and`, `or`, `not`. ## Built-in functions Always in scope; can't be shadowed by user fns. See [Built-in Functions](https://nybl-lang.com/docs/reference/builtins/index.html.md) for details. | Function | Returns | Description | |----------|---------|-------------| | `print(args...)` | none | Host-captured output | | `range(n)` / `range(s, e)` / `range(s, e, step)` | array | Integer range | | `rand(n)` | int | Pseudo-random `0..n` | | `try_call(f)` | Result | Run `f`, return `Ok(v)` or `Err(RuntimeError)` | | `panic(message)` | never returns | Raise a non-fatal runtime error carrying `message` | All math and conversion operations are [methods on values](https://nybl-lang.com/docs/reference/methods/index.html.md): `x.type()`, `x.to_str()`, `x.to_int()`, `x.to_float()`, `x.abs()`, `a.min(b)`, `x.sqrt()`, `x.len()`, etc. ## Grammar Informal EBNF. Whitespace is insignificant *except* for newlines, which auto-insert semicolons (see below). ``` program = statement* statement = letDecl | constDecl | assign | ifStmt | whileStmt | repeatStmt | forStmt | fnDecl | returnStmt | breakStmt | continueStmt | useStmt | publicSurface | structDecl | enumDecl | methodDecl | exprStmt letDecl = "let" IDENT "=" expr constDecl = "const" IDENT "=" expr assign = target ("=" | "+=" | "-=" | "*=" | "/=" | "%=") expr target = IDENT | postfix "[" expr "]" | postfix "." IDENT ifStmt = "if" expr block ("else" "if" expr block)* ("else" block)? whileStmt = "while" expr block repeatStmt = "repeat" expr block forStmt = "for" IDENT "in" expr block fnDecl = "pub"? "fn" IDENT "(" params? ")" block returnStmt = "return" expr? breakStmt = "break" continueStmt = "continue" useStmt = "use" path | "use" path "." "{" IDENT ("," IDENT)* "}" | "use" path "as" IDENT | "use" path "." "{" IDENT ("," IDENT)* "}" "as" IDENT path = IDENT ("." IDENT)* publicSurface = "pub" "{" (IDENT ("," IDENT)* ","?)? "}" structDecl = "struct" IDENT "{" fields? "}" fields = IDENT ("," IDENT)* enumDecl = "enum" IDENT "{" variants? "}" variants = variant ("," variant)* variant = IDENT // unit | IDENT "(" IDENT ("," IDENT)* ")" // tuple | IDENT "{" IDENT ("," IDENT)* "}" // struct methodDecl = "fn" IDENT "." IDENT "(" params ")" block exprStmt = expr block = "{" statement* "}" expr = or or = and ("||" and)* and = equality ("&&" equality)* equality = comparison (("==" | "!=") comparison)* comparison = addition (("<" | ">" | "<=" | ">=") addition)* addition = multiply (("+" | "-") multiply)* multiply = unary (("*" | "/" | "%") unary)* unary = ("!" | "-" | "try") unary | postfix postfix = primary (call | index | field | method | structLit | variantCtor)* call = "(" args? ")" index = "[" expr "]" field = "." IDENT method = "." IDENT "(" args? ")" structLit = "{" (IDENT ":" expr ("," IDENT ":" expr)*)? "}" // only at expr position variantCtor = "::" IDENT payload? payload = "(" expr ("," expr)* ")" // tuple variant | "{" IDENT ":" expr ("," IDENT ":" expr)* "}" // struct variant primary = INT | NUMBER | STRING | "true" | "false" | "none" | IDENT | resultShorthandExpr | "(" expr ")" | arrayLit | dictLit | ifExpr | matchExpr | fnExpr resultShorthandExpr = ("Ok" | "Err") "(" expr ("," expr)* ")" // sugar for Result::Ok/Err arrayLit = "[" (expr ("," expr)* ","?)? "]" dictLit = "{" (STRING ":" expr ("," STRING ":" expr)* ","?)? "}" ifExpr = "if" expr "{" expr "}" "else" "{" expr "}" matchExpr = "match" expr "{" arm ("," arm)* ","? "}" arm = pattern ("if" expr)? "=>" expr fnExpr = "fn" "(" params? ")" block pattern = orPattern orPattern = singlePattern ("|" singlePattern)* singlePattern = "_" | IDENT | literal | variantPattern | structPattern | arrayPattern | resultShorthand variantPattern = (IDENT ".")? IDENT "::" IDENT | (IDENT ".")? IDENT "::" IDENT "(" pattern ("," pattern)* ")" | (IDENT ".")? IDENT "::" IDENT "{" IDENT ":" pattern ("," IDENT ":" pattern)* "}" resultShorthand = ("Ok" | "Err") "(" pattern ("," pattern)* ")" // sugar for Result::Ok/Err structPattern = (IDENT ".")? IDENT "{" IDENT ":" pattern ("," IDENT ":" pattern)* "}" arrayPattern = "[" patternList? arrayRest? "]" patternList = pattern ("," pattern)* arrayRest = ".." | ".." IDENT params = fixedParam ("," fixedParam)* ("," restParam)? | restParam fixedParam = "ref"? IDENT restParam = ".." IDENT args = arg ("," arg)* arg = "ref"? expr ``` `INT` is an exact signed 64-bit integer after unary parsing. Decimal magnitudes through `9223372036854775807` are ordinary primary expressions; the boundary spelling `-9223372036854775808` is accepted when unary `-` directly owns that magnitude, including in literal patterns. A bare `9223372036854775808`, `0 - 9223372036854775808`, or any larger magnitude is out of range rather than being converted to a floating-point `number`. `pub fn` is accepted only on a named function at the direct program root and marks an entry for the stateful embedding ABI. Separately, a direct module-root `pub { name, Type }` statement declares that module's importable allow-list. It is inert in the directly executed root. `ref` marks copy-in/copy-out parameters and normally appears at the same positional argument at the call site. A method's first parameter is the exception: `fn Point.move(ref self, dx) { ... }` is called as `point.move(dx)` because method syntax supplies the receiver reference implicitly. Although the grammar accepts an expression after an argument marker so parsing stays independent of the dynamic callee, semantic validation requires a mutable field/index place rooted in an uncaptured `let` binding. Ordinary method receivers are read-only, and assigning through one is a parse error. See [Reference Parameters](https://nybl-lang.com/docs/functions/reference-parameters/index.html.md). A final `..rest` parameter collects zero or more extra value arguments into an array. It is declaration-only, value-only, and must be the last parameter. `methodDecl`, enum variant `IDENT`s, and `struct` names must start with an uppercase letter. `IDENT` bound by `let`, `fn`, parameters, `for`, etc. must start with lowercase or `_`. `const` names must be all-caps. Mis-shaped declarations parse-error with a "did you mean?" suggestion — see [Variables](https://nybl-lang.com/docs/basics/variables/index.html.md#name-shapes-are-checked). ## Automatic semicolons Nybl automatically inserts a semicolon at the end of a line if the last token is one of: - An identifier or literal (`int`, `number`, `string`) - `true`, `false`, `none` - `break`, `continue`, `return` - `)`, `]`, `}` Newlines do not insert semicolons while the innermost open delimiter is `(` or `[`. This makes calls, conditions, array literals, and index expressions safe to lay out over multiple lines. A newline immediately before a closing `)`, `]`, or `}` is also ignored, so the final item in a multiline literal does not require a trailing comma. Braces remain statement-capable even when their block is nested inside parentheses or brackets. Newlines between statements in a function or control-flow block still insert semicolons: ```nybl let callbacks = [ fn() { let x = 1 return x }, ] ``` A line starting with `.` continues a preceding value, including across blank lines or comments: ```nybl let size = values // Continue the same expression. .len() .to_str() ``` `return` itself remains a semicolon trigger. A newline immediately after it therefore means a bare `return` whose value is `none`; put the value on the same line, or open a parenthesized expression on that line when it needs multiline layout: ```nybl return ( left + right ) ``` This means the opening `{` of a block must be on the same line as its keyword: ```nybl // Correct if x > 3 { print("yes") } // Wrong — semicolon inserted after "3" if x > 3 { print("yes") } ``` You can also separate statements on the same line with an explicit `;`: ```nybl let x = 1; let y = 2 ``` ## Comments `//` starts a line comment — everything to the end of line is ignored: ```nybl // Whole-line comment let x = 5 // Inline trailing comment ``` There's no block-comment syntax. ## String interpolation Inside double-quoted strings, `{identifier}` inserts the value of a variable. Only plain variable names are allowed — no expressions, operators, or function calls: ```nybl let name = "Alice" print("Hello, {name}!") // works // print("Hello, {1 + 2}!") // error — expressions not allowed ``` Use `\{` and `\}` for literal braces in strings. Other supported escapes: `\"`, `\\`, `\n`, `\t`, `\r`. --- Source: https://nybl-lang.com/docs/stdlib/ # Standard Library — overview `nybl-lang` bundles a small set of modules written in Nybl itself behind the default `nybl-std` Cargo feature. They live under the `std.*` namespace. `nybl-sys::StandardHost` resolves them automatically before its filesystem fallback; custom hosts can delegate `std.*` names to `nybl::stdlib::resolve`. The stdlib is deliberately thin. Core math and `Result` operations are [methods on values](https://nybl-lang.com/docs/reference/methods/index.html.md) (`(-5).abs()`, `(9).sqrt()`, `r.unwrap_or(0)`, `r.map(f)`) — they don't need a module. The stdlib covers what's left: constants, higher-order helpers on arrays, data-structure types, string formatting, JSON, test assertions. ## Modules | Module | What it gives you | |--------|-------------------| | [`std.math`](https://nybl-lang.com/docs/stdlib/math/index.html.md) | `PI`, `E`, `TAU`, `clamp`, `sign`, `factorial`, `gcd`, `lcm`, `mean` | | [`std.iter`](https://nybl-lang.com/docs/stdlib/iter/index.html.md) | `map`, `filter`, `reduce`, `take`, `drop`, `zip`, `enumerate`, `all`, `any`, `count`, `find`, `find_index`, `flatten`, `sum`, `product`, `min_array`, `max_array` | | [`std.collections`](https://nybl-lang.com/docs/stdlib/collections/index.html.md) | `Stack`, `Queue`, `Set` as value-semantics structs | | [`std.string`](https://nybl-lang.com/docs/stdlib/string/index.html.md) | `pad_left`, `pad_right`, `center`, `chars`, `reverse`, `is_palindrome`, `count`, `join` | | [`std.json`](https://nybl-lang.com/docs/stdlib/json/index.html.md) | `parse`, `stringify` (RFC-8259, pure Nybl) | | [`std.test`](https://nybl-lang.com/docs/stdlib/test/index.html.md) | `assert`, `assert_eq`, `assert_near`, `assert_raises` | ## Using the stdlib `std` modules work with every [`use` form](https://nybl-lang.com/docs/modules/index.html.md): ```nybl use std.math // glob — `PI`, `clamp`, etc. available bare use std.iter.{map, filter} // selective use std.json as j // aliased ``` The modules are plain Nybl source — you can find the implementations in `nybl/src/modules/*.nybl` if you want to see how a helper is wired. ## Things you might expect to find here - **`Result` combinators** — `is_ok`, `is_err`, `unwrap`, `expect`, `unwrap_or`, `map`, `map_err`, `and_then` used to live in `std.result`. They're now **methods on the built-in `Result` type** and always available without any import. See [Methods → Result](https://nybl-lang.com/docs/reference/methods/index.html.md#result-methods-result). - **`print`, `range`, `rand`, `try_call`, `panic`** — always-in-scope [built-in functions](https://nybl-lang.com/docs/reference/builtins/index.html.md), not stdlib. - **Math on numbers** — `abs`, `sqrt`, `sin`, `cos`, `floor`, `ceil`, `round`, `pow`, `log`, `exp`, `min`, `max`, `to_int`, `to_float` are [methods on `int` / `number`](https://nybl-lang.com/docs/reference/methods/index.html.md#numeric-methods-int-and-number), not stdlib. ## Hosts without the stdlib Disable default Cargo features to omit the bundled source: ```toml nybl = { package = "nybl-lang", version = "0.4", default-features = false, features = ["std"] } ``` Embedders that keep the feature still choose whether to expose it: a custom host must call `nybl::stdlib::resolve` from `NyblHost::resolve_module`. Conversely, a host can bundle or load its own `std.*` source even when the feature is disabled. Nothing in the core language depends on the stdlib. The `std` feature shown above controls Rust standard-library integration; it is separate from the bundled Nybl stdlib. --- Source: https://nybl-lang.com/docs/stdlib/math/ # std.math Numeric constants and helpers that aren't idiomatic as methods. > The core math operations — `abs`, `sqrt`, `sin`, `cos`, `tan`, `floor`, `ceil`, `round`, `pow`, `log`, `exp`, `min`, `max` — are [methods on numbers](https://nybl-lang.com/docs/reference/methods/index.html.md), not stdlib functions. They live in core because they wrap `f64::*` operations that Nybl can't implement itself. ## Import ```nybl use std.math // glob use std.math.{PI, clamp} // selective use std.math as m // aliased ``` ## Constants All three are `const` (all-caps name, value is fixed at module load). | Name | Value | |------|-------| | `PI` | `3.141592653589793` | | `E` | `2.718281828459045` | | `TAU` | `6.283185307179586` | ## Functions ### `clamp(x, lo, hi)` Clamp `x` into the range `[lo, hi]`. Works on any mix of `int` and `number`; the return type mirrors the widest input. ```nybl use std.math.{clamp} print(clamp(5, 0, 10)) // 5 print(clamp(-3, 0, 10)) // 0 print(clamp(42, 0, 10)) // 10 ``` ### `sign(x)` Returns `-1`, `0`, or `1`. Works on both `int` and `number`. ```nybl use std.math.{sign} print(sign(-7)) // -1 print(sign(0)) // 0 print(sign(3.14)) // 1 ``` ### `factorial(n)` `n!` using iterative multiplication. Raises an integer-overflow error for `n ≥ 21` (the smallest factorial that doesn't fit in `i64`). Negative `n` returns `0`. ```nybl use std.math.{factorial} print(factorial(5)) // 120 print(factorial(10)) // 3628800 ``` ### `gcd(a, b)` Greatest common divisor using the Euclidean algorithm. Handles negatives by taking absolute values. `gcd(0, 0)` returns `0`. ```nybl use std.math.{gcd} print(gcd(12, 18)) // 6 print(gcd(-15, 25)) // 5 ``` ### `lcm(a, b)` Least common multiple. `lcm(0, x)` is `0` (so callers don't have to special-case). ```nybl use std.math.{lcm} print(lcm(4, 6)) // 12 print(lcm(0, 9)) // 0 ``` ### `mean(arr)` Arithmetic mean of a numeric array. Raises on an empty array so callers notice rather than silently getting `0`. ```nybl use std.math.{mean} print(mean([1, 2, 3, 4])) // 2.5 print(mean([10.0, 20.0])) // 15 ``` Need the sum or product instead? Use [`std.iter.sum`](https://nybl-lang.com/docs/stdlib/iter/index.html.md#sumarr) / [`std.iter.product`](https://nybl-lang.com/docs/stdlib/iter/index.html.md#productarr). --- Source: https://nybl-lang.com/docs/stdlib/iter/ # std.iter `std.iter` provides eager functional helpers over arrays. Array-in, array-out helpers such as `map` and `filter` allocate their result; a hand-written `for` loop can avoid that work when allocation matters. This module is separate from Nybl's built-in lazy [`.iter()` / `.next()` protocol](https://nybl-lang.com/docs/reference/methods/index.html.md#iter-methods-iter). You do not need `use std.iter` to write `for value in collection`, and the built-in protocol does not make these array helpers lazy. Every helper handles empty arrays gracefully and preserves relative order. ## Import ```nybl use std.iter // glob use std.iter.{map, filter, reduce} // selective use std.iter as i // aliased ``` ## Higher-order ### `map(arr, f)` Apply `f` to each element; return a new array of results. ```nybl use std.iter.{map} print(map([1, 2, 3], fn(x) { return x * 2 })) // [2, 4, 6] ``` ### `filter(arr, pred)` Keep only the elements for which `pred(x)` is truthy. ```nybl use std.iter.{filter} let evens = filter([1, 2, 3, 4, 5], fn(n) { return n % 2 == 0 }) print(evens) // [2, 4] ``` ### `reduce(arr, initial, combine)` Fold over the array left-to-right with a two-arg combiner. ```nybl use std.iter.{reduce} let sum = reduce([1, 2, 3, 4], 0, fn(acc, x) { return acc + x }) print(sum) // 10 ``` ### `all(arr, pred)`, `any(arr, pred)` `all` returns `true` when every element passes (vacuously true for empty). `any` returns `true` when at least one element passes. ```nybl use std.iter.{all, any} print(all([2, 4, 6], fn(n) { return n % 2 == 0 })) // true print(any([1, 3, 4], fn(n) { return n % 2 == 0 })) // true ``` ### `count(arr, pred)` Count elements for which `pred(x)` is truthy. ```nybl use std.iter.{count} print(count([1, 2, 3, 4, 5], fn(n) { return n > 2 })) // 3 ``` ### `find(arr, pred)` / `find_index(arr, pred)` `find` returns the first matching element, or `none` if no match. `find_index` returns the 0-based index, or `-1`. ```nybl use std.iter.{find, find_index} print(find([1, 2, 3, 4], fn(n) { return n > 2 })) // 3 print(find_index([1, 2, 3, 4], fn(n) { return n > 2 })) // 2 ``` ## Slicing ### `take(arr, n)` First `n` elements (or the whole array if it's shorter). Negative `n` yields an empty array. ```nybl use std.iter.{take} print(take([1, 2, 3, 4, 5], 3)) // [1, 2, 3] print(take([1, 2], 10)) // [1, 2] ``` ### `drop(arr, n)` Drop the first `n` elements. Negative `n` yields a full copy; `n >= arr.len()` yields `[]`. ```nybl use std.iter.{drop} print(drop([1, 2, 3, 4, 5], 2)) // [3, 4, 5] ``` ## Combining ### `zip(a, b)` Pair elements from two arrays. Stops at the shorter array's length. ```nybl use std.iter.{zip} print(zip([1, 2, 3], ["a", "b", "c"])) // [[1, "a"], [2, "b"], [3, "c"]] ``` ### `enumerate(arr)` Pair each element with its 0-based index. ```nybl use std.iter.{enumerate} print(enumerate(["a", "b"])) // [[0, "a"], [1, "b"]] ``` ### `flatten(arr)` Flatten an array of arrays one level down. ```nybl use std.iter.{flatten} print(flatten([[1, 2], [3, 4], [5]])) // [1, 2, 3, 4, 5] ``` ## Reductions ### `sum(arr)` Sum of a numeric array. Empty → `0`. ```nybl use std.iter.{sum} print(sum([1, 2, 3, 4])) // 10 print(sum([])) // 0 ``` ### `product(arr)` Product of a numeric array. Empty → `1` (multiplicative identity, matching NumPy / Python's `math.prod`). ```nybl use std.iter.{product} print(product([2, 3, 4])) // 24 print(product([])) // 1 ``` ### `min_array(arr)` / `max_array(arr)` Minimum / maximum of a numeric array. Raises on empty input so callers notice. ```nybl use std.iter.{min_array, max_array} print(min_array([3, 1, 4, 1, 5])) // 1 print(max_array([3, 1, 4, 1, 5])) // 5 ``` For pairwise min / max use the `.min()` / `.max()` [numeric methods](https://nybl-lang.com/docs/reference/methods/index.html.md#numeric-methods-int-and-number) instead. --- Source: https://nybl-lang.com/docs/stdlib/collections/ # std.collections `Stack`, `Queue`, and `Set` as value-semantics structs. ## Value semantics Nybl's user methods pass `self` by value, so these collections all return a **fresh instance** on mutation and you rebind: ```nybl let s = stack() s = s.push(1) s = s.push(2) ``` The pattern trades a little boilerplate for predictable semantics: `let a = b` doesn't alias, and method calls never surprise you by mutating out of band. ## Import ```nybl use std.collections // glob use std.collections.{stack, queue, set} // selective use std.collections as c // aliased ``` ## `Stack` (LIFO) ```nybl use std.collections.{stack} let s = stack() s = s.push(1) s = s.push(2) s = s.push(3) print(s.top()) // 3 print(s.size()) // 3 s = s.pop() print(s.top()) // 2 ``` | Method | Returns | Notes | |--------|---------|-------| | `stack()` | `Stack` | Empty constructor | | `s.is_empty()` | bool | | | `s.size()` | int | | | `s.push(v)` | `Stack` | New stack with `v` on top | | `s.top()` | value | Top element, or `none` if empty | | `s.pop()` | `Stack` | New stack without the top; popping empty is a no-op | ## `Queue` (FIFO) ```nybl use std.collections.{queue} let q = queue() q = q.enqueue("a") q = q.enqueue("b") q = q.enqueue("c") print(q.front()) // "a" q = q.dequeue() print(q.front()) // "b" ``` | Method | Returns | Notes | |--------|---------|-------| | `queue()` | `Queue` | Empty constructor | | `q.is_empty()` | bool | | | `q.size()` | int | | | `q.enqueue(v)` | `Queue` | New queue with `v` at the back | | `q.front()` | value | Front element, or `none` if empty | | `q.dequeue()` | `Queue` | New queue without the front; dequeuing empty is a no-op | Dequeue is O(n) in this naive implementation — good enough for scripting workloads. If you need tighter bounds, write an array-backed ring buffer. ## `Set` (unique, insertion-ordered) ```nybl use std.collections.{set, set_of} let a = set_of([1, 2, 3]) let b = set_of([2, 3, 4]) print(a.union(b).values()) // [1, 2, 3, 4] print(a.intersect(b).values()) // [2, 3] print(a.difference(b).values()) // [1] ``` | Method | Returns | Notes | |--------|---------|-------| | `set()` | `Set` | Empty constructor | | `set_of(arr)` | `Set` | From an array (duplicates collapse, first-seen wins) | | `s.is_empty()` | bool | | | `s.size()` | int | | | `s.has(v)` | bool | | | `s.add(v)` | `Set` | New set with `v`. Idempotent. | | `s.remove(v)` | `Set` | New set without `v`. Removing absent is a no-op. | | `s.values()` | array | Elements in insertion order | | `s.union(other)` | `Set` | | | `s.intersect(other)` | `Set` | | | `s.difference(other)` | `Set` | `self` minus `other` | --- Source: https://nybl-lang.com/docs/stdlib/string/ # std.string Formatting and character-level helpers that don't fit the method-on-string pattern. > The core string operations — `.len()`, `.trim()`, `.upper()`, `.split()`, `.contains()`, `.slice()`, `.replace()`, `.to_int()`, `.to_float()`, etc. — are [methods on strings](https://nybl-lang.com/docs/reference/methods/index.html.md#string-methods-string). This module adds things that are awkward as methods. ## Import ```nybl use std.string use std.string.{pad_left, center} use std.string as str ``` ## Padding ### `pad_left(s, width, ch)` / `pad_right(s, width, ch)` Pad `s` on the left (or right) with `ch` until it reaches `width` total characters. Already-long strings are returned unchanged. `ch` should be a single-character string. ```nybl use std.string.{pad_left, pad_right} print(pad_left("42", 5, "0")) // "00042" print(pad_right("hi", 6, ".")) // "hi...." ``` ### `center(s, width, ch)` Centre `s` inside `width` chars using `ch` as filler. Odd leftover space goes on the right (matches Python's `str.center`). ```nybl use std.string.{center} print(center("OK", 6, "-")) // "--OK--" print(center("OK", 7, "-")) // "--OK---" ``` ## Character-level ### `chars(s)` Split `s` into an array of single-character strings, preserving order. ```nybl use std.string.{chars} print(chars("abc")) // ["a", "b", "c"] ``` ### `reverse(s)` Reverse the character sequence. Works on ASCII and UTF-8 (iterates by code point). ```nybl use std.string.{reverse} print(reverse("hello")) // "olleh" ``` ### `is_palindrome(s)` `true` when `s` reads the same forwards and backwards. Case-sensitive — lowercase the input first if you need case-insensitive comparison. ```nybl use std.string.{is_palindrome} print(is_palindrome("racecar")) // true print(is_palindrome("Racecar")) // false ``` ## Other helpers ### `count(s, needle)` Count non-overlapping occurrences of `needle` in `s`. An empty `needle` returns `0` (avoids the "infinite matches at every position" ambiguity). ```nybl use std.string.{count} print(count("banana", "a")) // 3 print(count("aaaa", "aa")) // 2 (non-overlapping) ``` ### `join(arr, sep)` Join an array of strings with `sep`. This is a thin wrapper around the built-in `arr.join(sep)` [array method](https://nybl-lang.com/docs/reference/methods/index.html.md#array-methods-array) — it's here so users starting from `std.string` don't have to look elsewhere. ```nybl use std.string.{join} print(join(["a", "b", "c"], "-")) // "a-b-c" ``` --- Source: https://nybl-lang.com/docs/stdlib/json/ # std.json RFC-8259 JSON `parse` and `stringify`, implemented in pure Nybl. Performance is reasonable for scripting workloads (config files, API payloads up to a few MB). A C-backed parser would be faster but would pull `nybl-std` out of its zero-Rust-dep contract. ## Import ```nybl use std.json // glob use std.json.{parse, stringify} // selective use std.json as j // aliased ``` ## `stringify(value)` Emit `value` as JSON text. Nybl values that have no JSON analogue (`fn`, `struct`, `enum`) raise a runtime error — strip them out before calling. ```nybl use std.json.{stringify} print(stringify(42)) // "42" print(stringify("hello")) // "\"hello\"" print(stringify([1, 2, 3])) // "[1,2,3]" print(stringify({"name": "Alice", "age": 30})) // '{"name":"Alice","age":30}' print(stringify(true)) // "true" print(stringify(none)) // "null" ``` Strings are escaped for the five characters JSON requires (`"`, `\`, `\n`, `\r`, `\t`). Other control characters under 0x20 are not currently escaped — fine for the common cases, but a known gap if you're stringifying raw binary. ## `parse(text)` Parse JSON text into a Nybl value. Parse errors raise a runtime error with a position marker. ```nybl use std.json.{parse} print(parse("42")) // 42 print(parse("\"hello\"")) // "hello" print(parse("[1, 2, 3]")) // [1, 2, 3] print(parse("{\"name\": \"Alice\"}")) // {"name": "Alice"} print(parse("null")) // none ``` ### Mapping JSON type | Nybl type --- | --- number (integer) | `int` number (decimal / exponent) | `number` string | `string` boolean | `bool` null | `none` array | `array` object | `dict` (keys must be strings, which JSON already requires) ### Catching parse errors `parse` raises on malformed input. Wrap in `try_call` if you want a `Result`-shaped outcome: ```nybl use std.json.{parse} let r = try_call(fn() { return parse("\{broken") }) match r { Result::Ok(v) => print("parsed: " + v.to_str()), Result::Err(e) => print("parse failed: " + e.message), } // parse failed: ... ``` (The leading `\{` escapes the `{` so Nybl doesn't mistake it for a string-interpolation marker.) ### Known gaps - `\b` / `\f` escapes are rejected. Rare in real payloads. - `\uXXXX` escapes are rejected — they'd need 4-hex parsing plus code-point-to-UTF-8 conversion, which is nontrivial in pure Nybl. Both raise a clear "unsupported escape" error so you know what happened. --- Source: https://nybl-lang.com/docs/stdlib/test/ # std.test Minimal assertion toolkit for sanity checks in Nybl scripts. This isn't an xUnit clone — just the assertion primitives you'll reach for when writing quick tests. Assertions fail by routing through the [`panic`](https://nybl-lang.com/docs/reference/builtins/index.html.md#panicmessage) builtin, so the failure detail is surfaced verbatim in `Err(e).message` when caught by `try_call`. Print-based reporting is intentionally out of scope — wrap the assertion in `try_call` if you need "report and continue". ## Import ```nybl use std.test // glob use std.test.{assert_eq, assert_near} // selective use std.test as t // aliased ``` ## Assertions ### `assert(cond, message)` Assert that `cond` is truthy. On failure, raises a runtime error — `message` is surfaced in the crash trace. ```nybl use std.test.{assert} assert(1 + 1 == 2, "arithmetic still works") ``` ### `assert_eq(actual, expected)` Assert two values are structurally equal (same as `==`). On failure the error includes an `assert_eq failed:` prefix and the `.inspect()` of both sides. ```nybl use std.test.{assert_eq} assert_eq([1, 2, 3].len(), 3) assert_eq("hi".upper(), "HI") ``` ### `assert_near(actual, expected, tolerance)` Assert two floats are within `tolerance` of each other. Use this instead of `assert_eq` when comparing `number` values subject to rounding. ```nybl use std.test.{assert_near} assert_near((2).sqrt() * (2).sqrt(), 2, 0.0000001) ``` ### `assert_raises(body)` Assert that `body` — a zero-arg closure — raises a runtime error. On success (no raise), the assertion itself fails. Useful for negative tests. ```nybl use std.test.{assert_raises} assert_raises(fn() { let _ = 1 / 0 // expected to raise "Division by zero" }) assert_raises(fn() { "not a number".to_int() }) ``` Under the hood, `assert_raises` uses `try_call` to observe whether `body` raised, so the same fatal-vs-non-fatal rules apply — a step-limit exceeded inside `body` propagates past the assertion instead of being caught. ## Putting it together ```nybl use std.test.{assert_eq, assert_raises} fn normalize(arr) { if arr.len() == 0 { return none[0] } let total = 0 for x in arr { total = total + x } let m = total / arr.len() let out = [] for x in arr { out.push(x - m) } return out } assert_eq(normalize([1, 2, 3]), [-1, 0, 1]) assert_raises(fn() { normalize([]) }) // raises on empty print("normalize: all checks passed") ``` --- Source: https://nybl-lang.com/docs/embedding/ # Embedding Nybl Nybl is designed to be embedded in Rust applications. Three ways to run a program are available; every one uses the same `NyblHost` trait as the integration seam for print output, custom functions, module resolution, timeouts, etc. ## Quick start Add the crates you need to `Cargo.toml`: ```toml [dependencies] nybl = { package = "nybl-lang", version = "0.4" } nybl-sys = "0.4" # optional — OS-backed standard host nybl-vm = "0.4" # optional — bytecode VM (faster per-fn cost) ``` Run a program with the standard host: ```rust use nybl::NyblLimits; use nybl_sys::StandardHost; fn main() { let source = r#" let name = "world" print("Hello, {name}!") "#; let mut host = StandardHost::new(); if let Err(e) = nybl::run(source, &mut host, &NyblLimits::standard()) { eprintln!("{}", e.render(source)); } } ``` `StandardHost` (aliased as `StdHost`) lives in `nybl-sys` and is the OS-backed reference host. It supports `print()` output plus these host functions: | Function | Description | |----------|-------------| | `readline()` / `readline(prompt)` | Read one line from stdin; `none` at EOF | | `read_file(path)` | Read a UTF-8 file into a string | | `write_file(path, contents)` | Replace a file with string contents | | `append_file(path, contents)` | Append string contents to a file | | `file_exists(path)` | `true` / `false` | | `env(name)` | Environment variable, or `none` if missing | | `unix_time()` | Seconds since the epoch | | `unix_time_ms()` | Milliseconds since the epoch | `StandardHost::new()` resolves filesystem modules from the current working directory. Call `.with_module_root()` to choose a different guarded root: `use foo.bar.baz` then maps to `/foo/bar/baz.nybl`. ## Three engines, same host Nybl ships three execution engines — all of them share the `NyblHost` trait, `NyblError`, `NyblLimits`, and `Value` types, so the same program and host work with any of them. Pick whichever fits your workload: | Engine | One-shot entry point | Persistent entry point | When it's best | |--------|----------------------|------------------------|----------------| | Tree-walker | `nybl::run(src, host, limits)` | `nybl::NyblInstance` | Lowest start-up cost. Best for one-off scripts, REPL, small inputs, no_std / wasm. | | Bytecode VM | `nybl_vm::run(src, host, limits)` | `nybl_vm::NyblInstance` | 2–3× faster than the walker on hot loops. Same program, no compilation to disk. | | AOT transpiler | `nybl_compile::transpile(src, opts)` → `cargo build` | Generated `NyblInstance` in sandbox mode | Nybl → Rust source, compiled to a native binary. Maximum throughput, at the cost of a `cargo build` step. | The walker and VM always obey `NyblLimits`. Generated AOT code obeys the same limits only when `Options::sandbox` is enabled; default AOT output and `nybl compile` are unsandboxed and must not run untrusted source. All three surface failures as `NyblError` and use the same host boundary. Use the one-shot functions when a program should start from scratch on every run. For plugin-style programs whose globals, imports, callbacks, types, and random-number state must survive host calls, see [Stateful instances](https://nybl-lang.com/docs/embedding/instances/index.html.md). The VM can additionally compile a program once into a shareable `Send + Sync` artifact and instantiate it many times — including on worker threads — via [`CompiledScript`](https://nybl-lang.com/docs/embedding/instances/index.html.md#compile-once-instantiate-many). ## The `NyblHost` trait The `NyblHost` trait is the integration point between your application and Nybl: ```rust pub trait NyblHost { /// Called for unknown function names. `None` = not handled. fn call( &mut self, name: &str, args: &[Value], line: u32, ) -> Option>; /// Called for a non-common method on an opaque host value. /// `None` = this host does not implement the method. fn call_method( &mut self, receiver: &HostValue, method: &str, args: &[Value], line: u32, ) -> Option> { let _ = (receiver, method, args, line); None } /// Called by `print()`. Default: drops the message. fn on_print(&mut self, message: &str) { let _ = message; } /// Report a failure retained by the most recent `on_print`. /// Engines call this immediately after `on_print`. fn print_error(&self, line: u32) -> Option { let _ = line; None } /// Appended to "function not found" errors as a /// friendly hint (e.g. "Available functions: ..."). fn function_hint(&self) -> &str { "" } /// Called at each interpreter tick (statement, loop /// iteration, fn entry). Return `Err` to halt. fn on_tick(&mut self) -> Result<(), NyblError> { Ok(()) } /// Resolve a `use` target to Nybl source. /// `None` = "not mine" (the runtime raises "module not /// found"); `Some(Err(e))` = resolver failed (propagated). fn resolve_module(&mut self, name: &str) -> Option> { let _ = name; None } } ``` ### `call` — Custom functions The primary extension point. Lexical callable values dispatch directly. For a direct value-only function name, fallback resolution is built-in, host, then user-defined function, so a host can deliberately override that declaration. Ref-aware Nybl functions also dispatch directly so their reference metadata is preserved. Return `None` when you do not handle a fallback name; Nybl then tries the user-defined function and finally surfaces "function not found" with the optional `function_hint`. Return `Some(Ok(v))` on success or `Some(Err(e))` to raise a runtime error. ```rust use nybl::{NyblError, NyblHost, Value}; struct MyHost; impl NyblHost for MyHost { fn call( &mut self, name: &str, args: &[Value], line: u32, ) -> Option> { match name { "square" => match args { [Value::Int(n)] => Some( n.checked_mul(*n) .map(Value::Int) .ok_or_else(|| NyblError::runtime( "square(n) overflowed", line )) ), [Value::Number(n)] => Some(Ok(Value::Number(n * n))), _ => Some(Err(NyblError::runtime( "square(n) expects one number", line ))), }, _ => None, } } fn function_hint(&self) -> &str { "Custom functions: square(n)" } } ``` Nybl scripts now call `square(5)` as if it were built-in. ### Opaque host values and methods Use `HostValue` when a script should retain a host resource or capability and call methods on it without exposing its Rust representation. `Value::new_host` stores an owned, `'static` Rust payload behind a cheap reference-counted handle. `NyblHost::call_method` receives that handle and can recover its concrete payload with `downcast_ref`. ```rust use std::cell::Cell; use nybl::{HostValue, NyblError, NyblHost, Value}; struct Counter(Cell); struct AppHost; impl NyblHost for AppHost { fn call( &mut self, name: &str, args: &[Value], line: u32, ) -> Option> { match (name, args) { ("counter", []) => Some(Ok(Value::new_host( "counter", Counter(Cell::new(0)), ))), ("counter", _) => Some(Err(NyblError::runtime( "counter() expects no arguments", line, ))), _ => None, } } fn call_method( &mut self, receiver: &HostValue, method: &str, args: &[Value], line: u32, ) -> Option> { // A different host-value type is not handled by this dispatcher. let counter = receiver.downcast_ref::()?; match (method, args) { ("get", []) => Some(Ok(Value::Int(counter.0.get()))), ("add", [Value::Int(amount)]) => { counter.0.set(counter.0.get() + amount); Some(Ok(Value::Int(counter.0.get()))) } ("get" | "add", _) => Some(Err(NyblError::runtime( "invalid counter method arguments", line, ))), _ => None, } } } ``` The returned value behaves naturally in Nybl: ```nybl let count = counter() print(count.type()) // counter print(count) // print(count.add(4)) // 4 print(count.get()) // 4 ``` The type name is the static string supplied to `new_host`. Display, `to_str()`, and `inspect()` deliberately use the fixed `` form and never format the opaque payload. The universal methods `type`, `to_str`, `inspect`, `is_none`, and `is_some` run before `call_method` and cannot be overridden by the host. Cloning or passing a host value preserves the same handle. Equality is identity-based: two aliases of one handle compare equal, while two separately constructed handles compare unequal even if their Rust payloads contain equal data. `HostValue` and `&HostValue` also participate in `IntoValue` / `FromValue`, and `is::()` is available for type tests at the Rust boundary. Host methods accept ordinary value arguments only; `ref` arguments are rejected. A method may mutate its payload through interior mutability, a host registry, or another external resource. Those effects are host effects, not Nybl `ref` transactions: a later runtime error does not roll them back. Opaque payload allocation and nesting are outside `NyblLimits::max_memory` and the Nybl value-depth calculation. The host is responsible for bounding those resources. The payload remains alive until the last cloned handle is dropped. ### Typed `Value` conversions Use `IntoValue` and `Value::to_rust` at the host boundary instead of manually matching every nested array or dictionary. Extraction borrows the input, so targets such as `&str` do not copy; owned targets such as `String`, `Vec`, `Option`, `Result`, and `BTreeMap` are supported too. ```rust use nybl::{NyblError, NyblHost, IntoValue, Value}; struct MathHost; impl NyblHost for MathHost { fn call( &mut self, name: &str, args: &[Value], line: u32, ) -> Option> { if name != "sum_values" { return None; } Some((|| { let values: Vec = args .first() .ok_or_else(|| NyblError::runtime("missing array", line))? .to_rust() .map_err(|error| NyblError::runtime(error.to_string(), line))?; values .into_iter() .sum::() .into_value() .map_err(|error| NyblError::runtime(error.to_string(), line)) })()) } } ``` Infallible scalars also implement standard `From`. Recursive values use the fallible trait because Nybl enforces a maximum safe value depth. Integer conversion is strict (`Int` is not silently accepted as `Number`, or vice versa), and nested errors include paths such as `$[0]["stats"]["hp"]`. For literals, `nybl_value!` provides JSON-like array/dict syntax and returns a `Result` for the same reason: ```rust use nybl::nybl_value; let request = nybl_value!({ "name": "Ada", "scores": [10, 20, 30], "nickname": none, })?; ``` `Result` maps to the engine's canonical built-in `Result::Ok(value)` or `Result::Err(error)`. Deterministic dictionary conversion deliberately uses `BTreeMap`; no `std::collections::HashMap` implementation is provided, keeping the API available under `no_std` and its output order stable. ### `on_print` — Capturing output Override `on_print` to redirect `print()` output to a buffer, log, UI widget — anywhere that isn't stdout: ```rust struct Buffered { output: Vec } impl NyblHost for Buffered { fn call(&mut self, _: &str, _: &[Value], _: u32) -> Option> { None } fn on_print(&mut self, msg: &str) { self.output.push(msg.into()); } } ``` ### `resolve_module` — Custom `use` resolution Supply module source for `use path.to.module` statements. Return: - `Some(Ok(source))` — the module's source text (Nybl parses and executes it). - `Some(Err(err))` — resolver error; propagated to the user. - `None` — "not my module"; Nybl raises "module `foo` not found". ```rust impl NyblHost for MyHost { fn resolve_module(&mut self, name: &str) -> Option> { match name { "greetings" => Some(Ok(r#" fn hello(who) { return "hi " + who } "#.into())), _ => None, } } // ... call, etc. } ``` `nybl::host::resolve_from_map` and `nybl::host::StringModuleHost` (below) are ready-made helpers that cover the common "in-memory module table" pattern. ### `on_tick` — Execution control Called on every tick — fn entry, loop iteration, most statements. Use it for: - **Timeouts** — check elapsed time and halt. - **Cancellation** — read a `&AtomicBool` set by another thread. - **Progress tracking** — increment a counter or refresh a progress bar. ```rust use std::time::{Duration, Instant}; struct Timed { start: Instant, budget: Duration } impl NyblHost for Timed { fn call(&mut self, _: &str, _: &[Value], _: u32) -> Option> { None } fn on_tick(&mut self) -> Result<(), NyblError> { if self.start.elapsed() > self.budget { Err(NyblError::runtime("execution timed out", 0)) } else { Ok(()) } } } ``` `on_tick` errors count as runtime errors — they can be caught by `try_call` inside Nybl. Use `NyblError::fatal` instead if you need the halt to be uncatchable: ```rust fn on_tick(&mut self) -> Result<(), NyblError> { if cancel_flag.load(Ordering::Relaxed) { Err(NyblError::fatal("cancelled", 0)) // `try_call` won't swallow this } else { Ok(()) } } ``` ## Resource limits `NyblLimits` controls how much work a program can do before it's killed with a fatal error: ```rust pub struct NyblLimits { pub max_steps: u64, // tick budget pub max_memory: usize, // bytes for strings + arrays + structs pub disabled_builtins: BTreeSet, // engine builtins the host forbids } ``` Two presets: | Preset | `max_steps` | `max_memory` | |--------|-------------|--------------| | `NyblLimits::standard()` | 10,000 | 10 MB | | `NyblLimits::demo()` | 1,000 | 1 MB | Custom: ```rust let limits = NyblLimits { max_steps: 50_000, max_memory: 32 * 1024 * 1024, ..NyblLimits::standard() }; ``` Limit violations are **fatal** — `try_call` in user code can't swallow them. ## Disabling builtins `NyblLimits::disabled_builtins` lets the host forbid specific engine builtins (`range`, `rand`, `print`, `try_call`, `panic`) for a program: ```rust let limits = NyblLimits::standard().with_disabled_builtins(["rand"]); ``` The motivating case is a deterministic simulation or game engine: replays and lockstep networking need every piece of randomness to flow through the engine's own seeded RNG, and Nybl's builtin `rand` keeps instance-local state outside that seeding scheme. Disabling `rand` and exposing a host function (or module) with the engine's RNG makes the divergence impossible instead of merely discouraged. A disabled builtin is treated as a programming error, not a resource kill — but with the same **fatal** severity: - A definite reference fails at **load time** (or transpile time for the AOT engine), before any program statement runs: ``builtin `rand` is disabled by the host``. - A reference the load-time pass can't prove — the program contains a binding that might shadow the name, such as `let rand = ...`, a parameter named `rand`, or a glob `use` whose exports are unknown statically — fails with the same fatal error at the moment it would actually invoke the builtin. `try_call` cannot catch it in either form. - Imported modules are checked the same way when they load at their `use` site (at transpile time for AOT, which resolves modules eagerly). Shadowing note: a value binding of the same name (`let rand = my_rng`) is not a violation — every engine dispatches the binding, so the builtin is unreachable and scripts can keep the ergonomic name. A `fn rand(...)` *declaration*, by contrast, is still rejected: builtins take priority over function declarations for direct calls, so the builtin would still be reachable. Unknown names in the set are allowed and simply never match, so a deny list written for a newer engine stays compatible with older ones. ## Ready-made host helpers `nybl::host` bundles the two most common host shapes so embedders don't have to hand-roll them. ### `nybl::host::resolve_from_map(entries)` Build a `resolve_module`-compatible closure from any iterable of `(name, source)` pairs. Drop it inside your own `NyblHost` impl: ```rust use nybl::host::resolve_from_map; struct MyHost { resolve: Box Option>> } impl MyHost { fn new() -> Self { let resolve = resolve_from_map([ ("greetings", "fn hello() { return \"hi\" }"), ("math_ext", "fn sq(n) { return n * n }"), ]); Self { resolve: Box::new(resolve) } } } impl NyblHost for MyHost { // ... fn resolve_module(&mut self, name: &str) -> Option> { (self.resolve)(name) } } ``` ### `nybl::host::StringModuleHost` A minimal full `NyblHost` implementation — captures prints to an in-memory vec and resolves modules from a string map. Useful for tests and playgrounds: ```rust use nybl::host::StringModuleHost; use nybl::NyblLimits; let mut host = StringModuleHost::new([ ("greetings", "fn hello(who) { return \"hi \" + who }"), ]); nybl::run( r#"use greetings print(hello("Nybl"))"#, &mut host, &NyblLimits::standard(), ).unwrap(); assert_eq!(host.output(), "hi Nybl"); ``` ## Stateful REPL sessions `nybl::ReplSession` carries `let` bindings, `fn` declarations, user types, methods, module aliases, and the import cache across `eval` calls. Use it when you want "one Nybl interpreter, many user inputs" — interactive REPLs, notebook cells, per-request scripting. ```rust use nybl::{NyblLimits, ReplSession}; use nybl_sys::StandardHost; let mut session = ReplSession::new(); let mut host = StandardHost::new(); session.eval("let x = 5", &mut host, &NyblLimits::standard()).unwrap(); session.eval("let y = x + 3", &mut host, &NyblLimits::standard()).unwrap(); // `eval` returns `Ok(Some(v))` when the last statement is a bare // expression; `Ok(None)` for `let` / `fn` / `use` / etc. let r = session.eval("y * 2", &mut host, &NyblLimits::standard()).unwrap(); assert!(matches!(r, Some(nybl::Value::Int(16)))); // Introspection. assert!(session.get("x").is_some()); assert_eq!(session.binding_names(), vec!["x".to_string(), "y".to_string()]); ``` Each `eval` still respects the `NyblLimits` you pass — useful if you want to allow a higher step budget per cell than for a batch-run program. The built-in `nybl` CLI's `repl` subcommand is the canonical consumer: it adds rustyline, multi-line input, `:help` / `:vars` / `:reset` / `:quit` meta-commands, tab completion, and a persistent history file. See [REPL](https://nybl-lang.com/docs/repl/index.html.md) for the user-facing view. `ReplSession` accepts and evaluates new source over time. If the source is loaded once and the host should call an explicit, stable set of functions, prefer [`NyblInstance`](https://nybl-lang.com/docs/embedding/instances/index.html.md). ## Error rendering `NyblError::render(source)` produces a terminal-friendly error with a source snippet and a `^` caret under the offending column (when the error carries column info). Parse errors always have columns; runtime errors do when the failing expression was parsed from source. Errors raised while loading an imported module carry a `source_context`. `render` automatically uses that module's source and labels the location as `in module \`path\``, so callers should continue passing the root source exactly as shown below. If an embedder attaches only a module identity with `NyblError::with_module`, rendering deliberately omits the snippet rather than showing an unrelated root line. `NyblError::with_module_source` attaches both identity and source; nested loaders preserve the deepest existing context. ```rust match nybl::run(src, &mut host, &NyblLimits::standard()) { Ok(()) => {} Err(e) => eprintln!("{}", e.render(src)), } ``` Typical output: ``` error: Variable `undefined` not found --> line 2:7 | 2 | print(undefined) | ^ hint: Did you forget to create it with `let`? ``` ## Putting it all together A complete host that provides domain-specific functions, captures output, resolves modules from memory, and enforces a timeout: ```rust use nybl::{NyblError, NyblHost, NyblLimits, Value}; use nybl::host::resolve_from_map; use std::time::{Duration, Instant}; struct AppHost { output: Vec, start: Instant, data: Vec, resolve: Box Option>>, } impl AppHost { fn new() -> Self { let resolve = resolve_from_map([ ("stats_helpers", r#" fn median(xs) { let sorted = xs sorted.sort() let mid = (sorted.len() / 2).to_int() return sorted[mid] } "#), ]); Self { output: vec![], start: Instant::now(), data: vec![], resolve: Box::new(resolve), } } } impl NyblHost for AppHost { fn call( &mut self, name: &str, args: &[Value], line: u32, ) -> Option> { match name { "add_data" => match args { [Value::Int(n)] => { self.data.push(*n as f64); Some(Ok(Value::None)) } [Value::Number(n)] => { self.data.push(*n); Some(Ok(Value::None)) } _ => Some(Err(NyblError::runtime("add_data(n) expects a number", line))), }, "average" => { if self.data.is_empty() { Some(Ok(Value::Number(0.0))) } else { let sum: f64 = self.data.iter().sum(); Some(Ok(Value::Number(sum / self.data.len() as f64))) } } _ => None, } } fn on_print(&mut self, message: &str) { self.output.push(message.to_string()); } fn on_tick(&mut self) -> Result<(), NyblError> { if self.start.elapsed() > Duration::from_secs(5) { Err(NyblError::fatal("timed out", 0)) } else { Ok(()) } } fn function_hint(&self) -> &str { "Custom host: add_data(n), average()" } fn resolve_module(&mut self, name: &str) -> Option> { (self.resolve)(name) } } fn main() { let source = r#" use stats_helpers for n in [10, 20, 30, 40, 50] { add_data(n) } let avg = average() let mid = median([10, 20, 30, 40, 50]) print("Average: " + avg.to_str() + ", median: " + mid.to_str()) "#; let mut host = AppHost::new(); match nybl::run(source, &mut host, &NyblLimits::standard()) { Ok(()) => { for line in &host.output { println!("{}", line); } } Err(e) => eprintln!("{}", e.render(source)), } } ``` --- Source: https://nybl-lang.com/docs/embedding/instances/ # Stateful instances `NyblInstance` loads a program once and lets the host call its public entry points repeatedly. It is the plugin-style counterpart to the one-shot `nybl::run` and `nybl_vm::run` functions. The instance retains the state produced while loading and by later calls: - root and imported-module bindings; - functions and returned callbacks; - type and method declarations; - module aliases and the import cache; - the random-number generator state. The tree-walker and VM expose the same API. Sandboxed AOT output generates an equivalent API with the source already compiled into it. ## Declaring host entry points Mark a direct root function with `pub` to include it in the instance ABI: ```nybl let count = 0 pub fn increment(by) { count += by return count } pub fn make_reader() { return fn() { return count } } fn private_helper() { return count } ``` `pub fn` is only valid at the direct program root. It does not make a function globally visible to ordinary Nybl code, and `pub` declarations inside imported modules are not root instance entries. It only opts the final executed root declaration into the host-callable ABI. Loading executes top-level code before the entry list is collected. Therefore: - a declaration after a top-level `return` is not an entry; - redeclaring a public name replaces its earlier ABI position and arity; - a later private `fn` with the same name removes it from the ABI; - `entry_points()` reports the final surviving entries in declaration order. Ordinary Nybl calls continue to use normal lexical name lookup. Host `NyblInstance::call` uses the dedicated public-entry table, so assigning another value to an ordinary name cannot redirect the host ABI. Instance calls accept owned `Value` arguments and therefore cannot identify a mutable Nybl binding for a [`ref` parameter](https://nybl-lang.com/docs/functions/reference-parameters/index.html.md). `call` and `call_value` reject ref-bearing functions before execution. Keep host-facing entries value-only and put ref-based mutation behind an ordinary Nybl wrapper when needed. A public entry may end in a value-only `..rest` parameter. For those entries, `EntryPoint::arity()` is the minimum fixed argument count, `is_variadic()` is true, `max_arity()` is `None`, and `accepts_arity(count)` performs the complete check. ## Tree-walker instance ```rust use nybl::{NyblError, NyblHost, NyblInstance, NyblLimits, Value}; struct Host; impl NyblHost for Host { fn call(&mut self, _: &str, _: &[Value], _: u32) -> Option> { None } } fn main() -> Result<(), NyblError> { let source = r#" let count = 0 pub fn increment(by) { count += by return count } pub fn make_reader() { return fn() { return count } } "#; let mut host = Host; let limits = NyblLimits::standard(); let mut instance = NyblInstance::load(source, &mut host, &limits)?; for entry in instance.entry_points() { println!("{}/{}", entry.name(), entry.arity()); } let first = instance.call("increment", &[Value::Int(2)], &mut host)?; assert_eq!(first.inspect(), "2"); let reader = instance.call("make_reader", &[], &mut host)?; instance.call("increment", &[Value::Int(3)], &mut host)?; let current = instance.call_value(&reader, &[], &mut host)?; assert_eq!(current.inspect(), "5"); Ok(()) } ``` `call` validates the public name and arity. `call_value` accepts a function value created by that exact instance, including a callback returned by another call. ## Bytecode VM instance The VM is a drop-in replacement at this API boundary: ```rust use nybl::{NyblError, NyblHost, NyblLimits, Value}; use nybl_vm::NyblInstance; struct Host; impl NyblHost for Host { fn call(&mut self, _: &str, _: &[Value], _: u32) -> Option> { None } } fn main() -> Result<(), NyblError> { let mut host = Host; let mut instance = NyblInstance::load( "let total = 0\npub fn add(n) { total += n; return total }", &mut host, &NyblLimits::standard(), )?; assert_eq!( instance.call("add", &[Value::Int(4)], &mut host)?.inspect(), "4", ); assert_eq!( instance.call("add", &[Value::Int(5)], &mut host)?.inspect(), "9", ); Ok(()) } ``` Use `compile` plus `execute` when you want to reuse bytecode but intentionally start with fresh program state on every execution. Use `NyblInstance` when the state itself must persist. ## Prepared and batched dispatch The walker and VM can resolve a hot public entry once and reuse the opaque handle. The handle is bound to the exact instance that created it: ```rust let tick = instance.prepare_entry("tick")?; // Drop-in fast path for an existing per-entity loop. let value = instance.call_prepared(&tick, &[Value::Int(entity_id)], &mut host)?; // Host-side batch: one live evaluator/VM, ordered calls and results. let calls = [ [Value::Int(0)], [Value::Int(1)], [Value::Int(2)], ]; let values = instance.call_batch(&tick, &calls, &mut host)?; ``` `prepare_entry` rejects `ref`-bearing entries because host values do not name Nybl bindings. `call_prepared` and `call_batch` reject a handle created by a different instance, even if both instances loaded identical source. `call_batch` preserves repeated-`call` semantics: items run in order, each item gets a fresh step and call-depth budget, tracked memory remains persistent, and the first error stops the batch. Mutations and host effects from completed items remain visible. The host is borrowed once for the operation and is never retained. This is why prepared dispatch does not cache host-specific numeric function IDs: a later operation may deliberately supply a different compatible host. For the lowest dispatch overhead, expose a script-level batch entry and loop in Nybl. It keeps one Nybl function frame for the whole shard while retaining the same host calls: ```nybl pub fn tick_batch(entity_count) { for entity_id in range(entity_count) { // Read fields, update state, and queue commands for entity_id. } } ``` A script-level batch is one Nybl call, so `max_steps` applies to the complete batch. Size that budget for the maximum accepted shard; the batch cannot reset or evade it. Use host-side `call_batch` when every entity must instead receive the same independent per-call budget as `call`. ## Compile once, instantiate many `NyblInstance::load` parses, compiles, and executes in one step. When a host creates several instances of the same program — one per worker thread, one per game entity shard, one per tenant — split the pipeline with `CompiledScript`: ```rust use nybl::NyblLimits; use nybl_vm::{CompiledScript, NyblInstance}; // Parse + compile + validate once. No host is needed: nothing executes. let program = CompiledScript::compile(source)?; // Each instantiation runs the top-level statements once against its own // host and produces fully independent instance state. let mut a = NyblInstance::from_compiled(&program, &mut host_a, &NyblLimits::standard())?; let mut b = NyblInstance::from_compiled(&program, &mut host_b, &NyblLimits::standard())?; ``` `load` is exactly `CompiledScript::compile` followed by `NyblInstance::from_compiled`, so both paths behave identically. This basic artifact shares the root program: `from_compiled` never re-parses or re-compiles the root, and K instances execute the same root chunk graph. Modules keep their legacy lazy behavior in this mode, so each instance still asks its host to resolve, parse, and compile a module the first time execution reaches its `use` site. ### Compile the module graph too Hosts that create many instances of a module-bearing program can opt into a closed module artifact with `compile_with_modules`: ```rust use std::collections::BTreeMap; use nybl::NyblLimits; use nybl_vm::{CompiledScript, NyblInstance}; let module_sources = BTreeMap::from([ ("game.rules", "use game.math as math\nfn score(n) { return math.double(n) }"), ("game.math", "fn double(n) { return n * 2 }"), ]); let program = CompiledScript::compile_with_modules(source, |path| { module_sources.get(path).map(|source| Ok((*source).to_string())) })?; let mut instance = NyblInstance::from_compiled( &program, &mut host, &NyblLimits::standard(), )?; ``` The resolver runs at artifact-build time. Every unique module reachable from any root or transitive `use` site — including uses inside functions and lambdas — is resolved, parsed, compiled, bytecode-validated, and indexed once. Cycles and diamonds are retained in the graph; the normal per-instance import cache still provides import idempotency and reports a cycle only if execution actually enters it. This opt-in graph is complete: instances never fall back to `NyblHost::resolve_module`. Include every referenced custom or `std.*` module in the compile-time resolver. Module source is retained in the immutable artifact only so runtime errors can still render the correct module snippet. Compiled chunks and source are `Arc`-shared; module globals, imports, RNG, callable identity, memory accounting, and all other mutable state remain fresh per instance. `CompiledScript` is immutable, cheap to clone, and `Send + Sync`. Instances are deliberately not `Send`: their runtime state is reference-counted per thread for hot-path performance. The supported cross-thread pattern is therefore *create-on-worker* — clone the artifact into each worker and instantiate there: ```rust use nybl::NyblLimits; use nybl_vm::{CompiledScript, NyblInstance}; let program = CompiledScript::compile(source)?; let workers: Vec<_> = (0..4) .map(|_| { let program = program.clone(); // refcount bump, not a recompile std::thread::spawn(move || { let mut host = WorkerHost::new(); let mut instance = NyblInstance::from_compiled( &program, &mut host, &NyblLimits::standard(), ).expect("instantiate"); // Dispatch this worker's entities against its own instance. run_shard(&mut instance, &mut host) }) }) .collect(); ``` This is the sharded game-engine shape: N workers each own an instance built from one shared artifact and dispatch per-entity callbacks in parallel. Determinism is unchanged — instances from one artifact given identical call sequences produce byte-identical results, including RNG use, because all per-instance state (globals, RNG seed, imports, memory accounting) starts fresh at `from_compiled` exactly as it does at `load`. Three details to keep in mind: - **Per-instance rules still apply.** Re-entry guards and callback affinity are per *instance*, not per artifact: a callback created by one instance is rejected by its siblings even though they share compiled code. - **Choose module policy explicitly.** `compile` shares only the root and retains lazy per-instance host resolution. `compile_with_modules` shares a complete precompiled module graph and performs no runtime source resolution. - **Module execution is still lazy.** Precompiling source and bytecode does not run module top-level statements. Each instance executes a module once when its first live `use` site is reached and caches only that instance's exports. [Resource limits](https://nybl-lang.com/docs/embedding/index.html.md#resource-limits) stay per-instance too, and the builtin deny list (`NyblLimits::disabled_builtins`) is enforced separately for every instance: root usage is checked at `from_compiled`, and precompiled module usage is checked when that instance executes the module's first `use`. One unrestricted artifact can therefore serve hosts with different deny sets. ## Sandboxed AOT instances The AOT transpiler emits a persistent `NyblInstance` only when `Options::sandbox` is enabled. Generate library-shaped Rust and compile it into the host application: ```rust use nybl_compile::{Options, transpile}; let generated = transpile( "let count = 0\npub fn next() { count += 1; return count }", &Options { emit_main: false, use_nybl_sys: false, sandbox: true, ..Options::default() }, )?; ``` The generated module provides: ```rust,ignore let mut instance = NyblInstance::load(&mut host, &limits)?; let entries = instance.entry_points(); let value = instance.call("next", &[], &mut host)?; let value = instance.call_value(&callback, &[], &mut host)?; ``` Because the Nybl source is already compiled into the generated Rust, `NyblInstance::load` takes only `host` and `limits`, not a source string. Unsandboxed output remains a one-shot `run` API and does not emit the persistent instance surface. Generated code also contains hygienically named convenience wrappers for potential direct-root public declarations. They delegate to `call`, so the runtime entry table remains authoritative when top-level control flow skips or replaces a declaration. ## Hosts and re-entry An instance borrows a `NyblHost` only for `load` or one call; it never stores the host. Later operations may use a different compatible host. This also keeps host-owned allocations outside the instance's memory account. Opaque `HostValue` handles may be stored in globals and survive across calls. The compatible host supplied for the current operation dispatches their methods; the instance does not retain the host that originally created them. Their payload allocation is host-owned and untracked, and any external mutation performed by a host method remains visible even if the enclosing Nybl call later fails. The same instance cannot be re-entered while one of its operations is active. For example, a host function called by instance A must not recursively call A. It may call a different instance B, and B keeps independent state, limits, and memory accounting. Function values have instance affinity. Pass a callback back only to the instance that created it; `call_value`, public entries, and callback-taking builtins reject functions from another walker, VM, or generated AOT instance. ## Limits and failed calls `load` and every later operation enforce the limits captured at load time: - the step counter and fixed call-depth guard start fresh for each operation; - tracked memory belongs to the instance and remains accounted across calls; - returned values continue to charge the originating instance while they keep instance-owned storage alive. A step or call-depth failure unwinds transient call frames and leaves the instance callable again. Calls are not transactions: mutations completed before an ordinary or fatal error remain visible to later calls. Memory exhaustion is different because the retained state may itself still be over budget. The instance continues returning a fatal memory error until enough charged values are released. If an over-budget value was stored in a persistent global, the instance can remain unusable. ## Instances versus REPL sessions Use [`ReplSession`](https://nybl-lang.com/docs/embedding/index.html.md#stateful-repl-sessions) when each interaction introduces more source, as in a REPL or notebook. Use `NyblInstance` when the program is loaded once and exposes a deliberate host ABI through `pub fn`. --- Source: https://nybl-lang.com/docs/embedding/wasm/ # WebAssembly Nybl runs on wasm32 in production embeddings (browser clients and edge runtimes). This page collects the supported configuration in one place: which crates build for wasm, which features to pick, and the handful of host-side caveats. ## What builds for wasm `nybl-lang` (the tree-walker) and `nybl-vm` (the bytecode VM) both build clean for `wasm32-unknown-unknown`, in **both** feature configurations: - **Default features** (`std` + `nybl-std`) — works because Rust's `std` compiles for wasm32. Simplest option when you are using a bundler-style toolchain (wasm-bindgen, wasm-pack) that expects `std`. - **`no_std`** — disable default features and opt in explicitly. This is the configuration for bare-metal-style wasm modules and for embeddings that want a deterministic pure-Rust math backend (see below): ```toml [dependencies] nybl-lang = { version = "0.4", default-features = false, features = ["no_std", "nybl-std"] } nybl-vm = { version = "0.4", default-features = false, features = ["no_std", "nybl-std"] } ``` Keep `nybl-std` if you want the bundled Nybl stdlib (`use std.math`, `std.json`, …) to resolve. If Cargo ever unifies `std` and `no_std` in one build graph, `std` wins — a genuine no_std build must disable default features. `nybl-compile`'s generated sandbox runtime follows the same feature split. `nybl-cli` is a native application. `nybl-sys` compiles for wasm, but remains an OS-oriented host rather than the recommended browser/freestanding host; its unsupported clock behavior is described below. ## Allocator On `wasm32-unknown-unknown` without `std`'s default machinery you provide the global allocator yourself. The pattern used by the shipped embeddings is [`lol_alloc`](https://crates.io/crates/lol_alloc), a tiny single-threaded wasm allocator: ```rust #[cfg(target_arch = "wasm32")] #[global_allocator] static ALLOCATOR: lol_alloc::AssumeSingleThreaded = unsafe { lol_alloc::AssumeSingleThreaded::new(lol_alloc::FreeListAllocator::new()) }; ``` Walker + VM + libm + `lol_alloc` ships at roughly **355 KB stripped**. With default features (`std`), Rust's ordinary wasm allocator is used and no setup is needed. ## Math backend and float determinism Nybl's `f64` builtins (`sqrt`, `sin`, `cos`, `tan`, `exp`, `log`, `pow`, …) go through a single math facade: - With `std` (default), they call the platform's native `f64` methods. - With `no_std`, they call the pure-Rust [`libm`](https://crates.io/crates/libm) crate. Arithmetic (`+ - * / %`), `sqrt`, and the rounding builtins are exact IEEE 754 operations and bit-identical everywhere. The transcendentals (`sin`, `cos`, `tan`, `exp`, `log`, `pow`) are **not** guaranteed bit-identical across platform math libraries — e.g. macOS's system libm returns `1.tan()` one ULP away from wasi-libc's result. If your embedding needs bit-identical script output across native and wasm builds (lockstep simulation, replay verification), build **both** sides with `no_std` so every platform uses the same pure-Rust `libm` code. CI executes the same curated parity corpus twice: once with the default configuration and once with both engines linked using `default-features = false, features = ["no_std", "nybl-std"]`. The second comparison is the lockstep configuration described above — see [CI enforcement](#ci-enforcement) below. ## Timing: `Instant`, `SystemTime`, and `web-time` `std::time::Instant::now()` and `SystemTime::now()` compile on `wasm32-unknown-unknown` but **panic at runtime** — the target has no clock. Two places this bites: - **Host timeout patterns.** The `Instant`-based timeout host from [Embedding Nybl](https://nybl-lang.com/docs/embedding/index.html.md#the-nyblhost-trait) needs a wasm-aware clock. The standard fix is the [`web-time`](https://crates.io/crates/web-time) crate — a drop-in `Instant`/`SystemTime` replacement that uses `performance.now()` on wasm32 with browser bindings and re-exports `std::time` everywhere else. Swap the import and the rest of the host code is unchanged: ```rust use web_time::Instant; // instead of std::time::Instant ``` On WASI targets (`wasm32-wasip1`), `std::time` works natively and no replacement is needed. - **`nybl-sys`.** `StdHost` uses `SystemTime::now()` on native and WASI targets. On `wasm32-unknown-unknown`, `unix_time()` and `unix_time_ms()` instead return a Nybl runtime error explaining that the system clock is unavailable and recommending a custom `NyblHost`; they do not panic or trap. Browser and other freestanding wasm embeddings should provide time through a host function backed by `web-time`, JavaScript, or a caller-supplied deterministic tick. `nybl-vm` does not depend on `nybl-sys`, so this does not constrain the VM. ## CI enforcement Two CI jobs keep the wasm surface from regressing: - **Compile:** `cargo check` runs for `nybl-lang` and `nybl-vm` on `wasm32-unknown-unknown` in both the default and `no_std` configurations. `nybl-sys` is also compile-checked there with its default features. - **Execution:** a small `wasm32-unknown-unknown` module is run under wasmtime to prove `nybl-sys` reports its unsupported clock as a normal Nybl error. The `wasm_parity` runner (`tests/wasm-parity`) then runs a curated corpus of Nybl programs — float arithmetic and formatting, transcendental builtins, rounding at the i64 boundary, negative division/modulo, string interpolation, dict/array ordering, the deterministic `rand` sequence, error messages, and composite programs — through **both** engines, natively and under [wasmtime](https://wasmtime.dev/) on `wasm32-wasip1`, and byte-compares the transcripts in **both** feature configurations: default `std` (platform math) and defaults-off `no_std` (pure-Rust `libm`), with `nybl-std` enabled in both so the corpus can use bundled modules. Any native/wasm difference fails CI. An opt-in negative control perturbs one transcendental Nybl input on wasm: CI also verifies that the same comparison rejects the resulting math-output divergence. Parity executes on `wasm32-wasip1` because the std runner needs stdout; the engine libraries themselves are separately compile-checked for `wasm32-unknown-unknown`.