diff --git a/Cargo.lock b/Cargo.lock index 16dc8a8fe..ead2d91ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4797,6 +4797,7 @@ version = "0.3.0" dependencies = [ "criterion", "mlua", + "runvil-markdown", "runvil-model-client", "runvil-types", "runvil-vfs", @@ -4804,6 +4805,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "runvil-markdown" +version = "0.3.0" +dependencies = [ + "pulldown-cmark", + "serde_json", + "serde_yaml_ng", +] + [[package]] name = "runvil-model-client" version = "0.3.0" @@ -4855,6 +4865,7 @@ dependencies = [ "mlua", "regex", "runvil-lua", + "runvil-markdown", "runvil-model-client", "runvil-prompt", "runvil-types", diff --git a/Cargo.toml b/Cargo.toml index bcc0710bb..9c45ecafb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/*", "crates/runvil-internal/types", "crates/runvil-internal/runtime", "crates/runvil-internal/lua", "crates/runvil-internal/prompt", "crates/runvil-internal/vfs", "crates/runvil-internal/model-client", "crates/workshop/desktop", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/gateway-discovery", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/workshop/run-log", "crates/workshop/agents", "crates/workshop/plugin-std", "crates/harness-internal/runner", "crates/harness-internal/plugin-loader", "crates/harness-internal/test-plugin"] +members = ["crates/*", "crates/runvil-internal/types", "crates/runvil-internal/runtime", "crates/runvil-internal/lua", "crates/runvil-internal/markdown", "crates/runvil-internal/prompt", "crates/runvil-internal/vfs", "crates/runvil-internal/model-client", "crates/workshop/desktop", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/gateway-discovery", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/workshop/run-log", "crates/workshop/agents", "crates/workshop/plugin-std", "crates/harness-internal/runner", "crates/harness-internal/plugin-loader", "crates/harness-internal/test-plugin"] # crates/runvil-internal, crates/workshop, and crates/harness-internal # are manifestless containers holding their families' private crates. # Cargo prunes excluded subtrees from member globs, so a "crates/workshop/*" @@ -49,6 +49,7 @@ harness-plugin-loader = { path = "crates/harness-internal/plugin-loader" } plugin-web = { path = "crates/plugin-web" } plugin-mcp = { path = "crates/plugin-mcp" } runvil-lua = { path = "crates/runvil-internal/lua" } +runvil-markdown = { path = "crates/runvil-internal/markdown" } runvil-model-client = { path = "crates/runvil-internal/model-client" } runvil-prompt = { path = "crates/runvil-internal/prompt" } runvil-vfs = { path = "crates/runvil-internal/vfs" } diff --git a/crates/build-xtask/src/engine_clippy.rs b/crates/build-xtask/src/engine_clippy.rs index 053409cd6..19ba7ee53 100644 --- a/crates/build-xtask/src/engine_clippy.rs +++ b/crates/build-xtask/src/engine_clippy.rs @@ -4,7 +4,7 @@ //! //! Clippy reads the nearest `.clippy.toml` or `clippy.toml` walking up from //! a crate's manifest directory and never merges it with the root file. So -//! `crates/runvil-internal/clippy.toml` binds the six internal crates, +//! `crates/runvil-internal/clippy.toml` binds the seven internal crates, //! `crates/runvil-engine/clippy.toml` and //! `crates/runvil-plugin/clippy.toml` bind the two root crates, and each //! repeats the root file's settings and diff --git a/crates/harness-internal/runner/tests/it/effect_loop-cap.rs b/crates/harness-internal/runner/tests/it/effect_loop-cap.rs index 11f567e83..e304a928c 100644 --- a/crates/harness-internal/runner/tests/it/effect_loop-cap.rs +++ b/crates/harness-internal/runner/tests/it/effect_loop-cap.rs @@ -47,7 +47,7 @@ const PREAMBLE: &str = "---\nname: runner-cap\ndescription: caps tool calls\nrun /// Main spawns Child and waits on a `wait` round while Child's loop asks /// its round, then cancels Child and lingers on a second round. const MAIN_CANCELS: &str = "## Main\n\n```lua\n\ - local t = tasks.spawn('## Child')\n\ + local t = tasks.spawn('../Child')\n\ models.infer('wait')\n\ tasks.cancel(t)\n\ models.infer('linger')\n\ @@ -55,8 +55,8 @@ const MAIN_CANCELS: &str = "## Main\n\n```lua\n\ /// Main spawns A, then B, and joins both. const MAIN_JOINS: &str = "## Main\n\n```lua\n\ - local a = tasks.spawn('## A')\n\ - local b = tasks.spawn('## B')\n\ + local a = tasks.spawn('../A')\n\ + local b = tasks.spawn('../B')\n\ tasks.join({ a, b })\n\ return 'joined'\n```\n\n"; diff --git a/crates/harness-internal/runner/tests/it/effect_loop-vfs.rs b/crates/harness-internal/runner/tests/it/effect_loop-vfs.rs index 19c6fabee..2979dce25 100644 --- a/crates/harness-internal/runner/tests/it/effect_loop-vfs.rs +++ b/crates/harness-internal/runner/tests/it/effect_loop-vfs.rs @@ -135,7 +135,7 @@ async fn open_gate_after_the_second_write( } /// A main section that writes twice while its child's tool call is out. -const WRITES_BESIDE_A_PARKED_CALL: &str = "local t = tasks.spawn('## Child')\n\ +const WRITES_BESIDE_A_PARKED_CALL: &str = "local t = tasks.spawn('../Child')\n\ store.write('first.md', '1')\n\ store.write('second.md', '2')\n\ local _task, _ok, result = tasks.join_any({ t })\n\ @@ -222,8 +222,8 @@ async fn a_backend_that_panics_drops_its_effect_instead_of_unwinding_the_run() { const WITHDRAWS_A_WRITE: [(&str, &str); 3] = [ ( "Main", - "local slow = tasks.spawn('## Slow')\n\ - local fast = tasks.spawn('## Fast')\n\ + "local slow = tasks.spawn('../Slow')\n\ + local fast = tasks.spawn('../Fast')\n\ local _task, _ok, result = tasks.join_any({ fast })\n\ tasks.cancel(slow)\n\ return result", diff --git a/crates/harness-internal/runner/tests/it/harness-inbox.rs b/crates/harness-internal/runner/tests/it/harness-inbox.rs index b0b6d33a7..50f021474 100644 --- a/crates/harness-internal/runner/tests/it/harness-inbox.rs +++ b/crates/harness-internal/runner/tests/it/harness-inbox.rs @@ -88,7 +88,7 @@ const LOOPS_THEN_RECEIVES_TWICE: &str = "---\nname: loops-receives-twice\ndescri const STEERS_A_CHILD: &str = "---\nname: steers-child\ndescription: d\nrunvil: 0\n---\n\n\ # SteersChild\n\n\ ## Main\n\n```lua\n\ - local child = tasks.spawn('## Child')\n\ + local child = tasks.spawn('../Child')\n\ tasks.send(child, (tasks.receive()))\n\ local _t, _ok, report = tasks.join_any({ child })\n\ return report\n```\n\n\ @@ -105,7 +105,7 @@ const STEERS_A_CHILD_THEN_LOOPS: &str = "---\nname: steers-child-loops\ndescript runvil: 0\n---\n\n\ # SteersChildLoops\n\n\ ## Main\n\n```lua\n\ - local child = tasks.spawn('## Child')\n\ + local child = tasks.spawn('../Child')\n\ tasks.send(child, (tasks.receive()))\n\ messages:user('mine')\n\ models.loop()\n\ diff --git a/crates/harness-internal/runner/tests/it/harness-stop-cap.rs b/crates/harness-internal/runner/tests/it/harness-stop-cap.rs index 161a19b98..43e3be9e1 100644 --- a/crates/harness-internal/runner/tests/it/harness-stop-cap.rs +++ b/crates/harness-internal/runner/tests/it/harness-stop-cap.rs @@ -79,7 +79,7 @@ const CANCELS_A_HOLDER: &str = "---\nname: cancels\ndescription: d\nrunvil: 0\n\ uses:\n - tests/harness\n---\n\n\ # Cancels\n\n\ ## Main\n\n```lua\n\ - local t = tasks.spawn('## Child')\n\ + local t = tasks.spawn('../Child')\n\ models.infer('wait')\n\ tasks.cancel(t)\n\ models.infer('after')\n\ diff --git a/crates/harness-internal/runner/tests/it/harness-stop-timing.rs b/crates/harness-internal/runner/tests/it/harness-stop-timing.rs index 3869ae831..e27a91429 100644 --- a/crates/harness-internal/runner/tests/it/harness-stop-timing.rs +++ b/crates/harness-internal/runner/tests/it/harness-stop-timing.rs @@ -26,7 +26,7 @@ const STOPS_TIMED_JOIN: &str = "---\nname: timed\ndescription: d\nrunvil: 0\n\ ---\n\n\ # Timed\n\n\ ## Main\n\n```lua\n\ - local child = tasks.spawn('## Child')\n\ + local child = tasks.spawn('../Child')\n\ local ok, err = pcall(tasks.join_any, { child }, { timeout = 30 })\n\ local after = models.infer('after')\n\ tasks.send(child, 'kept')\n\ @@ -41,7 +41,7 @@ const STOPS_BEFORE_A_STEP: &str = "---\nname: before-step\ndescription: d\nrunvi ---\n\n\ # BeforeStep\n\n\ ## Main\n\n```lua\n\ - local child = tasks.spawn('## Child')\n\ + local child = tasks.spawn('../Child')\n\ store.write('note.md', 'kept')\n\ local _t, _ok, report = tasks.join_any({ child })\n\ return report\n```\n\n\ diff --git a/crates/harness-internal/runner/tests/it/harness-stop.rs b/crates/harness-internal/runner/tests/it/harness-stop.rs index e9b256353..2e6ec3c25 100644 --- a/crates/harness-internal/runner/tests/it/harness-stop.rs +++ b/crates/harness-internal/runner/tests/it/harness-stop.rs @@ -34,8 +34,8 @@ const STOPS_ALL: &str = "---\nname: stops\ndescription: d\nrunvil: 0\n\ uses:\n - tests/harness\n---\n\n\ # Stops\n\n\ ## Main\n\n```lua\n\ - local chat = tasks.spawn('## Chat')\n\ - local tool = tasks.spawn('## Tool')\n\ + local chat = tasks.spawn('../Chat')\n\ + local tool = tasks.spawn('../Tool')\n\ local ok, err = pcall(tasks.join_any, { chat, tool }, { timeout = 30 })\n\ local results = tasks.join({ chat, tool })\n\ return tostring(ok) .. ':' .. err.kind .. '|' .. results[1].result .. '|' .. results[2].result\n```\n\n\ diff --git a/crates/harness-internal/runner/tests/it/performers.rs b/crates/harness-internal/runner/tests/it/performers.rs index 692b80f8e..0b7f105f6 100644 --- a/crates/harness-internal/runner/tests/it/performers.rs +++ b/crates/harness-internal/runner/tests/it/performers.rs @@ -139,7 +139,7 @@ async fn a_timer_effect_is_answered_after_its_duration() { // The first wait times out at 50ms while the child is still parked on // its 400ms tool call; the second wait, without a timer, delivers it. - let main = "local t = tasks.spawn('## Child')\n\ + let main = "local t = tasks.spawn('../Child')\n\ local first = tasks.join_any({ t }, { timeout = 0.05 })\n\ local _task, ok, result = tasks.join_any({ t })\n\ return tostring(first == nil) .. '|' .. tostring(ok) .. '|' .. result"; diff --git a/crates/harness-internal/runner/tests/it/render-calls.rs b/crates/harness-internal/runner/tests/it/render-calls.rs index a50e245ed..8a5a0d2f0 100644 --- a/crates/harness-internal/runner/tests/it/render-calls.rs +++ b/crates/harness-internal/runner/tests/it/render-calls.rs @@ -112,7 +112,7 @@ async fn call_dynamic_tool_runs_a_model_only_tool_the_render_put_in_the_dynamic_ const ON_TWO_MODELS: [(&str, &str); 3] = [ ( "Main", - "local on_m = tasks.spawn('## OnM')\nlocal on_n = tasks.spawn('## OnN')\n\ + "local on_m = tasks.spawn('../OnM')\nlocal on_n = tasks.spawn('../OnN')\n\ local ended = tasks.join({ on_m, on_n })\n\ return ended[1].result .. '|' .. ended[2].result", ), diff --git a/crates/harness-internal/runner/tests/it/support.rs b/crates/harness-internal/runner/tests/it/support.rs index 1d86e4c42..14e5039aa 100644 --- a/crates/harness-internal/runner/tests/it/support.rs +++ b/crates/harness-internal/runner/tests/it/support.rs @@ -173,7 +173,7 @@ pub(crate) fn run_with_child_and_cancel(main: &str, child: &str, cancel: &Cancel /// A main section that parks on a 30-second timer beside its child: the /// child's own tool call and the timer are two effects out at once. -pub(crate) const TIMED_MAIN: &str = "local t = tasks.spawn('## Child')\n\ +pub(crate) const TIMED_MAIN: &str = "local t = tasks.spawn('../Child')\n\ local _first, _ok, result = tasks.join_any({ t }, { timeout = 30 })\n\ return result"; diff --git a/crates/runvil-engine/public-api.txt b/crates/runvil-engine/public-api.txt index 04ea52d7f..0bb30136d 100644 --- a/crates/runvil-engine/public-api.txt +++ b/crates/runvil-engine/public-api.txt @@ -638,7 +638,6 @@ pub runvil_engine::ErrorCode::InvalidRange = 608 pub runvil_engine::ErrorCode::InvalidValue = 106 pub runvil_engine::ErrorCode::Io = 618 pub runvil_engine::ErrorCode::IsADirectory = 604 -pub runvil_engine::ErrorCode::ListEmptyItem = 104 pub runvil_engine::ErrorCode::LuaCompile = 202 pub runvil_engine::ErrorCode::LuaQuota = 203 pub runvil_engine::ErrorCode::LuaRuntime = 201 diff --git a/crates/runvil-engine/tests/prompts/invalid/list-h3-non-list-content.md b/crates/runvil-engine/tests/prompts/invalid/list-h3-non-list-content.md deleted file mode 100644 index 6a9c9288a..000000000 --- a/crates/runvil-engine/tests/prompts/invalid/list-h3-non-list-content.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: list_h3_empty_item -description: A list H3 whose every line is a marker but one is empty is a parse error -runvil: 0 ---- - -# List H3 Empty Item - -## Parent - -Parent prose. - -### Items - -- alpha -- -- gamma diff --git a/crates/runvil-engine/tests/suite/effect.rs b/crates/runvil-engine/tests/suite/effect.rs index 025466b59..e273131bc 100644 --- a/crates/runvil-engine/tests/suite/effect.rs +++ b/crates/runvil-engine/tests/suite/effect.rs @@ -6,11 +6,11 @@ use serde_json::{Value, json}; use super::support::{Scenario, TestResult, prompt, shout, text}; const GREET: &str = " - local ask = tasks.spawn('## Ask') - local shout = tasks.spawn('## Shout') + local ask = tasks.spawn('../Ask') + local shout = tasks.spawn('../Shout') store.write('note.md', 'hello') local results = tasks.join({ ask, shout }) - tasks.join_any({ tasks.spawn('## Quick') }, { timeout = 0.01 }) + tasks.join_any({ tasks.spawn('../Quick') }, { timeout = 0.01 }) return results[1].result .. ' / ' .. results[2].result "; diff --git a/crates/runvil-engine/tests/suite/ids.rs b/crates/runvil-engine/tests/suite/ids.rs index 578935b5c..bae90a359 100644 --- a/crates/runvil-engine/tests/suite/ids.rs +++ b/crates/runvil-engine/tests/suite/ids.rs @@ -8,8 +8,8 @@ use runvil_engine::ids::{Provenance, TaskId}; use super::support::{Scenario, TestResult, prompt, text}; const GREET: &str = " - local first = tasks.spawn('## Reply', { input = 'hello' }) - local second = tasks.spawn('## Reply', { input = 'bye' }) + local first = tasks.spawn('../Reply', { input = 'hello' }) + local second = tasks.spawn('../Reply', { input = 'bye' }) local results = tasks.join({ first, second }) return results[1].result .. ' ' .. results[2].result "; diff --git a/crates/runvil-engine/tests/suite/parsing.rs b/crates/runvil-engine/tests/suite/parsing.rs index 9eadd208b..06089076f 100644 --- a/crates/runvil-engine/tests/suite/parsing.rs +++ b/crates/runvil-engine/tests/suite/parsing.rs @@ -9,7 +9,7 @@ use runvil_engine::Prompt; use super::support::TestResult; -const INVALID_FIXTURES: [(&str, ErrorCode, &str); 6] = [ +const INVALID_FIXTURES: [(&str, ErrorCode, &str); 5] = [ ("missing-h1", ErrorCode::StructureInvalid, "requires an H1"), ( "unrecognized-lua-label", @@ -31,11 +31,6 @@ const INVALID_FIXTURES: [(&str, ErrorCode, &str); 6] = [ ErrorCode::LuaCompile, "section `Transform` epilog", ), - ( - "list-h3-non-list-content", - ErrorCode::ListEmptyItem, - "empty bullet item", - ), ]; #[test] diff --git a/crates/runvil-internal/lua/Cargo.toml b/crates/runvil-internal/lua/Cargo.toml index a7fd9143e..5873cf02a 100644 --- a/crates/runvil-internal/lua/Cargo.toml +++ b/crates/runvil-internal/lua/Cargo.toml @@ -9,6 +9,7 @@ publish = false [dependencies] mlua.workspace = true +runvil-markdown.workspace = true runvil-model-client.workspace = true runvil-types.workspace = true runvil-vfs.workspace = true diff --git a/crates/runvil-internal/lua/src/__impl_coro.lua b/crates/runvil-internal/lua/src/__impl_coro.lua index d11b53363..ea49fa927 100644 --- a/crates/runvil-internal/lua/src/__impl_coro.lua +++ b/crates/runvil-internal/lua/src/__impl_coro.lua @@ -137,17 +137,25 @@ local function infer(...) return run_infer(nil, (...)) end -local function call_section(target, input) - local ok, result = yield({ - op = "call", - target = target, - input = input, - var = var_snapshot(), - }) - if not ok then fail(result) end - return result +-- `call` for the code it is installed for. `from` is nil for a section's +-- own code and a library's identity for that library's code; the request +-- carries it, and the driver resolves the target's path from there. +local function call_from(from) + return function(target, input) + local ok, result = yield({ + op = "call", + target = target, + input = input, + var = var_snapshot(), + from = from, + }) + if not ok then fail(result) end + return result + end end +local call_section = call_from(nil) + -- Runs an adhoc tool's handler inside this block coroutine, so every -- suspending call the handler makes is an ordinary yield of the chain. -- `jump` refuses while the handler runs; the raw pcall catches every @@ -335,6 +343,8 @@ tools.call = tools_call return { call = call_section, + -- The factory `require` makes a library's own `call` with. + call_from = call_from, -- The failure helpers, handed to the `tasks` and `fanout` chunks -- (`__impl_tasks.lua`, `__impl_fanout.lua`) so their shims raise the one -- error shape this chunk defines. diff --git a/crates/runvil-internal/lua/src/__impl_fanout.lua b/crates/runvil-internal/lua/src/__impl_fanout.lua index f0ec3f218..dc6b1071b 100644 --- a/crates/runvil-internal/lua/src/__impl_fanout.lua +++ b/crates/runvil-internal/lua/src/__impl_fanout.lua @@ -3,7 +3,8 @@ -- is an ordinary yield and the scheduler keeps no fanout state of its own. -- -- The Engine installs this after the coroutine prelude (`__impl_coro.lua`) --- and installs the returned function as `tasks.fanout`. The chunk +-- and installs the first returned function as `tasks.fanout`. The second +-- makes the `tasks.fanout` of a library. The chunk -- arguments are privileged captures, never globals: `yield` wraps -- coroutine.yield, `var_snapshot` is the Engine helper returning the hidden -- `var` data table as a plain deep copy, `helpers` is the prelude's shared @@ -83,7 +84,10 @@ end -- an arm delivered with a result is canceled right after the `join_any` -- that delivers it, a failed arm has ended, and a failure cancels the live -- arms before the function raises. -local function fanout(...) +-- `from` is nil for a section's own code and a library's identity for that +-- library's code; every arm's `spawn` request carries it, so the worker's +-- path resolves from the place the fanout was written in. +local function fanout_impl(from, ...) local argc = select("#", ...) local model, worker, collection if argc == 2 then @@ -154,6 +158,7 @@ local function fanout(...) item = members[index], index = index, var = var, + from = from, }) if not ok then restore() @@ -200,4 +205,11 @@ local function fanout(...) return results end -return fanout +-- `tasks.fanout` for the code it is installed for. +local function fanout_from(from) + return function(...) + return fanout_impl(from, ...) + end +end + +return fanout_from(nil), fanout_from diff --git a/crates/runvil-internal/lua/src/__impl_require.lua b/crates/runvil-internal/lua/src/__impl_require.lua index df6bda8a7..83b458923 100644 --- a/crates/runvil-internal/lua/src/__impl_require.lua +++ b/crates/runvil-internal/lua/src/__impl_require.lua @@ -1,10 +1,10 @@ -- Runvil's `require` for a section VM. -- -- The Engine installs this once per VM. The chunk arguments are Engine --- captures, never globals: `resolve(heading)` asks the caller's resolver --- for a library, from the position of the library loading now or, when --- none is, of the section, and returns the library's identity, its --- heading, and the library itself; `begin_load(library)` reports the +-- captures, never globals: `resolve(path)` asks the caller's resolver for +-- the library a path from the top of the prompt names, and returns the +-- library's identity, its path as the Engine prints it, and the library +-- itself; `begin_load(library)` reports the -- load's start, loads the library's blocks, marks the library as the one -- loading, which makes `jump` refuse, and returns the blocks, their prose -- templates, and the prompt lines the templates start on, in order; @@ -27,8 +27,8 @@ local resolve, begin_load, end_load, swap_prose, restore_prose, error_value = .. local error, setmetatable, concat = error, setmetatable, table.concat -- Each library's saved value under its identity, or `LOADING` while its --- blocks run. Keyed by identity, never by heading text, so one heading --- resolved from two positions names two libraries. +-- blocks run. Keyed by identity, never by the path's text, so a library +-- loads once however its path is spaced. local cache = {} local LOADING = {} @@ -40,16 +40,16 @@ local function raise(message) end -- The cycle a require of `identity` closes, from that library's own load --- through the innermost: `require cycle: ## a -> ## b -> ## a`. -local function cycle(identity, heading) +-- through the innermost: `require cycle: /a -> /b -> /a`. +local function cycle(identity, path) local chain = {} local inside = false for index = 1, #loading do local entry = loading[index] if entry.identity == identity then inside = true end - if inside then chain[#chain + 1] = entry.heading end + if inside then chain[#chain + 1] = entry.path end end - chain[#chain + 1] = heading + chain[#chain + 1] = path return "require cycle: " .. concat(chain, " -> ") end @@ -83,13 +83,13 @@ local function run(blocks, templates, lines) return true end -local function require(heading) - local identity, library_heading, library = resolve(heading) +local function require(path) + local identity, library_path, library = resolve(path) local saved = cache[identity] - if saved == LOADING then raise(cycle(identity, library_heading)) end + if saved == LOADING then raise(cycle(identity, library_path)) end if saved ~= nil then return saved end local entry = setmetatable( - { identity = identity, heading = library_heading, finished = false }, + { identity = identity, path = library_path, finished = false }, closing ) cache[identity] = LOADING diff --git a/crates/runvil-internal/lua/src/__impl_tasks.lua b/crates/runvil-internal/lua/src/__impl_tasks.lua index 1a72af8b5..88b41ec03 100644 --- a/crates/runvil-internal/lua/src/__impl_tasks.lua +++ b/crates/runvil-internal/lua/src/__impl_tasks.lua @@ -3,7 +3,8 @@ -- send, the reply, cancel, and the admission limit. -- -- The Engine installs this after the coroutine prelude (`__impl_coro.lua`) --- and installs the returned table as the `tasks` global. The chunk +-- and installs the returned table as the `tasks` global. The chunk also +-- returns `spawn_from`, which makes the `tasks.spawn` of a library. The chunk -- arguments are privileged captures, never globals: `yield` wraps -- coroutine.yield, `var_snapshot` is the Engine helper returning the hidden -- `var` data table as a plain deep copy, and `helpers` is the prelude's @@ -25,24 +26,33 @@ local raise, fail, engine_type = helpers.raise, helpers.fail, helpers.engine_typ -- once with a Task handle. `opts.input` overrides the chain's args, -- `opts.item` becomes its `item` global, `opts.index` its `sys.index`; the -- caller's `var` seeds the chain. -local function tasks_spawn(target, opts) - if opts == nil then - opts = {} - elseif type(opts) ~= "table" then - raise("lua", { message = "tasks.spawn opts must be a table, got " .. engine_type(opts) }) +-- +-- `spawn_from(from)` makes the function for the code it is installed for: +-- `from` is nil for a section's own code and a library's identity for that +-- library's code, and the driver resolves the target's path from there. +local function spawn_from(from) + return function(target, opts) + if opts == nil then + opts = {} + elseif type(opts) ~= "table" then + raise("lua", { message = "tasks.spawn opts must be a table, got " .. engine_type(opts) }) + end + local ok, result = yield({ + op = "spawn", + target = target, + input = opts.input, + item = opts.item, + index = opts.index, + var = var_snapshot(), + from = from, + }) + if not ok then fail(result) end + return { task = result } end - local ok, result = yield({ - op = "spawn", - target = target, - input = opts.input, - item = opts.item, - index = opts.index, - var = var_snapshot(), - }) - if not ok then fail(result) end - return { task = result } end +local tasks_spawn = spawn_from(nil) + -- Resolves a tasks.* argument to its bare id: a Task handle (any table -- with a string `task` field) or the id string itself. Anything else is -- the call's argument error. @@ -315,4 +325,4 @@ return { reply = tasks_reply, cancel = tasks_cancel, concurrency = tasks_concurrency, -} +}, spawn_from diff --git a/crates/runvil-internal/lua/src/collection.rs b/crates/runvil-internal/lua/src/collection.rs index 2aa3a9590..e832ebe03 100644 --- a/crates/runvil-internal/lua/src/collection.rs +++ b/crates/runvil-internal/lua/src/collection.rs @@ -20,15 +20,17 @@ use crate::order::{KeyError, SortKey, sort_key}; /// Lua values; the `spawn` request converts each one at its own boundary. /// /// # Errors -/// Returns a `LuaRuntime` failure when the value is not a table (the message points -/// at `prompt.list` for the list-section case), when a member is a +/// Returns a `LuaRuntime` failure when the value is not a table (the message says +/// how to read the items of a list in a section), when a member is a /// function, userdata, or thread (the error names the member's index), or /// when a hash key is not a string, number, or boolean. pub(crate) fn collection_members(lua: &Lua, collection: &Value) -> Result { let Value::Table(table) = collection else { - return Err(make_lua_runtime_error( - "tasks.fanout's second parameter is a collection; for a list section use prompt.list(heading), which returns nil when no visible section has that heading".to_owned(), - )); + return Err(make_lua_runtime_error(format!( + "tasks.fanout's second parameter is a collection, got {}; for the items of a list \ + in a section, pass prompt.section(path, \"list\").items", + collection.type_name() + ))); }; let border = table.raw_len(); let members = lua @@ -148,13 +150,22 @@ mod tests { #[test] fn collection_members_rejects_a_non_table() { let lua = mlua::Lua::new(); - for source in ["return '### Items'", "return 5", "return true"] { + for (source, kind) in [ + ("return 'Items'", "string"), + ("return 5", "integer"), + ("return true", "boolean"), + ("return nil", "nil"), + ] { let value = eval(&lua, source); let error = collection_members(&lua, &value).expect_err("a non-table is not a collection"); - assert!( - error.to_string().contains("prompt.list(heading)"), - "the error must point at prompt.list for {source}: {error}" + assert_eq!( + error.to_string(), + format!( + "tasks.fanout's second parameter is a collection, got {kind}; for the items \ + of a list in a section, pass prompt.section(path, \"list\").items" + ), + "{source}" ); } } diff --git a/crates/runvil-internal/lua/src/coro.rs b/crates/runvil-internal/lua/src/coro.rs index 1f8e0f3ac..3b8e353c1 100644 --- a/crates/runvil-internal/lua/src/coro.rs +++ b/crates/runvil-internal/lua/src/coro.rs @@ -287,14 +287,14 @@ pub(crate) fn install_shims( let call: Function = shims.raw_get("call").map_err(map_lua)?; globals.raw_set("call", call).map_err(map_lua)?; let helpers: Table = shims.raw_get("helpers").map_err(map_lua)?; - let tasks: Table = TASKS_PROGRAM + let (tasks, spawn_from): (Table, Function) = TASKS_PROGRAM .as_ref() .map_err(Clone::clone)? .load(lua)? .call((yield_fn.clone(), var_snapshot.clone(), helpers.clone())) .map_err(map_lua)?; globals.raw_set("tasks", &tasks).map_err(map_lua)?; - let fanout = fanout_shim( + let (fanout, fanout_from) = fanout_shim( lua, yield_fn, var_snapshot, @@ -303,6 +303,10 @@ pub(crate) fn install_shims( is_model_handle, )?; tasks.raw_set("fanout", fanout).map_err(map_lua)?; + // What `require` makes a library's own `call` and `tasks` with. + let call_from: Function = shims.raw_get("call_from").map_err(map_lua)?; + crate::library_env::register_shims(lua, call_from, spawn_from, fanout_from, &tasks) + .map_err(map_lua)?; stash_shims(lua, &shims)?; for name in ["loop", "compact"] { let entry: Function = shims.raw_get(name).map_err(map_lua)?; @@ -333,7 +337,7 @@ fn recording_yield(lua: &Lua, raw_yield: Function) -> Result { /// captures and failure `helpers`, plus the collection enumerator, the item /// renderer, the two captures over the chain's current model `selection`, /// and the shim chunk's `is_model_handle`, and returns the shim installed as -/// `tasks.fanout`. +/// `tasks.fanout` and the function that makes a library's. fn fanout_shim( lua: &Lua, yield_fn: Function, @@ -341,7 +345,7 @@ fn fanout_shim( helpers: Table, selection: &Arc>>, is_model_handle: Function, -) -> Result { +) -> Result<(Function, Function)> { // The enumerator answers `(members)` or `(nil, message)` so the shim // raises the message as its own call error, exactly as the other // author-argument failures surface at the call site. diff --git a/crates/runvil-internal/lua/src/error.rs b/crates/runvil-internal/lua/src/error.rs index 268febfcc..395841fd5 100644 --- a/crates/runvil-internal/lua/src/error.rs +++ b/crates/runvil-internal/lua/src/error.rs @@ -51,7 +51,7 @@ struct PromptFile { } /// A condition the prompt author caused and can fix in Lua, such as an -/// empty `messages` list or a heading that names no section, as a +/// empty `messages` list or a path that names no section, as a /// `LuaRuntime` failure. The message is the specific failure as a noun /// phrase, with no `lua error:` label, since a failed run classifies it as /// a Lua failure. diff --git a/crates/runvil-internal/lua/src/error_value.rs b/crates/runvil-internal/lua/src/error_value.rs index 5ec1fcb0b..68738f46b 100644 --- a/crates/runvil-internal/lua/src/error_value.rs +++ b/crates/runvil-internal/lua/src/error_value.rs @@ -294,7 +294,6 @@ fn kind_of(code: ErrorCode) -> ErrorKind { ErrorCode::FrontmatterInvalid | ErrorCode::StructureInvalid | ErrorCode::FenceInvalid - | ErrorCode::ListEmptyItem | ErrorCode::UnsupportedVersion | ErrorCode::InvalidValue | ErrorCode::ToolSchemaInvalid diff --git a/crates/runvil-internal/lua/src/globals.rs b/crates/runvil-internal/lua/src/globals.rs index 995ff2f18..b094ea050 100644 --- a/crates/runvil-internal/lua/src/globals.rs +++ b/crates/runvil-internal/lua/src/globals.rs @@ -199,7 +199,7 @@ impl fmt::Display for Reserved { /// A global section setup installs must be listed here: the Engine's /// section setup tests compare this list against a set-up VM's globals in /// both directions. -pub const RESERVED_NAMES: [(&str, Reserved); 62] = [ +pub const RESERVED_NAMES: [(&str, Reserved); 63] = [ ("args", Reserved::EngineGlobal), ("argv", Reserved::EngineGlobal), ("call", Reserved::EngineGlobal), @@ -208,6 +208,7 @@ pub const RESERVED_NAMES: [(&str, Reserved); 62] = [ ("json", Reserved::EngineGlobal), ("jump", Reserved::EngineGlobal), ("log", Reserved::EngineGlobal), + ("markdown", Reserved::EngineGlobal), ("messages", Reserved::EngineGlobal), ("models", Reserved::EngineGlobal), ("prompt", Reserved::EngineGlobal), diff --git a/crates/runvil-internal/lua/src/json.rs b/crates/runvil-internal/lua/src/json.rs index a0e818b4c..76fa66cf8 100644 --- a/crates/runvil-internal/lua/src/json.rs +++ b/crates/runvil-internal/lua/src/json.rs @@ -14,6 +14,7 @@ mod decode; mod encode; mod keys; +pub(crate) use decode::text_input; pub(crate) use encode::to_json; use mlua::{Lua, LuaSerdeExt, Table, Value}; diff --git a/crates/runvil-internal/lua/src/json/decode.rs b/crates/runvil-internal/lua/src/json/decode.rs index 55d3ae853..98ca1511d 100644 --- a/crates/runvil-internal/lua/src/json/decode.rs +++ b/crates/runvil-internal/lua/src/json/decode.rs @@ -11,12 +11,13 @@ use serde_json::Value as Json; use crate::error::author_refusal; -/// The most bytes either function takes. +/// The most bytes of text a function that parses on the Rust heap takes, +/// because that parse runs outside the Lua memory limit. const INPUT_LIMIT: usize = 8_388_608; /// `json.decode(text)`: the one JSON document `text` holds, as Lua values. pub(super) fn decode(lua: &Lua, text: Value) -> mlua::Result { - let bytes = input(text, "json.decode")?; + let bytes = text_input(text, "json.decode")?; let document: Json = serde_json::from_slice(&bytes) .map_err(|error| author_refusal(format!("json.decode: {error}")))?; lua.to_value(&document) @@ -27,7 +28,7 @@ pub(super) fn decode(lua: &Lua, text: Value) -> mlua::Result { /// line and one may span lines, and an error's line and column count /// across the whole text. pub(super) fn decode_lines(lua: &Lua, text: Value) -> mlua::Result { - let bytes = input(text, "json.decode_lines")?; + let bytes = text_input(text, "json.decode_lines")?; let values = serde_json::Deserializer::from_slice(&bytes) .into_iter::() .collect::, _>>() @@ -35,9 +36,9 @@ pub(super) fn decode_lines(lua: &Lua, text: Value) -> mlua::Result { lua.to_value(&values) } -/// The bytes of `text`, checked before any parsing: a string of at most -/// [`INPUT_LIMIT`] bytes. -fn input(text: Value, function: &str) -> mlua::Result { +/// The bytes of `text`, the argument of `function`, checked before any +/// parsing: a string of at most [`INPUT_LIMIT`] bytes. +pub(crate) fn text_input(text: Value, function: &str) -> mlua::Result { let text = match text { Value::String(text) => text, other => { diff --git a/crates/runvil-internal/lua/src/json/encode.rs b/crates/runvil-internal/lua/src/json/encode.rs index 3472f472c..864dc710a 100644 --- a/crates/runvil-internal/lua/src/json/encode.rs +++ b/crates/runvil-internal/lua/src/json/encode.rs @@ -13,7 +13,6 @@ //! turn comes, and refuses a path that holds too many keys as Lua //! references at once. -use std::collections::HashSet; use std::ffi::c_void; use mlua::{Lua, LuaSerdeExt, LuaString, Table, Value}; @@ -21,7 +20,7 @@ use serde_json::{Map, Number, Value as Json}; use super::keys::{Entries, Shape, entries, shape}; use crate::error::author_refusal; -use crate::proxy::{key_path, key_text}; +use crate::proxy::{Unsealed, key_path, key_text, unseal}; use crate::walk_budget::{HELD_KEY_LIMIT, TooLarge, WalkBudget}; /// The most tables deep a value may nest, the top-level table being level @@ -145,14 +144,6 @@ fn too_deep() -> String { format!("is nested more than {DEPTH_LIMIT} levels deep") } -/// What a table stands for once sealed views are read through. -enum Resolved { - Data(Table), - Unreadable(Option), - /// A chain of views that leads back to one of its own views. - Loop, -} - /// One walk over a value: the mode, the path of keys to the current value /// for error messages, the tables on that path for cycle detection, what /// is left of the budget for values and text, and how many keys the tables @@ -257,15 +248,15 @@ impl Encoder { /// data table, which counts for the cycle and depth checks instead. fn table(&mut self, table: &Table, level: usize) -> mlua::Result { let data = match self.resolve(table)? { - Resolved::Data(data) => data, - Resolved::Unreadable(label) => { + Unsealed::Data(data) => data, + Unsealed::Unreadable(label) => { let placeholder = match &label { Some(label) => format!("[cannot read: {label}]"), None => "[cannot read]".to_owned(), }; return self.fail(&unreadable(label.as_deref()), placeholder); } - Resolved::Loop => return self.fail(CYCLE, "[cycle]".to_owned()), + Unsealed::Loop => return self.fail(CYCLE, "[cycle]".to_owned()), }; let pointer = data.to_pointer(); if self.tables.contains(&pointer) { @@ -373,11 +364,11 @@ impl Encoder { /// level 1 while the list stays on the cycle path. fn items(&mut self, list: &Table) -> mlua::Result> { let data = match self.resolve(list)? { - Resolved::Data(data) => data, - Resolved::Unreadable(label) => { + Unsealed::Data(data) => data, + Unsealed::Unreadable(label) => { return Err(self.error(&unreadable(label.as_deref()))); } - Resolved::Loop => return Err(self.error(CYCLE)), + Unsealed::Loop => return Err(self.error(CYCLE)), }; // Only integer keys reach the list below, and they hold no // reference, so `held` has nothing to add for this table. @@ -402,50 +393,10 @@ impl Encoder { } impl Encoder { - /// Reads through sealed views. A sealed view is a table with no entries - /// of its own whose metatable has both `__index` and `__metatable`: a - /// table `__index` is its data, read by these same rules, and a - /// function `__index` cannot be read, labelled by a string - /// `__metatable`. Any other table is its own data. Each hop to another - /// view spends one value of the budget, and the views already passed - /// sit in a set, so a long chain costs time in proportion to its - /// length. - fn resolve(&mut self, table: &Table) -> mlua::Result { - let mut current = table.clone(); - let mut views = HashSet::new(); - loop { - let Some(metatable) = current.metatable() else { - return Ok(Resolved::Data(current)); - }; - let label: Value = metatable.raw_get("__metatable")?; - if label.is_nil() - || current - .pairs::() - .next() - .transpose()? - .is_some() - { - return Ok(Resolved::Data(current)); - } - match metatable.raw_get::("__index")? { - Value::Table(data) => { - self.charge_node()?; - views.insert(current.to_pointer()); - if views.contains(&data.to_pointer()) { - return Ok(Resolved::Loop); - } - current = data; - } - Value::Function(_) => { - let label = match label { - Value::String(label) => Some(label.to_string_lossy()), - _ => None, - }; - return Ok(Resolved::Unreadable(label)); - } - _ => return Ok(Resolved::Data(current)), - } - } + /// Reads `table` through sealed views (see [`unseal`]). Each hop to + /// another view spends one value of the budget. + fn resolve(&mut self, table: &Table) -> mlua::Result { + unseal(table, || self.charge_node()) } } diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index 6e756fb57..fcb103e9f 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -14,7 +14,11 @@ //! `json.decode_lines`), encodes Lua values as compact JSON text //! (`json.encode` and `json.encode_lines`), holds `json.null`, the value //! every decoded JSON null becomes, and marks a table to encode as a JSON -//! array (`json.array`). +//! array (`json.array`). A persistent `markdown` table parses Markdown +//! text into plain tables (`markdown.parse`), finds a section or a block +//! in them (`markdown.section`, `markdown.section_or`, and +//! `markdown.select`), and writes their sources back as the text they +//! were read from (`markdown.render`). //! //! The chunk's top-level return value becomes the section's result (the finish //! case of the exit rule). The `var` table is read back afterward as JSON for @@ -61,9 +65,9 @@ //! //! ## Invariants //! -//! - May depend on: `runvil-types`, `runvil-model-client`, and -//! `runvil-vfs`. `cargo test -p build-xtask` enforces the product and -//! container boundaries. +//! - May depend on: `runvil-types`, `runvil-model-client`, +//! `runvil-markdown`, and `runvil-vfs`. `cargo test -p build-xtask` +//! enforces the product and container boundaries. //! - Scheduler state changes only through a typed `Request` yielded by the //! installed shim; a direct or malformed yield fails the block without //! changing scheduler state. @@ -93,6 +97,8 @@ mod globals; mod hardening; mod iteration; mod json; +mod library_env; +mod markdown; mod messages; mod models; mod models_loop; @@ -101,6 +107,7 @@ mod packs; mod prelude; mod program; mod projection; +mod prompt_reads; mod prose; mod protocol; mod proxy; @@ -154,7 +161,9 @@ pub use crate::protocol::{ ToolCallOutcome, ToolCallRecord, VfsOp, VfsOutcome, YieldParse, }; pub use crate::scope::{OfferMode, OfferedTool, Offers, RenderedTools, ToolCallCounts}; -pub use crate::section::{Library, LibraryBlock, LibraryKey, LuaBlockResult, SectionText}; +pub use crate::section::{ + Library, LibraryBlock, LibraryKey, LuaBlockResult, SectionRead, SectionText, +}; pub use crate::vm::{ CoroStep, InjectFailure, Injected, SectionVm, Uninjected, current_catalog_packs, current_dynamic_set, current_static_set, diff --git a/crates/runvil-internal/lua/src/library_env.rs b/crates/runvil-internal/lua/src/library_env.rs new file mode 100644 index 000000000..0b9e3d747 --- /dev/null +++ b/crates/runvil-internal/lua/src/library_env.rs @@ -0,0 +1,109 @@ +//! The environment a library's code runs in. +//! +//! `require` loads each block of a library with an environment table of +//! its own, so the block and every function it defines name their globals +//! through it. The table holds the three names that take a relative +//! section path, made for the library: `call`, `prompt`, and `tasks`, whose +//! `spawn` and `fanout` are the library's own. A relative path given to +//! them starts at the library's section, while the library loads and when +//! its functions run later, whichever section is using it. +//! +//! Every other global read and every global write goes on to the VM's +//! globals table, and so through the `_G` guard, as it does from a +//! section's own block. So `jump` and every other global a library names +//! belong to the section that is running, and so do `_G.prompt`, +//! `_G.call`, and `_G.tasks`. +//! +//! The parts come from two installs that run before `require` is +//! installed: the yield shims hand over what makes `call` and `tasks` +//! through [`register_shims`], and the control globals hand over what +//! makes `prompt` through [`register_prompt`]. [`build`] makes one +//! library's table from whatever was registered. + +use mlua::{Function, Lua, Table, Value}; + +use crate::section::LibraryKey; + +/// The registry key of what makes a library's `call` and `tasks`: a table +/// holding the `call_from`, `spawn_from`, and `fanout_from` functions and +/// the Engine's `tasks` table. +const SHIMS_REGISTRY: &str = "runvil.library_env.shims"; + +/// The registry key of the function that makes a library's `prompt` table +/// from the library's identity. +const PROMPT_REGISTRY: &str = "runvil.library_env.prompt"; + +/// Keeps what makes a library's `call` and `tasks`. Each of the three +/// functions takes a library's identity and returns the function for that +/// library, and `tasks` is the Engine's table, whose other fields a +/// library's `tasks` shares. +/// +/// # Errors +/// Returns the Lua failure of building or storing the registry value. +pub(crate) fn register_shims( + lua: &Lua, + call_from: Function, + spawn_from: Function, + fanout_from: Function, + tasks: &Table, +) -> mlua::Result<()> { + let shims = lua.create_table()?; + shims.raw_set("call_from", call_from)?; + shims.raw_set("spawn_from", spawn_from)?; + shims.raw_set("fanout_from", fanout_from)?; + shims.raw_set("tasks", tasks)?; + lua.set_named_registry_value(SHIMS_REGISTRY, shims) +} + +/// Keeps `prompt_from`, which takes a library's identity and returns the +/// `prompt` table whose reads start at that library. +/// +/// # Errors +/// Returns the Lua failure of storing the registry value. +pub(crate) fn register_prompt(lua: &Lua, prompt_from: Function) -> mlua::Result<()> { + lua.set_named_registry_value(PROMPT_REGISTRY, prompt_from) +} + +/// Builds the environment the blocks of the library under `key` load +/// with. +/// +/// The table holds `call`, `tasks`, and `prompt` made for the library. A +/// VM that never installed the yield shims has no `call` or `tasks` to +/// make, and one that never installed the control globals has no +/// `prompt`; the library then reads the VM's globals for those names, as +/// it does for every other. The metatable is protected, so a library's +/// code cannot read or replace it. +/// +/// # Errors +/// Returns the Lua failure of building the table or of a registered +/// function. +pub(crate) fn build(lua: &Lua, key: &LibraryKey) -> mlua::Result
{ + let identity = key.identity(); + let env = lua.create_table()?; + if let Value::Table(shims) = lua.named_registry_value::(SHIMS_REGISTRY)? { + let make = |name: &str| -> mlua::Result { + shims.raw_get::(name)?.call(identity) + }; + env.raw_set("call", make("call_from")?)?; + // The Engine's `tasks` copied field by field, so setting the two + // fields below changes the library's table alone. + let tasks = lua.create_table()?; + for pair in shims.raw_get::
("tasks")?.pairs::() { + let (name, value) = pair?; + tasks.raw_set(name, value)?; + } + tasks.raw_set("spawn", make("spawn_from")?)?; + tasks.raw_set("fanout", make("fanout_from")?)?; + env.raw_set("tasks", tasks)?; + } + if let Value::Function(prompt_from) = lua.named_registry_value::(PROMPT_REGISTRY)? { + env.raw_set("prompt", prompt_from.call::
(identity)?)?; + } + let globals = lua.globals(); + let meta = lua.create_table()?; + meta.raw_set("__index", &globals)?; + meta.raw_set("__newindex", &globals)?; + meta.raw_set("__metatable", "a library's environment is guarded")?; + env.set_metatable(Some(meta))?; + Ok(env) +} diff --git a/crates/runvil-internal/lua/src/markdown.rs b/crates/runvil-internal/lua/src/markdown.rs new file mode 100644 index 000000000..1b25f9c8a --- /dev/null +++ b/crates/runvil-internal/lua/src/markdown.rs @@ -0,0 +1,233 @@ +//! The `markdown` Engine global: Markdown text as plain Lua tables. +//! +//! `markdown.parse(text)` reads text into a list of blocks. A block is a +//! plain record and a list is a marked list, so a parse result passes +//! `json.encode`, can be stored in `var`, and can be a `tasks.fanout` +//! collection. Each block holds its `source`, the lines it was read +//! from, and the sources of a parse are the text. `markdown.section` +//! finds the one section a path names, or one block of it that a selector +//! picks, and raises when it is not there. `markdown.section_or` is the +//! same lookup and returns nil for that miss. `markdown.select` collects +//! every block a selector matches, all of them for `*`, and +//! `markdown.render` writes the sources of blocks back as one text. +//! +//! `runvil-markdown` parses, writes, and holds the path and selector +//! grammars. This module turns its blocks into tables and writes tables +//! back (`blocks`), reads a list of blocks the author passes (`walk`), +//! and runs the lookups over those tables (`lookup`). Every function is +//! pure, and a lookup returns the tables that are already inside the list +//! it was given. The `prompt` reads take the same selector and index, +//! read by [`Selection`], over the blocks of one section of the running +//! prompt. + +mod blocks; +mod lookup; +mod walk; + +pub(crate) use blocks::block_table; + +use mlua::{Lua, LuaSerdeExt, LuaString, MultiValue, Table, Value}; +use runvil_markdown::{Path, Pick, Selector, Writer, pick}; + +use self::lookup::Match; +use self::walk::{Walk, whole}; +use crate::error::{self, Result, author_refusal}; +use crate::json::text_input; + +/// Installs the `markdown` table as a global. +/// +/// # Errors +/// Returns a `LuaRuntime` failure if the table, its functions, or the +/// global cannot be created. +pub(crate) fn install(lua: &Lua) -> Result<()> { + let build = || -> mlua::Result
{ + let markdown = lua.create_table()?; + markdown.raw_set("parse", lua.create_function(parse)?)?; + markdown.raw_set( + "section", + lua.create_function(|lua, arguments| { + section(lua, "markdown.section", true, arguments) + })?, + )?; + markdown.raw_set( + "section_or", + lua.create_function(|lua, arguments| { + section(lua, "markdown.section_or", false, arguments) + })?, + )?; + markdown.raw_set("select", lua.create_function(select)?)?; + markdown.raw_set( + "render", + lua.create_function(|lua, blocks: Value| render(lua, &blocks))?, + )?; + Ok(markdown) + }; + let markdown = build().map_err(error::make_lua_error)?; + lua.globals() + .raw_set("markdown", markdown) + .map_err(error::make_lua_error) +} + +/// `markdown.parse(text)`: the blocks of `text`, as a marked list. +fn parse(lua: &Lua, text: Value) -> mlua::Result
{ + const FUNCTION: &str = "markdown.parse"; + let bytes = text_input(text, FUNCTION)?; + let text = std::str::from_utf8(&bytes) + .map_err(|_| author_refusal(format!("{FUNCTION}: text is not valid UTF-8")))?; + blocks::list_table(lua, &runvil_markdown::parse(text)) +} + +/// The selector of a lookup and the index after it. +pub(crate) struct Selection { + /// The selector as the author wrote it. + pub(crate) given: String, + /// The selector. + pub(crate) selector: Selector, + /// The index, when there is one. It is never zero. + pub(crate) n: Option, +} + +impl Selection { + /// Reads the `selector` and `n` arguments of `function`: none, a + /// selector, or a selector and an index. + /// + /// # Errors + /// Returns the refusal of a selector that is not a string or is + /// malformed, of an index that is not a nonzero whole number, and of + /// an index with no selector before it. + pub(crate) fn read(function: &str, selector: &Value, n: &Value) -> mlua::Result> { + let refuse = |message: String| author_refusal(format!("{function}: {message}")); + let given = match selector { + Value::Nil if n.is_nil() => return Ok(None), + Value::Nil => return Err(refuse("an index needs a selector before it".to_owned())), + Value::String(given) => given.to_string_lossy(), + other => { + return Err(refuse(format!( + "selector must be a string, got {}", + other.type_name() + ))); + } + }; + let selector = Selector::parse(&given).map_err(refuse)?; + let n = match (n, whole(n)) { + (Value::Nil, _) => None, + (_, Some(n)) if n != 0 => Some(n), + (Value::Integer(_) | Value::Number(_), Some(_)) => { + return Err(refuse("index must be a nonzero integer, got 0".to_owned())); + } + (Value::Number(number), None) => { + return Err(refuse(format!( + "index must be a nonzero integer, got {number}" + ))); + } + (other, _) => { + return Err(refuse(format!( + "index must be a nonzero integer, got {}", + other.type_name() + ))); + } + }; + Ok(Some(Self { given, selector, n })) + } + + /// Picks one of `lines`, the lines of the blocks the selector matched + /// in the section `place` names: the position of the pick, or the + /// message of a miss. + /// + /// # Errors + /// Returns the message that asks for an index when several matched + /// and there is none. + pub(crate) fn pick( + &self, + place: &str, + lines: &[Option], + ) -> std::result::Result { + pick(&self.given, place, lines, self.n) + } +} + +/// `markdown.section(blocks, path [, selector [, n]])` and its +/// `section_or` twin: the one section `path` names in `blocks`, or the one +/// block of it the selector picks. A section or a block that is not there +/// raises when `strict` and is nil otherwise; every other failure raises +/// from both. +fn section( + lua: &Lua, + function: &'static str, + strict: bool, + arguments: MultiValue, +) -> mlua::Result { + let mut arguments = arguments.into_iter(); + let mut next = || arguments.next().unwrap_or(Value::Nil); + let (blocks, path, selector, n) = (next(), next(), next(), next()); + let mut walk = Walk::new(lua, function); + let given = match &path { + Value::String(path) => path.to_string_lossy(), + other => { + return Err(walk.refuse(format!("path must be a string, got {}", other.type_name()))); + } + }; + let path = Path::parse(&given).map_err(|message| walk.refuse(message))?; + let selection = Selection::read(function, &selector, &n)?; + let miss = |walk: &Walk, message: String| { + if strict { + Err(walk.refuse(message)) + } else { + Ok(Value::Nil) + } + }; + let found = match lookup::sections(&mut walk, &blocks, &path)? { + Err(number) => return miss(&walk, path.no_match(&given, number)), + Ok(found) => found, + }; + let [section] = found.as_slice() else { + let lines: Vec> = found.iter().map(|section| section.line).collect(); + return Err(walk.refuse(Path::ambiguous(&given, &lines))); + }; + let Some(selection) = selection else { + return Ok(Value::Table(section.found.clone())); + }; + let members = lookup::members(&mut walk, §ion.blocks, &selection.selector)?; + let lines: Vec> = members.iter().map(|block| block.line).collect(); + let place = format!("\"{}\"", given.trim()); + match selection + .pick(&place, &lines) + .map_err(|message| walk.refuse(message))? + { + Pick::Match(at) => Ok(members + .into_iter() + .nth(at) + .map_or(Value::Nil, |Match { found, .. }| Value::Table(found))), + Pick::Missing(message) => miss(&walk, message), + } +} + +/// `markdown.select(blocks, selector)`: every block `selector` matches at +/// any depth, in file order, as a new marked list of the tables +/// themselves. +fn select(lua: &Lua, (blocks, selector): (Value, Value)) -> mlua::Result
{ + const FUNCTION: &str = "markdown.select"; + let mut walk = Walk::new(lua, FUNCTION); + let Some(selection) = Selection::read(FUNCTION, &selector, &Value::Nil)? else { + return Err(walk.refuse("selector must be a string, got nil")); + }; + let mut kept = Vec::new(); + lookup::every(&mut walk, &blocks, &selection.selector, &mut kept)?; + let list = lua.create_table_with_capacity(kept.len(), 0)?; + list.set_metatable(Some(lua.array_metatable()))?; + for (index, block) in kept.into_iter().enumerate() { + list.raw_set(index + 1, block)?; + } + Ok(list) +} + +/// `markdown.render(blocks)`: the `source` of every block of `blocks`, at +/// any depth and in file order, as one text. The writer parts two blocks +/// that would otherwise run together, and adds nothing between two blocks +/// of one parse. +fn render(lua: &Lua, blocks: &Value) -> mlua::Result { + let mut walk = Walk::new(lua, "markdown.render"); + let mut writer = Writer::new(); + blocks::write(&mut walk, blocks, &mut writer)?; + lua.create_string(writer.finish()) +} diff --git a/crates/runvil-internal/lua/src/markdown/blocks.rs b/crates/runvil-internal/lua/src/markdown/blocks.rs new file mode 100644 index 000000000..969bb9ae5 --- /dev/null +++ b/crates/runvil-internal/lua/src/markdown/blocks.rs @@ -0,0 +1,101 @@ +//! Blocks as Lua tables, and the tables written back as text. +//! +//! [`list_table`] and [`block_table`] build the plain tables a parse hands +//! to Lua: a block is a record with its `kind`, its `line`, its `source`, +//! and the fields of its kind, and every list is marked, so an empty one +//! encodes as `[]`. [`write`](fn@write) goes the other way for +//! `markdown.render`: it writes each block's `source`, and reads no other +//! field but the `kind` and `blocks` that say what comes next. + +use mlua::{Lua, LuaSerdeExt, Table, Value}; +use runvil_markdown::{Block, Kind, Writer}; + +use super::walk::Walk; + +/// Builds the marked list of tables for `blocks`. +/// +/// # Errors +/// Returns the Lua failure of building a table, the memory limit's among +/// them. +pub(super) fn list_table(lua: &Lua, blocks: &[Block]) -> mlua::Result
{ + let list = lua.create_table_with_capacity(blocks.len(), 0)?; + list.set_metatable(Some(lua.array_metatable()))?; + for (index, block) in blocks.iter().enumerate() { + list.raw_set(index + 1, block_table(lua, block)?)?; + } + Ok(list) +} + +/// Builds the table for one block. +/// +/// # Errors +/// Returns the Lua failure of building a table, the memory limit's among +/// them. +pub(crate) fn block_table(lua: &Lua, block: &Block) -> mlua::Result
{ + let table = lua.create_table()?; + table.raw_set("kind", block.kind().name())?; + table.raw_set("line", block.line())?; + table.raw_set("source", block.source())?; + match block { + Block::Frontmatter { data, .. } => table.raw_set("data", lua.to_value(data)?)?, + Block::Section { + level, + title, + blocks, + .. + } => { + table.raw_set("level", *level)?; + table.raw_set("title", title.as_str())?; + table.raw_set("blocks", list_table(lua, blocks)?)?; + } + Block::Prose { text, .. } => table.raw_set("text", text.as_str())?, + Block::Fence { + lang, info, text, .. + } => { + table.raw_set("lang", lang.as_str())?; + table.raw_set("info", info.as_str())?; + table.raw_set("text", text.as_str())?; + } + Block::List { ordered, items, .. } => { + let list = lua.create_table_with_capacity(items.len(), 0)?; + list.set_metatable(Some(lua.array_metatable()))?; + for (index, item) in items.iter().enumerate() { + list.raw_set(index + 1, item.as_str())?; + } + table.raw_set("ordered", *ordered)?; + table.raw_set("items", list)?; + } + Block::Rule { .. } => {} + } + Ok(table) +} + +/// Writes the `source` of every block of the list `value` to `writer`, in +/// order, a section's before those of the blocks under it. +/// +/// A block's `kind` is read for two things: the writer parts two prose +/// blocks, and only a section's `blocks` are written. A section with no +/// `blocks` field holds nothing, as it does for a lookup. +/// +/// # Errors +/// Returns the refusal of a list or a block that cannot be read, and of a +/// `source` that is not a string of valid UTF-8. +pub(super) fn write(walk: &mut Walk, value: &Value, writer: &mut Writer) -> mlua::Result<()> { + let list = walk.open(value)?; + walk.each(&list, &mut |walk, entry| { + let source = walk.text(&entry.field("source")?, "source")?; + let kind = entry.string("kind")?; + let kind = kind.as_ref().and_then(|kind| kind.to_str().ok()); + let kind = kind.as_deref().unwrap_or_default(); + writer.write(kind, &source); + if kind == Kind::Section.name() { + let blocks = entry.field("blocks")?; + if !blocks.is_nil() { + write(walk, &blocks, writer)?; + } + } + Ok(()) + })?; + walk.close(); + Ok(()) +} diff --git a/crates/runvil-internal/lua/src/markdown/lookup.rs b/crates/runvil-internal/lua/src/markdown/lookup.rs new file mode 100644 index 000000000..67d110ace --- /dev/null +++ b/crates/runvil-internal/lua/src/markdown/lookup.rs @@ -0,0 +1,212 @@ +//! Lookups over a list of blocks: the sections a path names, the blocks of +//! one section a selector matches, and every block a selector matches. +//! +//! A lookup returns the tables that are already inside the list it was +//! given, so it copies nothing. It reads only `kind`, `title`, `blocks`, +//! `lang`, and `line`, and a block whose `kind` it does not know matches +//! no selector but `*`, which matches every block. + +use std::collections::HashSet; +use std::ffi::c_void; + +use mlua::{Table, Value}; +use runvil_markdown::{Path, Selector, Step, position}; + +use super::walk::{Entry, Walk}; + +/// One block a lookup kept. +pub(super) struct Match { + /// The block's table, as it sits in the list. + pub(super) found: Table, + /// The block's `line`, when it holds one. + pub(super) line: Option, + /// A section's `blocks` field, or nil for any other block. + pub(super) blocks: Value, + /// The data table of a section's `blocks`, which the next step of a + /// path starts from. + list: Option<*const c_void>, +} + +/// One step of a path applied to the whole tree. +struct Search<'a> { + /// The step. + step: &'a Step, + /// The lists the step starts from, or `None` for the first step, + /// which starts from the list the lookup was given. + starts: Option<&'a HashSet<*const c_void>>, + /// The sections kept so far, so one that two parents share counts + /// once. + seen: HashSet<*const c_void>, + /// The sections the step kept, in file order. + kept: Vec, +} + +/// Finds the sections `path` names in the list `root`: every one its last +/// step kept, in file order, or the number of the first step, counted +/// from 1, that kept none. +/// +/// Each step is one walk of the whole tree. `/title` keeps the sections +/// with that title that are direct members of a starting list, and +/// `//title` keeps those at any depth under one. `[n]` then keeps only +/// the `n`th of them. +/// +/// # Errors +/// Returns the refusal of a list or a block that cannot be read. +pub(super) fn sections( + walk: &mut Walk, + root: &Value, + path: &Path, +) -> mlua::Result, usize>> { + let mut starts: Option> = None; + let mut kept = Vec::new(); + for (number, step) in path.steps().iter().enumerate() { + let mut search = Search { + step, + starts: starts.as_ref(), + seen: HashSet::new(), + kept: Vec::new(), + }; + search_list(walk, root, false, true, &mut search)?; + kept = search.kept; + if let Some(n) = step.index { + kept = position(kept.len(), n) + .map(|at| kept.swap_remove(at)) + .into_iter() + .collect(); + } + if kept.is_empty() { + return Ok(Err(number + 1)); + } + starts = Some(kept.iter().filter_map(|section| section.list).collect()); + } + Ok(Ok(kept)) +} + +/// Applies one step to the list `list` and everything under it, and +/// returns the list's data table. `inside` is whether the list sits under +/// a starting list, and `root` whether it is the list the lookup was +/// given. +fn search_list( + walk: &mut Walk, + list: &Value, + inside: bool, + root: bool, + search: &mut Search<'_>, +) -> mlua::Result<*const c_void> { + let data = walk.open(list)?; + let pointer = data.to_pointer(); + let here = search + .starts + .map_or(root, |starts| starts.contains(&pointer)); + walk.each(&data, &mut |walk, entry| { + if !entry.is("kind", SECTION)? { + return Ok(()); + } + let reachable = here || (search.step.deep && inside); + let kept = reachable + && entry.is("title", &search.step.title)? + && search.seen.insert(entry.found.to_pointer()); + let slot = if kept { + search.kept.push(Match { + found: entry.found.clone(), + line: entry.line()?, + blocks: Value::Nil, + list: None, + }); + Some(search.kept.len() - 1) + } else { + None + }; + let blocks = entry.field("blocks")?; + if !blocks.is_nil() { + let list = search_list(walk, &blocks, inside || here, false, search)?; + if let Some(section) = slot.and_then(|slot| search.kept.get_mut(slot)) { + section.list = Some(list); + section.blocks = blocks; + } + } + Ok(()) + })?; + walk.close(); + Ok(pointer) +} + +/// The `kind` of a section. +const SECTION: &str = "section"; + +/// Whether `selector` matches the block `entry`, and whether the block +/// is a section. The block's `kind` is the empty text when it has none, +/// which only `*` matches, and its `lang` is read only for a selector +/// that names a language. +fn selects(selector: &Selector, entry: &Entry) -> mlua::Result<(bool, bool)> { + let kind = entry.string("kind")?; + let kind = kind.as_ref().and_then(|kind| kind.to_str().ok()); + let kind = kind.as_deref().unwrap_or_default(); + let lang = match selector { + Selector::Any | Selector::Kind(_) => None, + Selector::Fence(_) => entry.string("lang")?, + }; + let lang = lang.as_ref().and_then(|lang| lang.to_str().ok()); + Ok((selector.matches(kind, lang.as_deref()), kind == SECTION)) +} + +/// Finds the blocks `selector` matches among the direct members of the +/// list `blocks`, in order. A nil `blocks` is a section that holds +/// nothing. +/// +/// # Errors +/// Returns the refusal of a list or a block that cannot be read. +pub(super) fn members( + walk: &mut Walk, + blocks: &Value, + selector: &Selector, +) -> mlua::Result> { + let mut kept = Vec::new(); + if blocks.is_nil() { + return Ok(kept); + } + let data = walk.open(blocks)?; + walk.each(&data, &mut |_, entry| { + let (selected, _) = selects(selector, entry)?; + if selected { + kept.push(Match { + found: entry.found.clone(), + line: entry.line()?, + blocks: Value::Nil, + list: None, + }); + } + Ok(()) + })?; + walk.close(); + Ok(kept) +} + +/// Adds to `kept` every block `selector` matches in the list `list` and +/// under it, in file order, a section before its members. +/// +/// # Errors +/// Returns the refusal of a list or a block that cannot be read. +pub(super) fn every( + walk: &mut Walk, + list: &Value, + selector: &Selector, + kept: &mut Vec
, +) -> mlua::Result<()> { + let data = walk.open(list)?; + walk.each(&data, &mut |walk, entry| { + let (selected, section) = selects(selector, entry)?; + if selected { + kept.push(entry.found.clone()); + } + if section { + let blocks = entry.field("blocks")?; + if !blocks.is_nil() { + every(walk, &blocks, selector, kept)?; + } + } + Ok(()) + })?; + walk.close(); + Ok(()) +} diff --git a/crates/runvil-internal/lua/src/markdown/walk.rs b/crates/runvil-internal/lua/src/markdown/walk.rs new file mode 100644 index 000000000..77df9df26 --- /dev/null +++ b/crates/runvil-internal/lua/src/markdown/walk.rs @@ -0,0 +1,272 @@ +//! Reading a list of blocks from a Lua value. +//! +//! A list of blocks is whatever the author passes: what `markdown.parse` +//! returned, one section's `blocks`, a list read back from `var`, or +//! tables built by hand. [`Walk`] reads one the way `json.encode` reads a +//! value, never calling into Lua: it reads through sealed views, takes a +//! list as items 1, 2, and so on up to the first nil, refuses a list on +//! its own path and one nested too deep, and counts every block it visits +//! against a budget, since a table shared by many parents is visited once +//! for each. +//! +//! A failure names the function the walk serves and the place it stopped, +//! such as `blocks[2].blocks[1]`. + +use std::ffi::c_void; +use std::fmt; + +use mlua::{Lua, LuaString, Table, Value}; + +use crate::error::author_refusal; +use crate::proxy::{Unsealed, unseal}; +use crate::walk_budget::WalkBudget; + +/// The most lists deep that blocks may nest, the list given being level 1: +/// the depth `json.encode` accepts. +const DEPTH_LIMIT: usize = 127; + +/// One walk over a list of blocks, for one call of a `markdown` function. +pub(super) struct Walk { + /// The function the walk serves, which starts every failure's text. + function: &'static str, + /// What is left of the work the walk may do. + budget: WalkBudget, + /// The data table of each list open on the path, outermost first. + open: Vec<*const c_void>, + /// The position of the block being read in each list on the path. + at: Vec, +} + +/// One block of a list. +pub(super) struct Entry { + /// The table as it sits in the list: what a lookup returns. + pub(super) found: Table, + /// The table its fields are read from, with any view read through. + data: Table, +} + +impl Entry { + /// The field `name`, read without calling into Lua. + pub(super) fn field(&self, name: &str) -> mlua::Result { + self.data.raw_get(name) + } + + /// The field `name` when it is a string, and `None` otherwise. + pub(super) fn string(&self, name: &str) -> mlua::Result> { + Ok(match self.field(name)? { + Value::String(text) => Some(text), + _ => None, + }) + } + + /// Whether the field `name` is the string `text`. + pub(super) fn is(&self, name: &str, text: &str) -> mlua::Result { + Ok(self + .string(name)? + .is_some_and(|field| field.as_bytes() == text.as_bytes())) + } + + /// The block's `line`, when it holds a whole number. + pub(super) fn line(&self) -> mlua::Result> { + Ok(whole(&self.field("line")?)) + } +} + +/// The integer `value` holds, when it is a whole number an `i64` can hold. +pub(super) fn whole(value: &Value) -> Option { + /// 2^63, one past `i64::MAX`. + const ABOVE_MAX: f64 = 9_223_372_036_854_775_808.0; + match value { + Value::Integer(integer) => Some(*integer), + Value::Number(number) + if number.fract() == 0.0 && (-ABOVE_MAX..ABOVE_MAX).contains(number) => + { + #[expect( + clippy::cast_possible_truncation, + reason = "the float is a whole number inside i64's range" + )] + let whole = *number as i64; + Some(whole) + } + _ => None, + } +} + +impl Walk { + /// A walk for one call of `function` on `lua`. + pub(super) fn new(lua: &Lua, function: &'static str) -> Self { + Self { + function, + budget: WalkBudget::for_vm(lua), + open: Vec::new(), + at: Vec::new(), + } + } + + /// The failure `: `. + pub(super) fn refuse(&self, message: impl fmt::Display) -> mlua::Error { + author_refusal(format!("{}: {message}", self.function)) + } + + /// Spends one visit of the budget. + fn charge(&mut self) -> mlua::Result<()> { + self.budget + .charge_node() + .map_err(|_| self.refuse("blocks are too large to walk")) + } + + /// Spends `count` bytes of text of the budget. + fn charge_bytes(&mut self, count: usize) -> mlua::Result<()> { + self.budget + .charge_bytes(count) + .map_err(|_| self.refuse("blocks are too large to walk")) + } + + /// The name of the block being read, such as `blocks[2].blocks[1]`. + /// It is empty when no block is being read. + fn block_name(&self) -> String { + self.at + .iter() + .map(|index| format!("blocks[{index}]")) + .collect::>() + .join(".") + } + + /// The name of the list a block holds, such as `blocks[2].blocks`, or + /// `blocks` for the list the walk was given. + fn list_name(&self) -> String { + if self.at.is_empty() { + "blocks".to_owned() + } else { + format!("{}.blocks", self.block_name()) + } + } + + /// The table `table` stands for, with any view read through, for the + /// value `name` names. + fn data(&mut self, table: &Table, name: &dyn Fn(&Self) -> String) -> mlua::Result
{ + let mut hops = 0_usize; + let unsealed = unseal(table, || { + hops += 1; + Ok(()) + })?; + for _ in 0..hops { + self.charge()?; + } + match unsealed { + Unsealed::Data(data) => Ok(data), + Unsealed::Unreadable(Some(label)) => Err(self.refuse(format!( + "{} cannot be read ({label}); copy the fields you need into a plain table", + name(self) + ))), + Unsealed::Unreadable(None) => Err(self.refuse(format!( + "{} cannot be read; copy the fields you need into a plain table", + name(self) + ))), + Unsealed::Loop => Err(self.refuse(format!("{} contains itself", name(self)))), + } + } + + /// Opens the list `value` and returns the table its items are read + /// from. [`close`](Self::close) ends it. + /// + /// # Errors + /// Returns the refusal of a value that is not a table, of a block + /// given where a list belongs, of a list that cannot be read, of one + /// that is on its own path, and of one nested past the depth limit. + pub(super) fn open(&mut self, value: &Value) -> mlua::Result
{ + let Value::Table(table) = value else { + return Err(self.refuse(format!( + "{} must be a list of blocks, got {}", + self.list_name(), + value.type_name() + ))); + }; + let data = self.data(table, &Self::list_name)?; + if let Value::String(kind) = data.raw_get::("kind")? { + let kind = kind.to_string_lossy(); + let got = if kind == "section" { + "a section; pass its blocks field".to_owned() + } else { + format!("a {kind} block") + }; + return Err(self.refuse(format!( + "{} must be a list of blocks, got {got}", + self.list_name() + ))); + } + let pointer = data.to_pointer(); + if self.open.contains(&pointer) { + return Err(self.refuse(format!("{} contains itself", self.list_name()))); + } + if self.open.len() >= DEPTH_LIMIT { + return Err(self.refuse(format!( + "{} is nested more than {DEPTH_LIMIT} levels deep", + self.list_name() + ))); + } + self.open.push(pointer); + Ok(data) + } + + /// Ends the list the last [`open`](Self::open) started. + pub(super) fn close(&mut self) { + self.open.pop(); + } + + /// Visits each block of `list`, an open list, in order: items 1, 2, + /// and so on up to the first nil. + /// + /// # Errors + /// Returns the refusal of an item that is not a table or that cannot + /// be read, and the failure of `visit`. + pub(super) fn each( + &mut self, + list: &Table, + visit: &mut dyn FnMut(&mut Self, &Entry) -> mlua::Result<()>, + ) -> mlua::Result<()> { + for index in 1.. { + let item: Value = list.raw_get(index)?; + self.at.push(index); + let found = match item { + Value::Nil => { + self.at.pop(); + break; + } + Value::Table(found) => found, + other => { + return Err(self.refuse(format!( + "{} must be a table, got {}", + self.block_name(), + other.type_name() + ))); + } + }; + self.charge()?; + let data = self.data(&found, &Self::block_name)?; + visit(self, &Entry { found, data })?; + self.at.pop(); + } + Ok(()) + } + + /// Reads `value`, the field `field` of the block being read, as text. + /// + /// # Errors + /// Returns the refusal of a value that is not a string, and of a + /// string that is not valid UTF-8. + pub(super) fn text(&mut self, value: &Value, field: &str) -> mlua::Result { + let Value::String(text) = value else { + return Err(self.refuse(format!( + "{}.{field} must be a string, got {}", + self.block_name(), + value.type_name() + ))); + }; + self.charge_bytes(text.as_bytes().len())?; + match text.to_str() { + Ok(text) => Ok(text.to_owned()), + Err(_) => Err(self.refuse(format!("{}.{field} is not valid UTF-8", self.block_name()))), + } + } +} diff --git a/crates/runvil-internal/lua/src/program.rs b/crates/runvil-internal/lua/src/program.rs index 92013afb8..64ecaf02f 100644 --- a/crates/runvil-internal/lua/src/program.rs +++ b/crates/runvil-internal/lua/src/program.rs @@ -181,6 +181,20 @@ impl LuaProgram { .map_err(error::make_lua_error) } + /// Loads the compiled function into `lua` with `env` as its + /// environment: the table every global the chunk names is read from + /// and written to, in the chunk and in every function it defines. + /// + /// # Errors + /// Returns a `LuaRuntime` failure if the VM rejects the internally + /// compiled bytecode. + pub(crate) fn load_in(&self, lua: &Lua, env: mlua::Table) -> Result { + lua.load(self.bytecode.as_slice()) + .set_environment(env) + .into_function() + .map_err(error::make_lua_error) + } + /// Maps a Lua runtime failure to its ordered core outcome, for a chunk /// that ran beside `loaded`, the programs of the libraries loaded into /// its VM, from the prompt file `file`. diff --git a/crates/runvil-internal/lua/src/prompt_reads.rs b/crates/runvil-internal/lua/src/prompt_reads.rs new file mode 100644 index 000000000..bdebfb876 --- /dev/null +++ b/crates/runvil-internal/lua/src/prompt_reads.rs @@ -0,0 +1,149 @@ +//! The `prompt` table: the reads of the running prompt. +//! +//! `prompt.section(path)` returns a section's own raw Markdown, and +//! `prompt.section(path, selector [, n])` returns one block of it as a +//! table built for the call: the block the selector picks once the +//! Markdown is parsed by the rules `markdown.parse` follows, with `line` +//! counting lines of the prompt file and `source` holding the block's +//! lines of that Markdown. Both forms raise when the section +//! or the block is not there. `prompt.section_or` is the same read, and +//! returns nil for that miss. Both raise for a path the caller's reader +//! refuses, for a target that is not a string, and for a malformed +//! selector or index. +//! +//! The reader looks a path up from a place: the library whose code made +//! the read, or the section that is running when a section's own code +//! did. A VM has one table for its sections' code, and +//! [`library_env`](crate::library_env) makes one more for each library it +//! loads. + +use std::sync::Arc; + +use mlua::{Function, Lua, Table, Value}; +use runvil_markdown::{Block, Kind, Pick, Selector, parse_body}; + +use crate::error::{self, Result, author_refusal}; +use crate::markdown::{Selection, block_table}; +use crate::section::{LibraryKey, SectionRead, SectionText, resolve_section_target}; + +/// Installs the `prompt` global over `read`, and hands the library +/// environment what makes a library's own `prompt`. +/// +/// # Errors +/// Returns a `LuaRuntime` failure if the table cannot be built or +/// installed. +pub(crate) fn install(lua: &Lua, globals: &Table, read: R) -> Result<()> +where + R: Fn(String, Option) -> std::result::Result + + Send + + Sync + + 'static, + E: std::error::Error + Send + Sync + 'static, +{ + let read = Arc::new(read); + let prompt = table(lua, &read, None).map_err(error::make_lua_error)?; + globals + .raw_set("prompt", prompt) + .map_err(error::make_lua_error)?; + let prompt_from = lua + .create_function(move |lua, identity: String| { + table(lua, &read, Some(LibraryKey::new(identity))) + }) + .map_err(error::make_lua_error)?; + crate::library_env::register_prompt(lua, prompt_from).map_err(error::make_lua_error) +} + +/// Builds one `prompt` table over `read`. Its reads start at the library +/// `from` names, or at the running section when `from` is `None`. +fn table(lua: &Lua, read: &Arc, from: Option) -> mlua::Result
+where + R: Fn(String, Option) -> std::result::Result + + Send + + Sync + + 'static, + E: std::error::Error + Send + Sync + 'static, +{ + let section = reader(lua, read, from.clone(), "prompt.section", true)?; + let section_or = reader(lua, read, from, "prompt.section_or", false)?; + let prompt = lua.create_table()?; + prompt.raw_set("section", section)?; + prompt.raw_set("section_or", section_or)?; + Ok(prompt) +} + +/// Builds one of the two reads, named `function`: `(path)` returns the +/// section's Markdown, and `(path, selector [, n])` one block of it. A +/// section or a block that is not there raises when `strict`, and is nil +/// otherwise. The selector and the index are checked before the path is +/// looked up, so a malformed one raises whether or not the section is +/// there. +fn reader( + lua: &Lua, + read: &Arc, + from: Option, + function: &'static str, + strict: bool, +) -> mlua::Result +where + R: Fn(String, Option) -> std::result::Result + + Send + + Sync + + 'static, + E: std::error::Error + Send + Sync + 'static, +{ + let read = Arc::clone(read); + lua.create_function(move |lua, (target, selector, n): (Value, Value, Value)| { + let path = resolve_section_target(target)?; + let selection = own_selection(function, &selector, &n)?; + let text = match read(path.clone(), from.clone()).map_err(mlua::Error::external)? { + SectionRead::Found(text) => text, + SectionRead::Missing(error) if strict => return Err(mlua::Error::external(error)), + SectionRead::Missing(_) => return Ok(Value::Nil), + }; + let Some(selection) = selection else { + return lua.create_string(text.markdown()).map(Value::String); + }; + let refuse = |message: String| author_refusal(format!("{function}: {message}")); + let blocks = own_blocks(&text); + let matches: Vec<&Block> = blocks + .iter() + .filter(|block| selection.selector.selects(block)) + .collect(); + let lines: Vec> = matches + .iter() + .map(|block| i64::try_from(block.line()).ok()) + .collect(); + let place = format!("section `{}`", path.trim()); + match selection.pick(&place, &lines).map_err(refuse)? { + Pick::Match(at) => match matches.get(at) { + Some(block) => block_table(lua, block).map(Value::Table), + None => Ok(Value::Nil), + }, + Pick::Missing(message) if strict => Err(refuse(message)), + Pick::Missing(_) => Ok(Value::Nil), + } + }) +} + +/// Reads the selector and index of a read named `function`. A section's +/// own content holds no subsection and no frontmatter, so those two kinds +/// are refused. +fn own_selection(function: &str, selector: &Value, n: &Value) -> mlua::Result> { + let selection = Selection::read(function, selector, n)?; + let refusal = match selection.as_ref().map(|selection| &selection.selector) { + Some(Selector::Kind(Kind::Section)) => { + "a section's own content has no subsections; name a subsection by its own path" + } + Some(Selector::Kind(Kind::Frontmatter)) => "a section's own content has no frontmatter", + _ => return Ok(selection), + }; + Err(author_refusal(format!("{function}: {refusal}"))) +} + +/// Parses a section's own Markdown into blocks whose lines count lines of +/// the prompt file. No frontmatter is looked for, because a prompt's is at +/// the top of the file and never in a section. +fn own_blocks(text: &SectionText) -> Vec { + let first_line = usize::try_from(text.line().get()).unwrap_or(usize::MAX); + parse_body(text.markdown(), first_line) +} diff --git a/crates/runvil-internal/lua/src/protocol/parse.rs b/crates/runvil-internal/lua/src/protocol/parse.rs index a57c8b621..5a2f33fb2 100644 --- a/crates/runvil-internal/lua/src/protocol/parse.rs +++ b/crates/runvil-internal/lua/src/protocol/parse.rs @@ -25,7 +25,7 @@ use tools::{parse_adhoc_tool_done, parse_tool_call, parse_tool_calls}; use crate::error::{self, Error, make_lua_runtime_error}; use crate::models::LuaModelHandle; -use crate::section::resolve_section_target; +use crate::section::{LibraryKey, resolve_section_target}; use super::request::Request; @@ -268,5 +268,17 @@ fn parse_call(lua: &Lua, table: &mlua::Table) -> std::result::Result std::result::Result, FieldFailure> { + Ok(shim_optional_string(table, "from")?.map(LibraryKey::new)) } diff --git a/crates/runvil-internal/lua/src/protocol/parse/tasks.rs b/crates/runvil-internal/lua/src/protocol/parse/tasks.rs index 0f77f64df..e2caeab9b 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/tasks.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/tasks.rs @@ -15,7 +15,9 @@ use runvil_types::ids::TaskId; use crate::protocol::{NonEmpty, Request}; use crate::section::scalar_return; -use super::{FieldFailure, call_error, call_optional_string, call_string, call_target, shim_var}; +use super::{ + FieldFailure, call_error, call_optional_string, call_string, call_target, shim_from, shim_var, +}; /// Parses one task id string; a malformed path is the call's error naming /// why the parse refused it. @@ -79,12 +81,14 @@ pub(super) fn parse_spawn( Err(_) => return Err(FieldFailure::Malformed), }; let var = shim_var(lua, table)?; + let from = shim_from(table)?; Ok(Request::Spawn { target, input, item, index, var, + from, }) } diff --git a/crates/runvil-internal/lua/src/protocol/request.rs b/crates/runvil-internal/lua/src/protocol/request.rs index 628b156a0..48a4ec315 100644 --- a/crates/runvil-internal/lua/src/protocol/request.rs +++ b/crates/runvil-internal/lua/src/protocol/request.rs @@ -19,6 +19,7 @@ use super::non_empty::NonEmpty; use super::record::ToolCallRecord; use crate::error::Error; use crate::messages::MessageList; +use crate::section::LibraryKey; /// A validated suspending Engine call, parsed from the yielded table. /// @@ -40,7 +41,7 @@ pub enum Request { /// `call(target, input?)`: run a contained chain over the target's /// slice. Call { - /// The heading string. + /// The target's section path, as the author wrote it. target: String, /// The optional input override; `None` runs under the caller's own /// args. @@ -48,13 +49,16 @@ pub enum Request { /// The caller's `var` snapshot, seeded into the chain and discarded /// when it ends. var: serde_json::Value, + /// The library whose code made the call, when a library's did. + /// The target's path then starts at that library's section, and + /// otherwise at the section that is running. + from: Option, }, /// `tasks.spawn(target, opts?)`, and each arm of the `fanout` shim: /// start a task chain over the target's slice and return at once. The - /// chain shares `call`'s target resolution and depth cap, and refuses a - /// list section as the target (the worker-template check). + /// chain shares `call`'s target resolution and depth cap. Spawn { - /// The heading string. + /// The target's section path, as the author wrote it. target: String, /// `opts.input`: the chain's `args` override; `None` runs under the /// caller's own args. @@ -68,6 +72,10 @@ pub enum Request { /// The caller's `var` snapshot, seeded into the chain and discarded /// when it ends. var: serde_json::Value, + /// The library whose code spawned, when a library's did. The + /// target's path then starts at that library's section, and + /// otherwise at the section that is running. + from: Option, }, /// The wait shims' internal timeout timer: a leaf request whose work /// is one sleep, registered as an effect-backed task slot the caller diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse.rs b/crates/runvil-internal/lua/src/protocol/tests/parse.rs index 03afef829..d37a3b336 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse.rs @@ -46,14 +46,20 @@ fn call_parses_target_input_and_var_snapshot() { let lua = Lua::new(); let table = lua_table( &lua, - "{ op = 'call', target = '## Child', input = 'override', var = { k = 1 } }", + "{ op = 'call', target = '../Child', input = 'override', var = { k = 1 } }", ); let request = expect_request(Request::from_yield(&lua, &Value::Table(table))); match request { - Request::Call { target, input, var } => { - assert_eq!(target, "## Child"); + Request::Call { + target, + input, + var, + from, + } => { + assert_eq!(target, "../Child"); assert_eq!(input.as_deref(), Some("override")); assert_eq!(var, json!({ "k": 1 })); + assert_eq!(from, None, "a request with no `from` names no library"); } other => panic!("expected a call request, got {other:?}"), } @@ -64,7 +70,7 @@ fn call_without_input_yields_none() { let lua = Lua::new(); let table = lua_table( &lua, - "{ op = 'call', target = '## Child', var = { k = 1 } }", + "{ op = 'call', target = '../Child', var = { k = 1 } }", ); let request = expect_request(Request::from_yield(&lua, &Value::Table(table))); match request { @@ -80,7 +86,7 @@ fn a_fanout_op_is_no_longer_a_request() { let lua = Lua::new(); let table = lua_table( &lua, - "{ op = 'fanout', worker = '### Worker', collection = {}, var = { k = 1 } }", + "{ op = 'fanout', worker = 'Worker', collection = {}, var = { k = 1 } }", ); assert_direct_yield(Request::from_yield(&lua, &Value::Table(table))); } @@ -300,7 +306,7 @@ fn a_call_with_a_non_string_target_keeps_the_resolve_error() { #[test] fn a_request_without_a_var_snapshot_is_rejected() { let lua = Lua::new(); - let table = lua_table(&lua, "{ op = 'call', target = '## Child' }"); + let table = lua_table(&lua, "{ op = 'call', target = '../Child' }"); assert_direct_yield(Request::from_yield(&lua, &Value::Table(table))); } diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs b/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs index b37610873..7d5e67fe4 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse_tasks.rs @@ -12,7 +12,7 @@ fn spawn_parses_target_seeds_and_var() { let lua = Lua::new(); let table = lua_table( &lua, - "{ op = 'spawn', target = '## Child', input = 'override', item = { name = 'alpha' }, \ + "{ op = 'spawn', target = '../Child', input = 'override', item = { name = 'alpha' }, \ index = 3, var = { k = 1 } }", ); let request = expect_request(Request::from_yield(&lua, &Value::Table(table))); @@ -23,23 +23,45 @@ fn spawn_parses_target_seeds_and_var() { item, index, var, + from, } => { - assert_eq!(target, "## Child"); + assert_eq!(target, "../Child"); assert_eq!(input.as_deref(), Some("override")); assert_eq!(item, Some(json!({ "name": "alpha" }))); assert_eq!(index, Some(3)); assert_eq!(var, json!({ "k": 1 })); + assert_eq!(from, None, "a request with no `from` names no library"); } other => panic!("expected a spawn request, got {other:?}"), } } +#[test] +fn a_call_and_a_spawn_carry_the_library_whose_code_made_them() { + let lua = Lua::new(); + for op in ["call", "spawn"] { + let table = lua_table( + &lua, + &format!("{{ op = '{op}', target = 'Worker', var = {{}}, from = '2.0' }}"), + ); + let request = expect_request(Request::from_yield(&lua, &Value::Table(table))); + let (Request::Call { from, .. } | Request::Spawn { from, .. }) = request else { + panic!("expected a {op} request, got {request:?}"); + }; + assert_eq!( + from.as_ref().map(crate::LibraryKey::identity), + Some("2.0"), + "{op}" + ); + } +} + #[test] fn spawn_without_options_parses_every_seed_as_absent() { let lua = Lua::new(); let table = lua_table( &lua, - "{ op = 'spawn', target = '## Child', var = { k = 1 } }", + "{ op = 'spawn', target = '../Child', var = { k = 1 } }", ); match expect_request(Request::from_yield(&lua, &Value::Table(table))) { Request::Spawn { @@ -57,7 +79,7 @@ fn spawn_without_options_parses_every_seed_as_absent() { fn a_spawn_item_holding_json_null_parses_to_a_null_field() { let vm = crate::test_vm::TestVm::new().build(); let table: mlua::Table = vm.eval( - "return { op = 'spawn', target = '## Child', \ + "return { op = 'spawn', target = '../Child', \ item = { a = json.null }, var = { k = 1 } }", ); match expect_request(Request::from_yield(vm.lua(), &Value::Table(table))) { @@ -73,7 +95,7 @@ fn spawn_seed_shape_errors_are_the_calls_error() { let lua = Lua::new(); let table = lua_table( &lua, - "{ op = 'spawn', target = '## Child', item = function() end, \ + "{ op = 'spawn', target = '../Child', item = function() end, \ var = { k = 1 } }", ); match Request::from_yield(&lua, &Value::Table(table)) { @@ -89,7 +111,7 @@ fn spawn_seed_shape_errors_are_the_calls_error() { let table = lua_table( &lua, - "{ op = 'spawn', target = '## Child', index = -1, var = { k = 1 } }", + "{ op = 'spawn', target = '../Child', index = -1, var = { k = 1 } }", ); match Request::from_yield(&lua, &Value::Table(table)) { YieldParse::Refused(Error { @@ -108,7 +130,7 @@ fn an_unconvertible_spawn_item_is_the_calls_error_naming_the_conversion_failure( let lua = Lua::new(); let table = lua_table( &lua, - "{ op = 'spawn', target = '## Child', item = { f = function() end }, \ + "{ op = 'spawn', target = '../Child', item = { f = function() end }, \ var = { k = 1 } }", ); let item: Value = table diff --git a/crates/runvil-internal/lua/src/proxy.rs b/crates/runvil-internal/lua/src/proxy.rs index 3df7d3217..4450cd895 100644 --- a/crates/runvil-internal/lua/src/proxy.rs +++ b/crates/runvil-internal/lua/src/proxy.rs @@ -1,9 +1,12 @@ //! The read-only proxy every seal builds on - `sys`, a frozen `argv` table, -//! a sealed prelude global, and a prelude's `var` view - and the key -//! formatter their refusals and field paths share. +//! a sealed prelude global, and a prelude's `var` view - the key +//! formatter their refusals and field paths share, and [`unseal`], which +//! reads a table through such views without calling into Lua. //! `tools.stats` and `tools.packs` build on the walkable [`read_only_walk`]. //! Tool and Pack objects share [`registry_object`] and [`add_frozen_guard`]. +use std::collections::HashSet; + use mlua::{ AnyUserData, Function, Lua, MaybeSend, MetaMethod, MultiValue, Table, UserDataMethods, Value, }; @@ -51,6 +54,70 @@ pub(crate) fn read_only_proxy( Ok(proxy) } +/// What a table stands for once sealed views are read through. +pub(crate) enum Unsealed { + /// The table that holds the data. + Data(Table), + /// A view whose data only a function can read, with the view's label + /// when it has one. + Unreadable(Option), + /// A chain of views that leads back to one of its own views. + Loop, +} + +/// Reads `table` through sealed views, never calling into Lua. +/// +/// A sealed view is a table with no entries of its own whose metatable has +/// both `__index` and `__metatable`: a table `__index` is its data, read +/// by these same rules, and a function `__index` cannot be read, labelled +/// by a string `__metatable`. Any other table is its own data. `hop` runs +/// once for each view passed, so the caller can bound the work, and the +/// views already passed sit in a set, so a long chain costs time in +/// proportion to its length. +/// +/// # Errors +/// Returns the failure of `hop`, or of reading a metatable. +pub(crate) fn unseal( + table: &Table, + mut hop: impl FnMut() -> mlua::Result<()>, +) -> mlua::Result { + let mut current = table.clone(); + let mut views = HashSet::new(); + loop { + let Some(metatable) = current.metatable() else { + return Ok(Unsealed::Data(current)); + }; + let label: Value = metatable.raw_get("__metatable")?; + if label.is_nil() + || current + .pairs::() + .next() + .transpose()? + .is_some() + { + return Ok(Unsealed::Data(current)); + } + match metatable.raw_get::("__index")? { + Value::Table(data) => { + hop()?; + views.insert(current.to_pointer()); + if views.contains(&data.to_pointer()) { + return Ok(Unsealed::Loop); + } + current = data; + } + Value::Function(_) => { + let label = match label { + Value::String(label) => Some(label.to_string_lossy()), + _ => None, + }; + return Ok(Unsealed::Unreadable(label)); + } + _ => return Ok(Unsealed::Data(current)), + } + } +} + /// An empty proxy whose metatable holds `index`, a `__pairs` that walks /// with `step`, a `__newindex` that raises ` is read-only`, and that /// text as its `__metatable`, which protects it. diff --git a/crates/runvil-internal/lua/src/require.rs b/crates/runvil-internal/lua/src/require.rs index 6eaad2d38..9291597bd 100644 --- a/crates/runvil-internal/lua/src/require.rs +++ b/crates/runvil-internal/lua/src/require.rs @@ -1,7 +1,8 @@ //! Runvil's `require`: a section VM's loader for library sections. //! -//! `require '## heading'` asks the caller's resolver for a [`Library`], -//! loads its compiled blocks into the VM's one environment, and runs them +//! `require '/path'` asks the caller's resolver for a [`Library`], +//! loads its compiled blocks with the library's environment +//! ([`library_env`](crate::library_env)), and runs them //! in order inside the calling block's coroutine, so a library block's //! suspending calls are ordinary yields of that block. The loader is Lua //! (`__impl_require.lua`), because a Rust function cannot call one that @@ -26,7 +27,7 @@ use crate::error::{self, Error, Result}; use crate::error_value::install_error_value; use crate::program::LuaProgram; use crate::prose::ProseState; -use crate::section::{Library, LibraryKey}; +use crate::section::Library; /// The loader chunk's name. const REQUIRE_CHUNK_NAME: &str = "@crates/runvil-internal/lua/src/__impl_require.lua"; @@ -38,10 +39,9 @@ const REQUIRE_SOURCE: &str = include_str!("__impl_require.lua"); static REQUIRE_PROGRAM: LazyLock> = LazyLock::new(|| LuaProgram::compile_internal(REQUIRE_SOURCE, REQUIRE_CHUNK_NAME)); -/// The loads under way on one VM, outermost first: each library's key, -/// which a `require` made during its load passes to the resolver, and its -/// heading, which its reports name. -type Loading = Arc>>; +/// The loads under way on one VM, outermost first: each library's path +/// from the top, which its reports name. +type Loading = Arc>>; /// The section VM's state `require` works on. pub(crate) struct VmShare<'a> { @@ -71,12 +71,12 @@ pub(crate) fn install( vm: &VmShare<'_>, ) -> Result<()> where - R: Fn(String, Option) -> std::result::Result + Send + Sync + 'static, + R: Fn(String) -> std::result::Result + Send + Sync + 'static, E: std::error::Error + Send + Sync + 'static, P: Fn(&str, Option, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, { let loading = Loading::default(); - let resolve = resolve_capture(lua, resolver, &loading)?; + let resolve = resolve_capture(lua, resolver)?; let begin_load = begin_load_capture(lua, &loading, vm, emitter, section)?; let end_load = end_load_capture(lua, &loading, vm.depth, emitter, section)?; let (swap_prose, restore_prose) = prose_captures(lua, render, vm.sys_live)?; @@ -99,32 +99,28 @@ where .map_err(error::make_lua_error) } -/// Builds `resolve(heading)`: the resolver's library for `heading`, asked -/// with the key of the library loading now, if any, and handed back as -/// its identity, its heading, and the library itself for `begin_load`. -fn resolve_capture(lua: &Lua, resolver: R, loading: &Loading) -> Result +/// Builds `resolve(path)`: the resolver's library for `path`, handed back +/// as its identity, its path from the top, and the library itself for +/// `begin_load`. +fn resolve_capture(lua: &Lua, resolver: R) -> Result where - R: Fn(String, Option) -> std::result::Result + Send + Sync + 'static, + R: Fn(String) -> std::result::Result + Send + Sync + 'static, E: std::error::Error + Send + Sync + 'static, { - let loading = Arc::clone(loading); - lua.create_function(move |lua, heading: Value| { - let heading = match heading { - Value::String(heading) => heading.to_str()?.to_owned(), + lua.create_function(move |lua, path: Value| { + let path = match path { + Value::String(path) => path.to_str()?.to_owned(), other => { return Err(mlua::Error::external(format!( - "require takes a section heading string such as '## helpers', got {}", + "require takes a section path such as '/helpers', got {}", other.type_name() ))); } }; - let from = lock(&loading, "library-load state")? - .last() - .map(|(key, _)| key.clone()); - let library = resolver(heading, from).map_err(mlua::Error::external)?; + let library = resolver(path).map_err(mlua::Error::external)?; let identity = library.key.identity().to_owned(); - let library_heading = library.heading.clone(); - Ok((identity, library_heading, lua.create_any_userdata(library)?)) + let library_path = library.path.clone(); + Ok((identity, library_path, lua.create_any_userdata(library)?)) }) .map_err(error::make_lua_error) } @@ -148,16 +144,23 @@ fn begin_load_capture( let section = section.to_owned(); lua.create_function(move |lua, library: AnyUserData| { let library = library.take::()?; - emitter.library_load(§ion, &library.heading, LibraryLoad::Started); - let blocks = library - .blocks - .iter() - .map(|block| block.program.load(lua)) - .collect::>>(); + emitter.library_load(§ion, &library.path, LibraryLoad::Started); + // Every block loads with the library's environment, so the block + // and each function it defines resolve a relative section path + // from the library's own section. + let blocks = crate::library_env::build(lua, &library.key) + .map_err(error::make_lua_error) + .and_then(|env| { + library + .blocks + .iter() + .map(|block| block.program.load_in(lua, env.clone())) + .collect::>>() + }); let blocks = match blocks { Ok(blocks) => blocks, Err(error) => { - emitter.library_load(§ion, &library.heading, LibraryLoad::Failed); + emitter.library_load(§ion, &library.path, LibraryLoad::Failed); return Err(mlua::Error::external(error)); } }; @@ -175,11 +178,11 @@ fn begin_load_capture( let mut loading = match lock(&loading, "library-load state") { Ok(loading) => loading, Err(error) => { - emitter.library_load(§ion, &library.heading, LibraryLoad::Failed); + emitter.library_load(§ion, &library.path, LibraryLoad::Failed); return Err(error); } }; - loading.push((library.key, library.heading)); + loading.push(library.path); depth.fetch_add(1, Ordering::Relaxed); Ok((blocks, templates, lines)) }) @@ -230,7 +233,7 @@ fn end_load_capture( let emitter = emitter.clone(); let section = section.to_owned(); lua.create_function(move |_, finished: bool| { - let Some((_, heading)) = lock(&loading, "library-load state")?.pop() else { + let Some(path) = lock(&loading, "library-load state")?.pop() else { return Err(mlua::Error::external(error::make_internal_error( "require ended a library load that never began", ))); @@ -241,7 +244,7 @@ fn end_load_capture( } else { LibraryLoad::Failed }; - emitter.library_load(§ion, &heading, phase); + emitter.library_load(§ion, &path, phase); Ok(()) }) .map_err(error::make_lua_error) diff --git a/crates/runvil-internal/lua/src/section.rs b/crates/runvil-internal/lua/src/section.rs index 9eaeb00a5..07c75295f 100644 --- a/crates/runvil-internal/lua/src/section.rs +++ b/crates/runvil-internal/lua/src/section.rs @@ -3,18 +3,20 @@ //! sections `require` loads. use std::num::NonZeroU32; +use std::ops::Range; +use std::sync::Arc; use mlua::{MultiValue, Value}; -use crate::error::{Result, make_lua_runtime_error}; +use crate::error::{Error, Result, make_lua_runtime_error}; /// Outcome of a Lua block that may invoke `jump`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum LuaBlockResult { /// Normal completion with an optional scalar return. Returned(Option), - /// `jump` transferred control to this heading (`## Name`), called on - /// this prompt line, which is `None` when the call found no author + /// `jump` transferred control to the section this path names, called + /// on this prompt line, which is `None` when the call found no author /// frame running. Jump(String, Option), } @@ -48,7 +50,7 @@ pub(crate) fn scalar_return(returned: MultiValue) -> Result> { } } -/// Resolves a `call` / `jump` target from a heading string. +/// Reads a `call` / `jump` target: a section path, as the author wrote it. /// /// # Errors /// Returns a Lua error when the value is not a string. @@ -62,20 +64,57 @@ pub(crate) fn resolve_section_target(value: Value) -> mlua::Result { } } -/// What a prompt read sees of one visible section. +/// What a prompt read sees of one section: its own raw Markdown, read in +/// place from the prompt text the run shares, so a read copies only what +/// it returns. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SectionText { - /// The section's own raw Markdown. - pub markdown: String, - /// Its pre-parsed list items; empty for a section that isn't a list. - pub items: Vec, + /// The text the Markdown is a stretch of. + text: Arc, + /// The bytes of `text` the Markdown covers. + range: Range, + /// The line of the prompt file the Markdown starts on. + line: NonZeroU32, +} + +impl SectionText { + /// A section's Markdown: the bytes `range` of `text`, which start on + /// `line` of the prompt file. + #[must_use] + pub fn new(text: Arc, range: Range, line: NonZeroU32) -> Self { + Self { text, range, line } + } + + /// The section's own raw Markdown. A range that does not lie in the + /// text reads as empty. + #[must_use] + pub fn markdown(&self) -> &str { + self.text.get(self.range.clone()).unwrap_or_default() + } + + /// The line of the prompt file the Markdown starts on. + #[must_use] + pub fn line(&self) -> NonZeroU32 { + self.line + } +} + +/// What a prompt read found for a well-formed path. +#[derive(Debug, Clone)] +pub enum SectionRead { + /// The section the path names. + Found(SectionText), + /// No section. The error says where the lookup looked: + /// `prompt.section` raises it, and `prompt.section_or` returns nil in + /// its place. + Missing(Error), } /// Which library a `require` resolved to, opaque to this crate. /// /// The caller encodes a library section's position in the identity -/// string; `require`'s cache keys on that identity, never on the heading -/// text, so one heading resolved from two positions names two libraries. +/// string; `require`'s cache keys on that identity, never on the path's +/// text, so a library loads once however its path is spaced. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct LibraryKey(String); @@ -96,12 +135,11 @@ impl LibraryKey { /// What `require` loads from one library section. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Library { - /// Which library this is: the cache key, and the position a `require` - /// made while it loads resolves from. + /// Which library this is: the cache key. pub key: LibraryKey, - /// The library section's heading with its markers, such as - /// `## helpers`, which its load reports and a cycle error name. - pub heading: String, + /// The library section's path from the top of the prompt, such as + /// `/helpers`, which its load reports and a cycle error name. + pub path: String, /// The section's blocks in order. pub blocks: Vec, } diff --git a/crates/runvil-internal/lua/src/test_support.rs b/crates/runvil-internal/lua/src/test_support.rs index c7c009833..86038a08b 100644 --- a/crates/runvil-internal/lua/src/test_support.rs +++ b/crates/runvil-internal/lua/src/test_support.rs @@ -211,8 +211,8 @@ impl SectionVm { fn run_loaded(&self, program: &LuaProgram) -> Result { self.clear_jump()?; let result = program.load(self.lua())?.call(()); - if let Some((heading, line)) = self.take_jump()? { - return Ok(LuaBlockResult::Jump(heading, line)); + if let Some((path, line)) = self.take_jump()? { + return Ok(LuaBlockResult::Jump(path, line)); } let returned = result.map_err(|error| self.map_chunk_failure(program, &error))?; Ok(LuaBlockResult::Returned(scalar_return(returned)?)) diff --git a/crates/runvil-internal/lua/src/test_vm.rs b/crates/runvil-internal/lua/src/test_vm.rs index 371bae045..0cf2cb7cd 100644 --- a/crates/runvil-internal/lua/src/test_vm.rs +++ b/crates/runvil-internal/lua/src/test_vm.rs @@ -21,7 +21,7 @@ use serde_json::{Value as Json, json}; use crate::error::{self, Result}; use crate::section::scalar_return; use crate::tests::recording::null_emitter; -use crate::{Argv, OfferedTool, RosterEntry, SectionText, SectionVm, ToolSet}; +use crate::{Argv, OfferedTool, RosterEntry, SectionRead, SectionVm, ToolSet}; /// The one guard-nonce seed of every test VM. pub(crate) const TEST_SEED: RunSeed = RunSeed::from_bytes([0x73; 32]); @@ -279,9 +279,15 @@ pub(crate) struct TestVm<'a> { var: Option<&'a Json>, limits: Option<(usize, u32)>, raw_model_ids: bool, - shims: bool, + /// The reader behind the `prompt` reads, set when the VM takes the + /// control globals and the coroutine shims. + shims: Option, } +/// The reader behind a test VM's `prompt` reads: the path as written, and +/// the library whose code made the read, if any. +type TestRead = Box) -> SectionRead + Send + Sync>; + impl<'a> TestVm<'a> { #[track_caller] pub(crate) fn new() -> Self { @@ -297,7 +303,7 @@ impl<'a> TestVm<'a> { var: None, limits: None, raw_model_ids: false, - shims: false, + shims: None, } } @@ -383,8 +389,21 @@ impl<'a> TestVm<'a> { /// The control globals, whose prompt reads find no section, and every /// coroutine yield shim. #[track_caller] - pub(crate) fn shims(mut self) -> Self { - self.shims = true; + pub(crate) fn shims(self) -> Self { + self.reading(|path, _| { + SectionRead::Missing(crate::make_lua_runtime_error(format!( + "section `{path}` not found" + ))) + }) + } + + /// [`shims`](Self::shims), with the `prompt` reads answered by `read`. + #[track_caller] + pub(crate) fn reading( + mut self, + read: impl Fn(String, Option) -> SectionRead + Send + Sync + 'static, + ) -> Self { + self.shims = Some(Box::new(read)); self } @@ -395,7 +414,7 @@ impl<'a> TestVm<'a> { #[track_caller] pub(crate) fn inject(mut self) -> SectionVm { assert!( - !self.shims, + self.shims.is_none(), "shims() needs the Engine globals, which inject() stops before" ); self.run_injection().0 @@ -407,9 +426,9 @@ impl<'a> TestVm<'a> { let (mut vm, selection, access) = self.run_injection(); vm.install_engine_globals(&self.emitter, self.section, &access.scope) .expect("the Engine globals install"); - if self.shims { - vm.install_control_globals(|_| { - Ok::, std::convert::Infallible>(None) + if let Some(read) = self.shims.take() { + vm.install_control_globals(move |path, from| { + Ok::(read(path, from)) }) .expect("the control globals install"); vm.install_coro_shims(1) diff --git a/crates/runvil-internal/lua/src/tests.rs b/crates/runvil-internal/lua/src/tests.rs index 152d4061c..dfe4661e3 100644 --- a/crates/runvil-internal/lua/src/tests.rs +++ b/crates/runvil-internal/lua/src/tests.rs @@ -43,6 +43,8 @@ mod engine_misuse; mod json; mod library_load; mod logging; +mod markdown; +mod prompt_reads; mod require; mod sandbox; mod section_vm; @@ -90,6 +92,32 @@ pub(crate) fn refused(message: &str) -> String { format!("{message}. {}", ErrorCode::LuaRuntime.prose()) } +/// Runs `call` under `pcall` on `vm` and returns the caught error's kind and +/// message, failing the test when the call returns. +#[track_caller] +fn caught(vm: &BuiltVm, call: &str) -> (String, String) { + let (raised, kind, message): (bool, String, String) = vm.eval(&format!( + "local ok, err = pcall(function() return {call} end)\n\ + if ok then return false, '', '' end\n\ + return true, tostring(err.kind), tostring(err)" + )); + assert!(raised, "required {call} to raise, actual a return"); + (kind, message) +} + +/// The `(kind, message)` pair of a caught `lua` error whose own text is +/// `message`: its kind and the author prose `tostring` gives for it. +fn lua_error(message: &str) -> (String, String) { + ("lua".to_owned(), refused(message)) +} + +/// Evaluates the Lua expression `call` on `vm` and returns the string it +/// gives. +#[track_caller] +fn encoded(vm: &BuiltVm, call: &str) -> String { + vm.eval(&format!("return {call}")) +} + /// Returns the message of a `LuaRuntime` failure. #[track_caller] fn lua_error_message(error: &Error) -> &str { @@ -224,8 +252,8 @@ fn run_scalar( ) -> Result> { match vm.run_chunk(program, emitter, section)? { LuaBlockResult::Returned(value) => Ok(value), - LuaBlockResult::Jump(heading, _) => Err(crate::error::make_lua_runtime_error(format!( - "unexpected jump to {heading}" + LuaBlockResult::Jump(path, _) => Err(crate::error::make_lua_runtime_error(format!( + "unexpected jump to {path}" ))), } } diff --git a/crates/runvil-internal/lua/src/tests/json.rs b/crates/runvil-internal/lua/src/tests/json.rs index 5887693ba..772f4cae3 100644 --- a/crates/runvil-internal/lua/src/tests/json.rs +++ b/crates/runvil-internal/lua/src/tests/json.rs @@ -24,32 +24,6 @@ fn json_vm() -> BuiltVm { TestVm::new().shims().build() } -/// Runs `call` under `pcall` on `vm` and returns the caught error's kind and -/// message, failing the test when the call returns. -#[track_caller] -fn caught(vm: &BuiltVm, call: &str) -> (String, String) { - let (raised, kind, message): (bool, String, String) = vm.eval(&format!( - "local ok, err = pcall(function() return {call} end)\n\ - if ok then return false, '', '' end\n\ - return true, tostring(err.kind), tostring(err)" - )); - assert!(raised, "required {call} to raise, actual a return"); - (kind, message) -} - -/// The `(kind, message)` pair of a caught `lua` error whose own text is -/// `message`: its kind and the author prose `tostring` gives for it. -fn lua_error(message: &str) -> (String, String) { - ("lua".to_owned(), refused(message)) -} - -/// Evaluates the Lua expression `call` on `vm` and returns the string it -/// gives. -#[track_caller] -fn encoded(vm: &BuiltVm, call: &str) -> String { - vm.eval(&format!("return {call}")) -} - /// A userdata whose `__tostring` gives `probe`, which debug mode never /// calls. struct Described; diff --git a/crates/runvil-internal/lua/src/tests/library_load.rs b/crates/runvil-internal/lua/src/tests/library_load.rs index abb6b21ba..f93c7111e 100644 --- a/crates/runvil-internal/lua/src/tests/library_load.rs +++ b/crates/runvil-internal/lua/src/tests/library_load.rs @@ -10,13 +10,13 @@ fn a_library_load_folds_to_its_kind_with_the_library() { let recorder = Recorder::default(); recorder .emitter() - .library_load("Main", "## shared_stuff", LibraryLoad::Started); + .library_load("Main", "/shared_stuff", LibraryLoad::Started); recorder .emitter() - .library_load("Main", "## shared_stuff", LibraryLoad::Succeeded); + .library_load("Main", "/shared_stuff", LibraryLoad::Succeeded); recorder .emitter() - .library_load("Main", "## helpers", LibraryLoad::Failed); + .library_load("Main", "/helpers", LibraryLoad::Failed); assert_eq!( recorder.observations(), [ @@ -24,21 +24,21 @@ fn a_library_load_folds_to_its_kind_with_the_library() { "Main".to_owned(), Observation::Library { kind: "lua_library_load_started".to_owned(), - library: "## shared_stuff".to_owned(), + library: "/shared_stuff".to_owned(), }, ), ( "Main".to_owned(), Observation::Library { kind: "lua_library_load_succeeded".to_owned(), - library: "## shared_stuff".to_owned(), + library: "/shared_stuff".to_owned(), }, ), ( "Main".to_owned(), Observation::Library { kind: "lua_library_load_failed".to_owned(), - library: "## helpers".to_owned(), + library: "/helpers".to_owned(), }, ), ] diff --git a/crates/runvil-internal/lua/src/tests/markdown.rs b/crates/runvil-internal/lua/src/tests/markdown.rs new file mode 100644 index 000000000..f6f1f9041 --- /dev/null +++ b/crates/runvil-internal/lua/src/tests/markdown.rs @@ -0,0 +1,128 @@ +//! The `markdown` global: `markdown.parse` over text, `markdown.section` +//! and `markdown.section_or` over a path and a selector, +//! `markdown.select`, `markdown.render`, and the same functions over +//! blocks read back from `var`. + +use super::*; + +mod parse; +mod render; +mod section; +mod select; +mod views; + +/// A document with every kind of block: frontmatter, prose before the +/// first heading, three levels of section, both kinds of list, fences +/// with and without extra info words, and a rule. +const GUIDE: &str = r#"--- +title: Notes +tags: [a, b] +--- + +Preface paragraph. + +# Guide + +Intro text +on two lines. + +- alpha +- beta + +## Templates + +Use these. + +```lua +return 1 +``` + +```markdown +# Hello +``` + +Then the fallback: + +```json strict +{ "ok": true } +``` + +### Extras + +--- + +```lua +return 2 +``` + +## Usage + +1. first +2. second +"#; + +/// A document whose two `Entry` sections share a parent and a title. +const LOG: &str = "# Log\n\n## Entry\n\nfirst\n\n## Entry\n\nsecond\n"; + +/// A document whose two `Items` sections sit under different parents. +const ITEMS: &str = "# H1\n\n## H2\n\n### Items\n\n- a\n- b\n\n## H3\n\n### Items\n\n- c\n"; + +/// A document whose outer and inner section share the title `A`, and +/// whose two `B` sections sit at different depths. +const NESTED: &str = "# A\n## A\n### B\n## B\n"; + +/// A test VM that binds each sample's parse: `md` for [`GUIDE`], with +/// `guide`, `templates`, and `usage` naming its sections, `log` for +/// [`LOG`], `items` for [`ITEMS`], and `nested` for [`NESTED`]. +#[track_caller] +fn markdown_vm() -> BuiltVm { + let vm = TestVm::new().shims().build(); + bind_samples(&vm); + vm +} + +/// Binds the samples of [`markdown_vm`] on `vm`. +#[track_caller] +fn bind_samples(vm: &BuiltVm) { + let globals = vm.lua().globals(); + for (name, text) in [ + ("guide_text", GUIDE), + ("log_text", LOG), + ("items_text", ITEMS), + ("nested_text", NESTED), + ] { + globals.raw_set(name, text).expect("the sample binds"); + } + vm.exec( + "md = markdown.parse(guide_text)\n\ + guide = md[3]\n\ + templates = guide.blocks[3]\n\ + usage = guide.blocks[4]\n\ + log = markdown.parse(log_text)\n\ + items = markdown.parse(items_text)\n\ + nested = markdown.parse(nested_text)", + ); +} + +/// Asserts that each Lua expression gives the value its JSON text names, +/// where `nil` stands for a nil result. +#[track_caller] +fn assert_values(vm: &BuiltVm, cases: &[(&str, &str)]) { + for (expression, want) in cases { + let got: String = vm.eval(&format!( + "local value = {expression}\n\ + if value == nil then return 'nil' end\n\ + return json.encode(value)" + )); + assert_eq!(got, *want, "{expression}"); + } +} + +/// Asserts that each Lua expression raises a `lua` error whose text is +/// the message beside it. +#[track_caller] +fn assert_raises(vm: &BuiltVm, cases: &[(&str, &str)]) { + for (expression, message) in cases { + assert_eq!(caught(vm, expression), lua_error(message), "{expression}"); + } +} diff --git a/crates/runvil-internal/lua/src/tests/markdown/parse.rs b/crates/runvil-internal/lua/src/tests/markdown/parse.rs new file mode 100644 index 000000000..99010c258 --- /dev/null +++ b/crates/runvil-internal/lua/src/tests/markdown/parse.rs @@ -0,0 +1,240 @@ +//! `markdown.parse`: the tables a text becomes, their shape, and the +//! arguments it refuses. + +use super::*; + +#[test] +fn a_document_parses_to_plain_tables_that_ordinary_indexing_reads() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ("#md", "3"), + ("md[1].kind", r#""frontmatter""#), + ("md[1].data.title", r#""Notes""#), + ("md[1].data.tags[2]", r#""b""#), + ("md[2].text", r#""Preface paragraph.""#), + ("md[2].line", "6"), + ("md[4]", "nil"), + ("md.kind", "nil"), + ("guide.kind", r#""section""#), + ("guide.level", "1"), + ("guide.title", r#""Guide""#), + ("guide.line", "8"), + ("#guide.blocks", "4"), + ("guide.blocks[1].text", r#""Intro text\non two lines.""#), + ("guide.blocks[2].ordered", "false"), + ("guide.blocks[2].items[1]", r#""alpha""#), + ("#guide.blocks[2].items", "2"), + ("templates.title", r#""Templates""#), + ("templates.blocks[2].lang", r#""lua""#), + ("templates.blocks[5].lang", r#""json""#), + ("templates.blocks[5].info", r#""json strict""#), + ("templates.blocks[5].text", r#""{ \"ok\": true }""#), + ("templates.blocks[6].title", r#""Extras""#), + ( + "templates.blocks[6].blocks[1]", + r#"{"kind":"rule","line":36,"source":"---\n\n"}"#, + ), + ("templates.blocks[2].title", "nil"), + ("usage.blocks[1].ordered", "true"), + ("usage.blocks[1].line", "44"), + ], + ); + let kinds: String = vm.eval( + "local kinds = {}\n\ + for index, block in ipairs(md) do kinds[index] = block.kind end\n\ + return table.concat(kinds, ',')", + ); + assert_eq!(kinds, "frontmatter,prose,section"); +} + +#[test] +fn every_block_holds_the_lines_it_was_read_from_and_the_blank_lines_after_them() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ( + "md[1].source", + r#""---\ntitle: Notes\ntags: [a, b]\n---\n\n""#, + ), + ("md[2].source", r#""Preface paragraph.\n\n""#), + // A section's source is its heading's, and the blocks under + // it hold their own. + ("guide.source", r##""# Guide\n\n""##), + ( + "guide.blocks[1].source", + r#""Intro text\non two lines.\n\n""#, + ), + ("guide.blocks[2].source", r#""- alpha\n- beta\n\n""#), + ("templates.source", r###""## Templates\n\n""###), + ( + "templates.blocks[2].source", + r#""```lua\nreturn 1\n```\n\n""#, + ), + ( + "templates.blocks[5].source", + r#""```json strict\n{ \"ok\": true }\n```\n\n""#, + ), + ("templates.blocks[6].source", r####""### Extras\n\n""####), + // The last block of the text ends where the text does. + ("usage.blocks[1].source", r#""1. first\n2. second\n""#), + // Blank lines between two headings are the upper heading's, + // so the lower one is still its first member. + ("items[1].source", r##""# H1\n\n""##), + ("items[1].blocks[1].title", r#""H2""#), + ("nested[1].source", r##""# A\n""##), + ], + ); +} + +#[test] +fn the_source_keeps_what_the_other_fields_leave_out() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + // A list keeps its markers and its numbers. + ( + "markdown.parse('3) x\\n4) y\\n')[1].source", + r#""3) x\n4) y\n""#, + ), + ("markdown.parse('3) x\\n4) y\\n')[1].items", r#"["x","y"]"#), + // A heading keeps its markup and its spelling. + ( + "markdown.parse('See [API](x) *now*\\n===\\n')[1].source", + r#""See [API](x) *now*\n===\n""#, + ), + ( + "markdown.parse('See [API](x) *now*\\n===\\n')[1].title", + r#""See API now""#, + ), + // Frontmatter keeps its comments and the order of its keys. + ( + "markdown.parse('---\\n# why\\nb: 2\\na: 1\\n---\\n')[1].source", + r#""---\n# why\nb: 2\na: 1\n---\n""#, + ), + // Blank lines at the top of the text are in the first block's + // source, and its line is still its own. + ("markdown.parse('\\n\\n# T\\n')[1].source", r#""\n\n# T\n""#), + ("markdown.parse('\\n\\n# T\\n')[1].line", "3"), + ], + ); +} + +#[test] +fn a_parse_result_is_plain_data_that_json_encodes_and_decodes_whole() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ( + "usage", + r###"{"blocks":[{"items":["first","second"],"kind":"list","line":44,"ordered":true,"source":"1. first\n2. second\n"}],"kind":"section","level":2,"line":42,"source":"## Usage\n\n","title":"Usage"}"###, + ), + // The whole document survives a trip through JSON text. + ( + "json.encode(json.decode(json.encode(md))) == json.encode(md)", + "true", + ), + // No table has a metatable but the list marker, so none has + // methods. + ("getmetatable(guide)", "nil"), + ("getmetatable(md) == getmetatable(json.array())", "true"), + ], + ); +} + +#[test] +fn every_list_is_marked_so_an_empty_one_encodes_as_an_array() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ("markdown.parse('')", "[]"), + // Text of blank lines alone has no block to hold them. + ("markdown.parse('\\n\\n \\n')", "[]"), + ("markdown.parse('## A\\n')[1].blocks", "[]"), + ( + "markdown.parse('---\\ntags: []\\nnone: ~\\n---\\n')[1].data", + r#"{"none":null,"tags":[]}"#, + ), + ( + "markdown.parse('---\\nnone: ~\\n---\\n')[1].data.none == json.null", + "true", + ), + ("markdown.parse('-\\n- b\\n')[1].items", r#"["","b"]"#), + ], + ); +} + +#[test] +fn text_is_read_raw_and_every_string_uses_newlines_only() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + // A placeholder comes back as written. + ( + "markdown.parse('Hello {{ name }}')[1].text", + r#""Hello {{ name }}""#, + ), + ( + "markdown.parse('# T\\r\\n\\r\\none\\r\\ntwo\\r\\n')[1].blocks[1].text", + r#""one\ntwo""#, + ), + ( + "markdown.parse('# T\\r\\n\\r\\none\\r\\ntwo\\r\\n')[1].source", + r##""# T\n\n""##, + ), + ("markdown.parse('\\239\\187\\191# T')[1].title", r#""T""#), + ( + "markdown.parse('\\239\\187\\191# T')[1].source", + r##""# T""##, + ), + // A new parse counts lines from 1. + ("markdown.parse(templates.blocks[3].text)[1].line", "1"), + ( + "markdown.parse(templates.blocks[3].text)[1].kind", + r#""section""#, + ), + // An item's text parses again as Markdown. + ( + "markdown.parse(markdown.parse('- a\\n - a1\\n - a2\\n')[1].items[1])[2].items", + r#"["a1","a2"]"#, + ), + ], + ); +} + +#[test] +fn parse_refuses_a_value_that_is_not_utf8_text_within_the_limit() { + let vm = markdown_vm(); + assert_raises( + &vm, + &[ + ( + "markdown.parse(42)", + "markdown.parse expects a string, got integer", + ), + ( + "markdown.parse()", + "markdown.parse expects a string, got nil", + ), + ( + "markdown.parse({})", + "markdown.parse expects a string, got table", + ), + ( + "markdown.parse('ok \\255')", + "markdown.parse: text is not valid UTF-8", + ), + ( + "markdown.parse(string.rep('x', 8388609))", + "markdown.parse: input exceeds the 8388608-byte limit", + ), + ], + ); + assert_values(&vm, &[("#markdown.parse(string.rep('x', 8388608))", "1")]); +} diff --git a/crates/runvil-internal/lua/src/tests/markdown/render.rs b/crates/runvil-internal/lua/src/tests/markdown/render.rs new file mode 100644 index 000000000..14e1b66a8 --- /dev/null +++ b/crates/runvil-internal/lua/src/tests/markdown/render.rs @@ -0,0 +1,340 @@ +//! `markdown.render`: a parse written back as the text it was read from, +//! the one field it writes, the two line endings it adds, and the lists +//! it refuses. + +use super::*; + +#[test] +fn a_parsed_document_is_written_back_as_the_text_it_was_read_from() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ("markdown.render(md) == guide_text", "true"), + ("markdown.render(log) == log_text", "true"), + ("markdown.render(items) == items_text", "true"), + ("markdown.render(nested) == nested_text", "true"), + ("markdown.render({})", r#""""#), + // Whatever a rewriting writer would change comes back as it + // was: numbers, markers, fences, rules, headings, blank lines. + ( + "(function()\n\ + local text = '3) c\\n4) d\\n\\n\\n* x\\n\\n~~~ ~`\\ncode\\n~~~\\n\ + ___\\nTitle\\n=====\\n\\n\\n\\nlast'\n\ + return markdown.render(markdown.parse(text)) == text\n\ + end)()", + "true", + ), + // Line endings are written as they were read, as `\n`, and a + // byte order mark is not written. + ( + "markdown.render(markdown.parse('\\239\\187\\191# T\\r\\n\\r\\nx\\r\\n'))", + r##""# T\n\nx\n""##, + ), + // Text of blank lines alone has no block, so nothing is written. + ("markdown.render(markdown.parse('\\n\\n'))", r#""""#), + ], + ); +} + +#[test] +fn the_sources_of_every_block_in_file_order_join_to_the_text() { + let vm = markdown_vm(); + vm.exec( + "function joined(blocks)\n\ + local parts = {}\n\ + for index, block in ipairs(markdown.select(blocks, '*')) do\n\ + parts[index] = block.source\n\ + end\n\ + return table.concat(parts)\n\ + end", + ); + assert_values( + &vm, + &[ + ("joined(md) == guide_text", "true"), + ("joined(log) == log_text", "true"), + ("joined(items) == items_text", "true"), + ("joined(nested) == nested_text", "true"), + // The join of a section's blocks is what render writes for them. + ( + "joined(templates.blocks) == markdown.render(templates.blocks)", + "true", + ), + ], + ); +} + +#[test] +fn a_sections_blocks_are_written_without_its_heading() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ( + "markdown.render(usage.blocks)", + r#""1. first\n2. second\n""#, + ), + // Wrapping the section in a list writes the heading too. + ( + "markdown.render({ usage })", + r###""## Usage\n\n1. first\n2. second\n""###, + ), + ( + "markdown.render({ md[1], md[2] })", + r#""---\ntitle: Notes\ntags: [a, b]\n---\n\nPreface paragraph.\n\n""#, + ), + ( + "markdown.render(markdown.section(md, '//Extras').blocks)", + r#""---\n\n```lua\nreturn 2\n```\n\n""#, + ), + // The blocks of a section and of its subsections, in order. + ( + "markdown.render({ templates }) == \ + guide_text:sub(guide_text:find('## Templates', 1, true), \ + guide_text:find('## Usage', 1, true) - 1)", + "true", + ), + ], + ); +} + +#[test] +fn only_the_source_is_written_so_an_edit_of_another_field_changes_nothing() { + let vm = markdown_vm(); + vm.exec( + "local list = markdown.section(md, '/Guide/Usage', 'list')\n\ + list.items[#list.items + 1] = 'third'\n\ + list.ordered = false\n\ + usage.title = 'How to use it'\n\ + usage.level = 5\n\ + templates.blocks[2].text = 'return 99'\n\ + md[1].data.title = 'Other'", + ); + assert_values(&vm, &[("markdown.render(md) == guide_text", "true")]); + // An edit of the source is what is written. + vm.exec( + "usage.source = '## How to use it\\n\\n'\n\ + usage.blocks[1].source = '1. first\\n2. second\\n3. third\\n'", + ); + assert_values( + &vm, + &[( + "markdown.render({ usage })", + r###""## How to use it\n\n1. first\n2. second\n3. third\n""###, + )], + ); +} + +#[test] +fn a_document_with_one_sections_blocks_replaced_is_written_back_otherwise_unchanged() { + let vm = markdown_vm(); + vm.exec( + "local entry = markdown.section(log, '/Log/Entry[1]')\n\ + entry.blocks = { { kind = 'prose', source = 'changed\\n\\n' } }", + ); + assert_values( + &vm, + &[( + "markdown.render(log)", + r##""# Log\n\n## Entry\n\nchanged\n\n## Entry\n\nsecond\n""##, + )], + ); + // A heading rewritten with the blank line after it, and a block added + // to the end of the section's list. + vm.exec( + "usage.source = '## How to use it\\n\\n'\n\ + table.insert(usage.blocks, { kind = 'prose', source = '\\nDone.\\n' })", + ); + assert_values( + &vm, + &[ + ( + "markdown.render({ usage })", + r###""## How to use it\n\n1. first\n2. second\n\nDone.\n""###, + ), + ("#usage.blocks", "2"), + ], + ); +} + +#[test] +fn a_block_built_by_hand_is_written_by_its_source_alone() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + // No field but the source is needed, a kind included. + ( + "markdown.render({ { source = '# T\\n\\n' }, { kind = 'note', source = 'x\\n' } })", + r##""# T\n\nx\n""##, + ), + // A block's other fields are not checked. + ( + "markdown.render({ { kind = 'list', line = 'nowhere', items = 7, source = '- a\\n' } })", + r#""- a\n""#, + ), + // A section with no `blocks` field holds nothing, and one + // with no source of its own still has its blocks written. + ( + "markdown.render({ { kind = 'section', source = '# A\\n' }, \ + { kind = 'section', source = '', blocks = { { kind = 'rule', source = '---\\n' } } } })", + r##""# A\n---\n""##, + ), + // Only a section's `blocks` are written. + ( + "markdown.render({ { kind = 'prose', source = 'x\\n', blocks = { md[2] } } })", + r#""x\n""#, + ), + // Blocks of two parses, and blocks built by hand, in one list. + ( + "markdown.render({ log[1].blocks[2], { source = '***\\n\\n' }, usage })", + r###""## Entry\n\nsecond\n***\n\n## Usage\n\n1. first\n2. second\n""###, + ), + ], + ); +} + +#[test] +fn every_block_starts_on_a_new_line() { + let vm = markdown_vm(); + // The last block of a text has no line ending when the text has none. + vm.exec( + "one = markdown.parse('# A\\nlast')\n\ + two = markdown.parse('# B\\n\\n- x')", + ); + assert_values( + &vm, + &[ + ("one[1].blocks[1].source", r#""last""#), + ( + "markdown.render({ one[1], two[1], one[1] })", + r##""# A\nlast\n# B\n\n- x\n# A\nlast""##, + ), + // A source edited to end without one. + ( + "markdown.render({ { kind = 'section', source = '## New' }, \ + { kind = 'fence', source = '```\\nx\\n```' }, { kind = 'rule', source = '***' } })", + r###""## New\n```\nx\n```\n***""###, + ), + // Two prose blocks built by hand get the blank line too. + ( + "markdown.render({ { kind = 'prose', source = 'one\\n' }, \ + { kind = 'prose', source = 'two\\n' } })", + r#""one\n\ntwo\n""#, + ), + ], + ); +} + +#[test] +fn a_blank_line_sits_between_two_prose_blocks_and_nowhere_else() { + let vm = markdown_vm(); + vm.exec("mixed = markdown.parse('one\\n***\\ntwo\\n```\\nx\\n```\\nthree')"); + assert_values( + &vm, + &[ + // No prose block of this text has a blank line after it. + ("#markdown.select(mixed, 'prose')", "3"), + ( + "markdown.render(markdown.select(mixed, 'prose'))", + r#""one\n\ntwo\n\nthree""#, + ), + // Nothing is added where the blank line is already there. + ( + "markdown.render(markdown.select(md, 'prose'))", + r#""Preface paragraph.\n\nIntro text\non two lines.\n\nUse these.\n\nThen the fallback:\n\n""#, + ), + // The last prose block of a section, then prose after it. + ( + "markdown.render({ log[1].blocks[2], mixed[1] })", + r###""## Entry\n\nsecond\n\none\n""###, + ), + // No other two kinds are parted: the blocks of the text are + // written as they were, and so are two lists. + ( + "markdown.render(mixed)", + r#""one\n***\ntwo\n```\nx\n```\nthree""#, + ), + ( + "markdown.render({ usage.blocks[1], usage.blocks[1] })", + r#""1. first\n2. second\n1. first\n2. second\n""#, + ), + ( + "markdown.render({ mixed[1], usage.blocks[1], mixed[3] })", + r#""one\n1. first\n2. second\ntwo\n""#, + ), + ], + ); +} + +/// Asserts that `markdown.render` refuses each list of blocks with the +/// message beside it. +#[track_caller] +fn assert_refused(cases: &[(&str, &str)]) { + let vm = markdown_vm(); + for (blocks, message) in cases { + assert_raises( + &vm, + &[( + &format!("markdown.render({blocks})"), + &format!("markdown.render: {message}"), + )], + ); + } +} + +#[test] +fn render_refuses_a_block_with_no_source_to_write_and_names_it() { + assert_refused(&[ + ( + "{ { kind = 'prose', text = 'x' } }", + "blocks[1].source must be a string, got nil", + ), + ( + "{ md[2], { kind = 'rule' } }", + "blocks[2].source must be a string, got nil", + ), + ( + "{ { kind = 'section', source = '# A\\n', blocks = { md[2], { kind = 'prose' } } } }", + "blocks[1].blocks[2].source must be a string, got nil", + ), + ( + "{ { kind = 'prose', source = 5 } }", + "blocks[1].source must be a string, got integer", + ), + ( + "{ { kind = 'prose', source = { 'x' } } }", + "blocks[1].source must be a string, got table", + ), + ( + "{ { kind = 'prose', source = 'ok \\255' } }", + "blocks[1].source is not valid UTF-8", + ), + ]); +} + +#[test] +fn render_refuses_a_value_that_is_not_a_list_of_blocks() { + assert_refused(&[ + ( + "guide", + "blocks must be a list of blocks, got a section; pass its blocks field", + ), + ( + "md[2]", + "blocks must be a list of blocks, got a prose block", + ), + ("'text'", "blocks must be a list of blocks, got string"), + ("nil", "blocks must be a list of blocks, got nil"), + ("{ 'text' }", "blocks[1] must be a table, got string"), + ( + "{ { kind = 'section', source = '# A\\n', blocks = 5 } }", + "blocks[1].blocks must be a list of blocks, got integer", + ), + ( + "{ { kind = 'section', source = '# A\\n', blocks = usage } }", + "blocks[1].blocks must be a list of blocks, got a section; pass its blocks field", + ), + ]); +} diff --git a/crates/runvil-internal/lua/src/tests/markdown/section.rs b/crates/runvil-internal/lua/src/tests/markdown/section.rs new file mode 100644 index 000000000..548aa52c9 --- /dev/null +++ b/crates/runvil-internal/lua/src/tests/markdown/section.rs @@ -0,0 +1,621 @@ +//! `markdown.section` and `markdown.section_or`: the section a path +//! names, the block a selector picks, what is a miss and what is a +//! mistake, and the scope and identity of what comes back. + +use super::*; + +#[test] +fn a_path_names_one_section_from_the_top_of_the_list() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ("markdown.section(md, '/Guide').title", r#""Guide""#), + ("markdown.section(md, '/Guide/Templates').line", "16"), + ("markdown.section(md, '/Guide/Templates/Extras').level", "3"), + ("markdown.section(md, '//Extras').line", "34"), + ("markdown.section(md, '//Templates/Extras').line", "34"), + ("markdown.section(md, '/Guide//Extras').line", "34"), + ("markdown.section(md, '//Guide').line", "8"), + // Whitespace around the path and around each step is ignored. + ("markdown.section(md, ' / Guide / Templates ').line", "16"), + // A path starts at the list it is given. + ("markdown.section(guide.blocks, '/Usage').line", "42"), + ("markdown.section_or(md, '/Guide').title", r#""Guide""#), + ], + ); +} + +#[test] +fn a_path_that_names_no_section_raises_from_section_and_is_nil_from_section_or() { + let vm = markdown_vm(); + let misses = [ + ( + "/Templates", + r#"path "/Templates" matches no section; step 1, "Templates", matches nothing"#, + ), + // Titles are case-sensitive. + ( + "/guide", + r#"path "/guide" matches no section; step 1, "guide", matches nothing"#, + ), + ( + "/Guide/Nope", + r#"path "/Guide/Nope" matches no section; step 2, "Nope", matches nothing"#, + ), + ( + "//Nope", + r#"path "//Nope" matches no section; step 1, "//Nope", matches nothing"#, + ), + // A step in the middle that keeps nothing is where the path stops. + ( + "/Nope/Templates", + r#"path "/Nope/Templates" matches no section; step 1, "Nope", matches nothing"#, + ), + ]; + for (path, message) in misses { + assert_raises( + &vm, + &[( + &format!("markdown.section(md, '{path}')"), + &format!("markdown.section: {message}"), + )], + ); + assert_values( + &vm, + &[(&format!("markdown.section_or(md, '{path}')"), "nil")], + ); + } +} + +#[test] +fn a_step_index_picks_among_sections_that_share_a_title() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ("markdown.section(log, '/Log/Entry[1]').line", "3"), + ("markdown.section(log, '/Log/Entry[2]').line", "7"), + ("markdown.section(log, '/Log/Entry[-1]').line", "7"), + ("markdown.section(log, '/Log[1]/Entry[2]').line", "7"), + ("markdown.section_or(log, '/Log/Entry[3]')", "nil"), + ( + "markdown.section(log, '/Log/Entry[2]', 'prose').text", + r#""second""#, + ), + ], + ); + let ambiguous = r#"path "/Log/Entry" is ambiguous; it matches 2 sections, at lines 3 and 7; add an index such as "/Log/Entry[1]""#; + assert_raises( + &vm, + &[ + ( + "markdown.section(log, '/Log/Entry')", + &format!("markdown.section: {ambiguous}"), + ), + // Several matches is a choice, not an absence, so both raise. + ( + "markdown.section_or(log, '/Log/Entry')", + &format!("markdown.section_or: {ambiguous}"), + ), + ( + "markdown.section(log, '/Log/Entry[3]')", + r#"markdown.section: path "/Log/Entry[3]" matches no section; step 2, "Entry[3]", matches nothing"#, + ), + ], + ); +} + +#[test] +fn each_step_is_applied_to_the_whole_tree_in_file_order() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ("markdown.section(nested, '/A/A/B').line", "3"), + ("markdown.section(nested, '/A/B').line", "4"), + // File order, not the order the A sections were found in. + ("markdown.section(nested, '//A/B[1]').line", "3"), + ("markdown.section(nested, '//A/B[2]').line", "4"), + // The inner B counts once, though both A sections reach it. + ("markdown.section(nested, '//A//B[1]').line", "3"), + ("markdown.section(nested, '//A//B[2]').line", "4"), + ("markdown.section_or(nested, '//A//B[3]')", "nil"), + ("markdown.section(nested, '//B[1]').line", "3"), + ("markdown.section(nested, '//A[2]/B').line", "3"), + ("markdown.section(nested, '//A[1]/B').line", "4"), + ("markdown.section(items, '/H1/H2/Items').line", "5"), + ("markdown.section(items, '/H1/H3/Items').line", "12"), + ("markdown.section(items, '//H3/Items').line", "12"), + ("markdown.section(items, '//Items[1]').line", "5"), + ("markdown.section(items, '//Items[2]').line", "12"), + ("markdown.section(items, '//H1//Items[2]').line", "12"), + ("markdown.section_or(items, '/Items')", "nil"), + ], + ); + assert_raises( + &vm, + &[ + ( + "markdown.section(nested, '//A/B')", + r#"markdown.section: path "//A/B" is ambiguous; it matches 2 sections, at lines 3 and 4; add an index such as "//A/B[1]""#, + ), + ( + "markdown.section_or(items, '//Items')", + r#"markdown.section_or: path "//Items" is ambiguous; it matches 2 sections, at lines 5 and 12; add an index such as "//Items[1]""#, + ), + ( + "markdown.section(nested, '/A/C/B')", + r#"markdown.section: path "/A/C/B" matches no section; step 2, "C", matches nothing"#, + ), + ( + "markdown.section(nested, '//A[3]/B')", + r#"markdown.section: path "//A[3]/B" matches no section; step 1, "//A[3]", matches nothing"#, + ), + ], + ); +} + +#[test] +fn a_title_is_matched_as_the_path_grammar_reads_it() { + let vm = markdown_vm(); + vm.exec( + "titles = markdown.parse('# The *big* `foo` API\\n\\n## Input/Output\\n\\n\ + ## Appendix [2]\\n\\n## [Draft] Notes\\n\\n## C:\\\\\\\\Users\\n\\n## Notes [old]\\n')", + ); + assert_values( + &vm, + &[ + ("markdown.section(titles, '/The big foo API').line", "1"), + ("markdown.section(titles, [[//Input\\/Output]]).line", "3"), + ("markdown.section(titles, [=[//Appendix \\[2]]=]).line", "5"), + // Unescaped, the bracketed number is an index. + ("markdown.section_or(titles, '//Appendix [2]')", "nil"), + ("markdown.section(titles, '//[Draft] Notes').line", "7"), + ("markdown.section(titles, [[//C:\\\\Users]]).line", "9"), + ("markdown.section(titles, '//Notes [old]').line", "11"), + ("markdown.section(titles, '//Notes [old] [1]').line", "11"), + ], + ); +} + +#[test] +fn a_selector_picks_the_one_block_of_its_kind_in_the_sections_own_blocks() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ( + "markdown.section(md, '/Guide/Templates', 'fence markdown').text", + r##""# Hello""##, + ), + // The other lua fence is in Extras, and a subsection's blocks + // are not counted. + ( + "markdown.section(md, '/Guide/Templates', 'fence lua').text", + r#""return 1""#, + ), + ( + "markdown.section(md, '/Guide/Templates', 'section').title", + r#""Extras""#, + ), + ( + "markdown.section(md, '/Guide', 'list').items[2]", + r#""beta""#, + ), + ( + "markdown.section(md, '//Extras', 'fence').text", + r#""return 2""#, + ), + ( + "markdown.section(items, '/H1/H2/Items', 'list').items", + r#"["a","b"]"#, + ), + // Extra spaces in the selector are ignored. + ( + "markdown.section(md, '/Guide/Templates', ' fence markdown ').lang", + r#""markdown""#, + ), + ( + "markdown.section_or(md, '/Guide/Templates', 'fence markdown').text", + r##""# Hello""##, + ), + ], + ); +} + +#[test] +fn an_index_picks_among_several_blocks() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ( + "markdown.section(md, '/Guide/Templates', 'fence', 3).text", + r#""{ \"ok\": true }""#, + ), + ( + "markdown.section(md, '/Guide/Templates', 'fence', -1).lang", + r#""json""#, + ), + ( + "markdown.section(md, '/Guide/Templates', 'fence', -3).line", + "20", + ), + ( + "markdown.section(md, '/Guide/Templates', 'prose', 2).text", + r#""Then the fallback:""#, + ), + // An index is allowed when there is one match. + ( + "markdown.section(md, '/Guide/Templates', 'fence markdown', 1).text", + r##""# Hello""##, + ), + // A number with an integer value is an index. + ( + "markdown.section(md, '/Guide/Templates', 'fence', 2.0).line", + "24", + ), + // A lookup scoped to a section still reports lines of the + // whole text. + ( + "markdown.section(guide.blocks, '/Templates', 'fence', 3).line", + "30", + ), + ], + ); + // A loop counts upward with section_or until it returns nil. + let langs: String = vm.eval( + "local langs, n = {}, 1\n\ + while true do\n\ + local fence = markdown.section_or(md, '/Guide/Templates', 'fence', n)\n\ + if not fence then break end\n\ + langs[n] = fence.lang\n\ + n = n + 1\n\ + end\n\ + return table.concat(langs, ',')", + ); + assert_eq!(langs, "lua,markdown,json"); +} + +#[test] +fn a_star_picks_among_every_block_of_the_sections_own_blocks() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + // Usage holds one block, so no index is needed. + ("markdown.section(md, '/Guide/Usage', '*').ordered", "true"), + ( + "markdown.section(md, '/Guide/Templates', '*', 1).text", + r#""Use these.""#, + ), + ( + "markdown.section(md, '/Guide/Templates', '*', 2).lang", + r#""lua""#, + ), + // A subsection is one of its section's blocks, and the blocks + // under it are not counted. + ( + "markdown.section(md, '/Guide/Templates', '*', -1).title", + r#""Extras""#, + ), + ("markdown.section_or(md, '/Guide/Templates', '*', 7)", "nil"), + ("markdown.section(md, '/Guide', '*', 4) == usage", "true"), + ], + ); + assert_raises( + &vm, + &[ + ( + "markdown.section(md, '/Guide', '*')", + r#"markdown.section: selector "*" matches 4 blocks, at lines 10, 13, 16, and 42; add an index, such as 1 for the first"#, + ), + ( + "markdown.section(md, '/Guide/Templates', '*', 7)", + r#"markdown.section: selector "*" matches 6 blocks in "/Guide/Templates"; there is no block 7"#, + ), + ( + "markdown.section(nested, '//B[1]', '*')", + r#"markdown.section: no block in "//B[1]" matches selector "*""#, + ), + ], + ); +} + +#[test] +fn a_block_that_is_not_there_raises_from_section_and_is_nil_from_section_or() { + let vm = markdown_vm(); + let misses = [ + ( + "'/Guide/Templates', 'list'", + r#"no block in "/Guide/Templates" matches selector "list""#, + ), + // Every fence there has a language. + ( + r#"'/Guide/Templates', 'fence ""'"#, + r#"no block in "/Guide/Templates" matches selector "fence """"#, + ), + ( + "'/Guide/Templates', 'fence', 4", + r#"selector "fence" matches 3 blocks in "/Guide/Templates"; there is no block 4"#, + ), + ( + "'/Guide/Templates', 'fence', -4", + r#"selector "fence" matches 3 blocks in "/Guide/Templates"; there is no block -4"#, + ), + // The second lua fence is in Extras. + ( + "'/Guide/Templates', 'fence lua', 2", + r#"selector "fence lua" matches 1 block in "/Guide/Templates"; there is no block 2"#, + ), + // A section's own blocks hold no frontmatter. + ( + "'/Guide', 'frontmatter'", + r#"no block in "/Guide" matches selector "frontmatter""#, + ), + // No such section. + ( + "'/Guide/Nope', 'fence'", + r#"path "/Guide/Nope" matches no section; step 2, "Nope", matches nothing"#, + ), + ]; + for (arguments, message) in misses { + assert_raises( + &vm, + &[( + &format!("markdown.section(md, {arguments})"), + &format!("markdown.section: {message}"), + )], + ); + assert_values( + &vm, + &[(&format!("markdown.section_or(md, {arguments})"), "nil")], + ); + } +} + +#[test] +fn several_matches_without_an_index_raise_from_both() { + let vm = markdown_vm(); + let three = r#"selector "fence" matches 3 blocks, at lines 20, 24, and 30; add an index, such as 1 for the first"#; + let two = r#"selector "prose" matches 2 blocks, at lines 18 and 28; add an index, such as 1 for the first"#; + assert_raises( + &vm, + &[ + ( + "markdown.section(md, '/Guide/Templates', 'fence')", + &format!("markdown.section: {three}"), + ), + ( + "markdown.section_or(md, '/Guide/Templates', 'fence')", + &format!("markdown.section_or: {three}"), + ), + ( + "markdown.section(md, '/Guide/Templates', 'prose')", + &format!("markdown.section: {two}"), + ), + ( + "markdown.section_or(md, '/Guide/Templates', 'prose')", + &format!("markdown.section_or: {two}"), + ), + ], + ); +} + +#[test] +fn a_malformed_path_selector_or_index_raises_from_both_whether_or_not_the_section_is_there() { + let vm = markdown_vm(); + for function in ["markdown.section", "markdown.section_or"] { + let cases = [ + ( + "md, 'Guide/Setup'", + r#"a path starts with "/", got "Guide/Setup""#.to_owned(), + ), + ( + "md, '/Guide/../Usage'", + r#"path "/Guide/../Usage" uses ".."; parsed text has no parent to step to"# + .to_owned(), + ), + ( + "md, '/Guide/'", + r#"path "/Guide/" has an empty step"#.to_owned(), + ), + ("md, '///a'", r#"path "///a" has an empty step"#.to_owned()), + ("md, ''", r#"path "" has an empty step"#.to_owned()), + ( + r"md, [[/a\b]]", + r#"path "/a\b" has an unknown escape "\b"; the escapes are "\/", "\\", and "\[""# + .to_owned(), + ), + ( + "log, '/Log/Entry[0]'", + r#"path "/Log/Entry[0]" has index 0; an index is a nonzero integer"#.to_owned(), + ), + ("md, 42", "path must be a string, got integer".to_owned()), + ("md", "path must be a string, got nil".to_owned()), + ( + "md, '/Guide', 'fences', 1", + r#"unknown kind "fences"; kinds are frontmatter, section, prose, fence, list, rule, and * selects every block"# + .to_owned(), + ), + ( + "md, '/Guide/Nope', 'fences', 1", + r#"unknown kind "fences"; kinds are frontmatter, section, prose, fence, list, rule, and * selects every block"# + .to_owned(), + ), + ( + "md, '/Guide', 'list lua', 1", + r#"only a fence selector takes a language, got "list lua""#.to_owned(), + ), + ( + "md, '/Guide', 'fence a b'", + r#"a fence selector takes one language, got "fence a b""#.to_owned(), + ), + ( + "md, '/Guide', 7", + "selector must be a string, got integer".to_owned(), + ), + ( + "md, '/Guide', nil, 1", + "an index needs a selector before it".to_owned(), + ), + ( + "md, '/Guide', 'fence', 0", + "index must be a nonzero integer, got 0".to_owned(), + ), + ( + "md, '/Guide/Nope', 'fence', 0", + "index must be a nonzero integer, got 0".to_owned(), + ), + ( + "md, '/Guide', 'fence', 1.5", + "index must be a nonzero integer, got 1.5".to_owned(), + ), + ( + "md, '/Guide', 'fence', '1'", + "index must be a nonzero integer, got string".to_owned(), + ), + ]; + for (arguments, message) in cases { + assert_raises( + &vm, + &[( + &format!("{function}({arguments})"), + &format!("{function}: {message}"), + )], + ); + } + } +} + +#[test] +fn a_lookup_sees_only_the_list_it_is_given_and_a_section_is_not_a_list() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ("markdown.section(guide.blocks, '/Usage').line", "42"), + // A section is not inside its own blocks. + ("markdown.section_or(guide.blocks, '/Guide')", "nil"), + ("markdown.section_or(guide.blocks, '/Guide/Usage')", "nil"), + // An empty list holds no section. + ("markdown.section_or({}, '/Guide')", "nil"), + ("markdown.section_or(json.array(), '//Guide')", "nil"), + ], + ); + for function in ["markdown.section", "markdown.section_or"] { + assert_raises( + &vm, + &[ + ( + &format!("{function}(guide, '/Usage')"), + &format!( + "{function}: blocks must be a list of blocks, got a section; pass its \ + blocks field" + ), + ), + ( + &format!("{function}(templates.blocks[2], '/Usage')"), + &format!("{function}: blocks must be a list of blocks, got a fence block"), + ), + ( + &format!("{function}('text', '/Usage')"), + &format!("{function}: blocks must be a list of blocks, got string"), + ), + ( + &format!("{function}(nil, '/Usage')"), + &format!("{function}: blocks must be a list of blocks, got nil"), + ), + ( + &format!("{function}({{ 'text' }}, '/Usage')"), + &format!("{function}: blocks[1] must be a table, got string"), + ), + ( + &format!( + "{function}({{ {{ kind = 'section', title = 'A', blocks = 5 }} }}, '/A/B')" + ), + &format!("{function}: blocks[1].blocks must be a list of blocks, got integer"), + ), + ], + ); + } +} + +#[test] +fn a_lookup_returns_the_tables_inside_the_list_it_was_given() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ("markdown.section(md, '/Guide') == md[3]", "true"), + ( + "markdown.section(md, '/Guide/Templates', 'fence', 1) == templates.blocks[2]", + "true", + ), + ( + "markdown.section_or(md, '//Usage', 'list') == usage.blocks[1]", + "true", + ), + ], + ); + // An edit made through a lookup's result is an edit of the document. + vm.exec("markdown.section(md, '/Guide/Usage', 'list').items[1] = 'edited'"); + assert_values( + &vm, + &[("md[3].blocks[4].blocks[1].items[1]", r#""edited""#)], + ); +} + +#[test] +fn a_lookup_reads_only_the_fields_it_needs_from_blocks_built_by_hand() { + let vm = markdown_vm(); + vm.exec( + "built = {\n\ + { kind = 'note', title = 'A' },\n\ + { kind = 'section', title = 'A', blocks = {\n\ + { kind = 'fence', lang = 'lua' },\n\ + { kind = 'fence' },\n\ + { kind = 'mystery', lang = 'lua' },\n\ + } },\n\ + { kind = 'section', title = 'A' },\n\ + { title = 'A' },\n\ + }", + ); + assert_values( + &vm, + &[ + // A block whose kind the lookup does not know matches nothing. + ("markdown.section(built, '/A[1]') == built[2]", "true"), + ("markdown.section(built, '/A[2]') == built[3]", "true"), + ("markdown.section_or(built, '/A[3]')", "nil"), + ( + "markdown.section(built, '/A[1]', 'fence lua') == built[2].blocks[1]", + "true", + ), + ( + "markdown.section(built, '/A[1]', 'fence', 2) == built[2].blocks[2]", + "true", + ), + // A fence with no `lang` field has no language to match. + ("markdown.section_or(built, '/A[1]', 'fence \"\"')", "nil"), + // A section with no `blocks` field holds nothing. + ("markdown.section_or(built, '/A[2]', 'fence')", "nil"), + ("markdown.section_or(built, '/A[2]/B')", "nil"), + ], + ); + // Without lines, the messages that list them leave them out. + assert_raises( + &vm, + &[ + ( + "markdown.section(built, '/A')", + r#"markdown.section: path "/A" is ambiguous; it matches 2 sections; add an index such as "/A[1]""#, + ), + ( + "markdown.section(built, '/A[1]', 'fence')", + r#"markdown.section: selector "fence" matches 2 blocks; add an index, such as 1 for the first"#, + ), + ], + ); +} diff --git a/crates/runvil-internal/lua/src/tests/markdown/select.rs b/crates/runvil-internal/lua/src/tests/markdown/select.rs new file mode 100644 index 000000000..129201174 --- /dev/null +++ b/crates/runvil-internal/lua/src/tests/markdown/select.rs @@ -0,0 +1,165 @@ +//! `markdown.select`: every block a selector matches, at any depth. + +use super::*; + +#[test] +fn select_collects_every_match_at_any_depth_in_file_order() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ("#markdown.select(md, 'section')", "4"), + ("#markdown.select(md, 'fence')", "4"), + ("#markdown.select(md, 'fence lua')", "2"), + ("markdown.select(md, 'fence lua')[2].text", r#""return 2""#), + ("markdown.select(md, 'list')[2].ordered", "true"), + ("#markdown.select(md, 'frontmatter')", "1"), + ("#markdown.select(md, 'rule')", "1"), + ( + "markdown.select(templates.blocks, 'prose')[1].text", + r#""Use these.""#, + ), + // Searched inside Guide only. + ("#markdown.select(guide.blocks, 'fence lua')", "2"), + ], + ); + // A section comes before its members. + let titles: String = vm.eval( + "local titles = {}\n\ + for index, section in ipairs(markdown.select(md, 'section')) do\n\ + titles[index] = section.title\n\ + end\n\ + return table.concat(titles, ',')", + ); + assert_eq!(titles, "Guide,Templates,Extras,Usage"); +} + +#[test] +fn a_star_selects_every_block_at_any_depth_in_file_order() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + ("#markdown.select(md, '*')", "16"), + // The same tables, a section before the blocks under it. + ("markdown.select(md, '*')[1] == md[1]", "true"), + ("markdown.select(md, '*')[3] == guide", "true"), + ("markdown.select(md, '*')[4] == guide.blocks[1]", "true"), + ("markdown.select(md, '*')[6] == templates", "true"), + ("markdown.select(md, '*')[7] == templates.blocks[1]", "true"), + ("markdown.select(md, '*')[16] == usage.blocks[1]", "true"), + // Searched inside Templates only: its six blocks and the two + // under Extras. + ("#markdown.select(templates.blocks, '*')", "8"), + ("#markdown.select({}, '*')", "0"), + ("#markdown.select(nested, ' * ')", "4"), + ], + ); + let kinds: String = vm.eval( + "local kinds = {}\n\ + for index, block in ipairs(markdown.select(md, '*')) do\n\ + kinds[index] = block.kind\n\ + end\n\ + return table.concat(kinds, ',')", + ); + assert_eq!( + kinds, + "frontmatter,prose,section,prose,list,section,prose,fence,fence,prose,fence,section,\ + rule,fence,section,list" + ); + // Every block is selected, whatever its `kind` field holds. + vm.exec( + "built = {\n\ + { kind = 'note' },\n\ + { title = 'no kind' },\n\ + { kind = 'section', blocks = { { kind = 7 }, {} } },\n\ + { kind = 'prose', blocks = { { kind = 'rule' } } },\n\ + }", + ); + assert_values( + &vm, + &[ + // Only a section's `blocks` are searched. + ("#markdown.select(built, '*')", "6"), + ("markdown.select(built, '*')[2] == built[2]", "true"), + ( + "markdown.select(built, '*')[5] == built[3].blocks[2]", + "true", + ), + ("#markdown.select(built, 'section')", "1"), + ("#markdown.select(built, 'rule')", "0"), + ], + ); +} + +#[test] +fn select_returns_a_new_marked_list_of_the_tables_themselves() { + let vm = markdown_vm(); + assert_values( + &vm, + &[ + // Empty when nothing matches, and still a list. + ("markdown.select(md, 'fence python')", "[]"), + ("markdown.select({}, 'fence')", "[]"), + ( + "markdown.select(md, 'fence')[1] == templates.blocks[2]", + "true", + ), + ("markdown.select(md, 'section')[1] == md[3]", "true"), + // Each call builds its own list. + ( + "markdown.select(md, 'rule') == markdown.select(md, 'rule')", + "false", + ), + // A fanout collection is a list of sections. + ("markdown.select(log, 'section')[3].line", "7"), + ], + ); +} + +#[test] +fn select_refuses_a_malformed_selector_and_a_value_that_is_not_a_list_of_blocks() { + let vm = markdown_vm(); + assert_raises( + &vm, + &[ + ( + "markdown.select(md, 'fences')", + r#"markdown.select: unknown kind "fences"; kinds are frontmatter, section, prose, fence, list, rule, and * selects every block"#, + ), + ( + "markdown.select(md, 'rule 2')", + r#"markdown.select: only a fence selector takes a language, got "rule 2""#, + ), + ( + "markdown.select(md, '* lua')", + r#"markdown.select: only a fence selector takes a language, got "* lua""#, + ), + ( + "markdown.select(md, '**')", + r#"markdown.select: unknown kind "**"; kinds are frontmatter, section, prose, fence, list, rule, and * selects every block"#, + ), + ( + "markdown.select(md)", + "markdown.select: selector must be a string, got nil", + ), + ( + "markdown.select(md, 3)", + "markdown.select: selector must be a string, got integer", + ), + ( + "markdown.select(guide, 'fence')", + "markdown.select: blocks must be a list of blocks, got a section; pass its blocks \ + field", + ), + ( + "markdown.select('text', 'fence')", + "markdown.select: blocks must be a list of blocks, got string", + ), + ( + "markdown.select({ md[3], 7 }, 'fence')", + "markdown.select: blocks[2] must be a table, got integer", + ), + ], + ); +} diff --git a/crates/runvil-internal/lua/src/tests/markdown/views.rs b/crates/runvil-internal/lua/src/tests/markdown/views.rs new file mode 100644 index 000000000..52deb3d0b --- /dev/null +++ b/crates/runvil-internal/lua/src/tests/markdown/views.rs @@ -0,0 +1,223 @@ +//! The `markdown` functions over blocks read back from `var`, and the +//! lists they refuse: one that cannot be read, one on its own path, one +//! nested too deep, and one too large to walk. + +use super::*; + +/// A VM whose `var.doc` holds the parse of [`GUIDE`], stored and read +/// back as guarded views. +#[track_caller] +fn stored_vm() -> BuiltVm { + let seed = json!({}); + let vm = TestVm::new().var(&seed).shims().build(); + bind_samples(&vm); + vm.exec("var.doc = md"); + vm +} + +#[test] +fn every_function_reads_through_blocks_kept_in_var() { + let vm = stored_vm(); + assert_values( + &vm, + &[ + // The stored copy is a view, not the parse result. + ("var.doc == md", "false"), + ("markdown.section(var.doc, '/Guide').title", r#""Guide""#), + ("markdown.section(var.doc, '/Guide/Templates').line", "16"), + ( + "markdown.section(var.doc, '//Extras', 'fence').text", + r#""return 2""#, + ), + ( + "markdown.section(var.doc, '/Guide/Templates', 'fence', -1).lang", + r#""json""#, + ), + ("markdown.section_or(var.doc, '/Guide/Nope')", "nil"), + ( + "markdown.section_or(var.doc, '/Guide/Templates', 'list')", + "nil", + ), + ("#markdown.select(var.doc, 'fence lua')", "2"), + ("markdown.select(var.doc, 'list')[2].ordered", "true"), + ( + "markdown.render(markdown.section(var.doc, '/Guide/Usage').blocks)", + r#""1. first\n2. second\n""#, + ), + // The stored copy keeps every source, so it is still written + // back as the text. + ("markdown.render(var.doc) == guide_text", "true"), + ("#markdown.select(var.doc, '*')", "16"), + ( + "markdown.select(var.doc, '*')[4].source", + r#""Intro text\non two lines.\n\n""#, + ), + // So does a copy that went through JSON text. + ( + "markdown.render(json.decode(json.encode(md))) == guide_text", + "true", + ), + // A section's `blocks` read through the view narrows a lookup. + ( + "markdown.section(var.doc[3].blocks, '/Usage', 'list').items[2]", + r#""second""#, + ), + ], + ); +} + +#[test] +fn a_lookup_over_a_view_returns_the_views_own_members() { + let vm = stored_vm(); + assert_values( + &vm, + &[ + ("markdown.section(var.doc, '/Guide') == var.doc[3]", "true"), + ( + "markdown.select(var.doc, 'section')[1] == var.doc[3]", + "true", + ), + ], + ); + // What comes back is still guarded, so a write through it is checked. + assert_raises( + &vm, + &[( + "(function() markdown.section(var.doc, '/Guide').title = function() end end)()", + "var.doc[3].title must be JSON data, got function", + )], + ); +} + +#[test] +fn a_section_given_through_a_view_is_still_not_a_list() { + let vm = stored_vm(); + assert_raises( + &vm, + &[( + "markdown.section(var.doc[3], '/Usage')", + "markdown.section: blocks must be a list of blocks, got a section; pass its blocks \ + field", + )], + ); +} + +#[test] +fn a_list_whose_data_only_a_function_can_read_is_refused() { + let vm = markdown_vm(); + assert_raises( + &vm, + &[( + "markdown.select(sys, 'fence')", + "markdown.select: blocks cannot be read (sys is sealed); copy the fields you need \ + into a plain table", + )], + ); +} + +#[test] +fn a_list_that_contains_itself_is_refused() { + let vm = markdown_vm(); + vm.exec( + "loop = { { kind = 'section', title = 'A', source = '# A\\n' } }\n\ + loop[1].blocks = loop", + ); + assert_raises( + &vm, + &[ + ( + "markdown.select(loop, 'fence')", + "markdown.select: blocks[1].blocks contains itself", + ), + ( + "markdown.select(loop, '*')", + "markdown.select: blocks[1].blocks contains itself", + ), + ( + "markdown.section(loop, '//A[2]')", + "markdown.section: blocks[1].blocks contains itself", + ), + ( + "markdown.render(loop)", + "markdown.render: blocks[1].blocks contains itself", + ), + ], + ); +} + +#[test] +fn a_list_nested_past_the_depth_limit_is_refused() { + let vm = markdown_vm(); + // `deep(n)` is a list nested `n` lists deep, the list given being + // level 1. + vm.exec( + "function deep(levels)\n\ + local list = {}\n\ + for _ = 2, levels do\n\ + list = { { kind = 'section', level = 1, title = 'A', source = '# A\\n', blocks = list } }\n\ + end\n\ + return list\n\ + end", + ); + assert_values( + &vm, + &[ + ("#markdown.select(deep(127), 'section')", "126"), + ("#markdown.select(deep(127), '*')", "126"), + ("#markdown.render(deep(127))", "504"), + ], + ); + let name = format!("blocks{}", "[1].blocks".repeat(127)); + assert_raises( + &vm, + &[ + ( + "markdown.select(deep(128), 'section')", + &format!("markdown.select: {name} is nested more than 127 levels deep"), + ), + ( + "markdown.section(deep(128), '//A[1]')", + &format!("markdown.section: {name} is nested more than 127 levels deep"), + ), + ( + "markdown.render(deep(128))", + &format!("markdown.render: {name} is nested more than 127 levels deep"), + ), + ], + ); +} + +#[test] +fn a_shared_list_walked_past_the_budget_is_refused() { + // 17 levels of a section that holds the level below twice: 35 tables + // in memory and about 260,000 blocks in the walk, more than the + // 131,072 a 1 MiB heap allows. + let vm = TestVm::new() + .limits(1024 * 1024, DEFAULT_LUA_LOG_EVENTS) + .shims() + .build(); + vm.exec( + "shared = {}\n\ + for _ = 1, 17 do\n\ + local section = { kind = 'section', level = 1, title = 'A', source = '# A\\n', blocks = shared }\n\ + shared = { section, section }\n\ + end", + ); + assert_raises( + &vm, + &[ + ( + "markdown.select(shared, 'fence')", + "markdown.select: blocks are too large to walk", + ), + ( + "markdown.section(shared, '//B')", + "markdown.section: blocks are too large to walk", + ), + ( + "markdown.render(shared)", + "markdown.render: blocks are too large to walk", + ), + ], + ); +} diff --git a/crates/runvil-internal/lua/src/tests/prompt_reads.rs b/crates/runvil-internal/lua/src/tests/prompt_reads.rs new file mode 100644 index 000000000..963f62fae --- /dev/null +++ b/crates/runvil-internal/lua/src/tests/prompt_reads.rs @@ -0,0 +1,455 @@ +//! The `prompt` reads over a reader: `prompt.section` returns a section's +//! Markdown or one block of it and raises for what is not there, +//! `prompt.section_or` returns nil for that, and both raise for a path the +//! reader refuses, a target that is not a string, and a malformed selector +//! or index. + +use super::*; + +/// The error the reader gives for a path that names no section. +const MISSING: &str = "section `Missing` not found: `/Main` has no child `Missing`"; + +/// The error the reader gives for the path it refuses. +const REFUSED: &str = "section path \"bad/\" has an empty step"; + +/// A section's Markdown with prose and one fence, which starts on line 23 +/// of its prompt file. +const TEMPLATE: &str = "Fill in this outline.\n\n```markdown\n# Title\n\n## Summary\n```"; + +/// A section's Markdown with a paragraph, two lists, and a `lua` fence, +/// which starts on line 40 of its prompt file. +const TOPICS: &str = "Pick from these:\n\n- allocators\n - arenas\n- coroutines\n-\n\n\ +```lua\nreturn 'not run'\n```\n\n1. one\n2. two"; + +/// A section's Markdown that opens as frontmatter would, which starts on +/// line 60 of its prompt file. +const LOOKALIKE: &str = "---\n\na: b\n\n---"; + +/// The text of a section whose Markdown is `markdown`, starting on `line` +/// of the prompt file. +fn section_text(markdown: &str, line: u32) -> SectionText { + SectionText::new( + Arc::from(markdown), + 0..markdown.len(), + NonZeroU32::new(line).expect("a prompt line is at least 1"), + ) +} + +/// A VM whose reader knows the sections `Rules`, `Template`, `Topics`, +/// `Lookalike`, and the empty `Empty`, refuses the path `bad/`, and finds +/// no section for any other path. +fn reading_vm() -> BuiltVm { + TestVm::new() + .reading(|path, _| { + SectionRead::Found(match path.trim() { + "Rules" => section_text("Be brief.", 1), + "Template" => section_text(TEMPLATE, 23), + "Topics" => section_text(TOPICS, 40), + "Lookalike" => section_text(LOOKALIKE, 60), + "Empty" => section_text("", 70), + _ => return SectionRead::Missing(crate::make_lua_runtime_error(MISSING)), + }) + }) + .build() +} + +/// Asserts that each Lua expression gives the value its JSON text names, +/// where `nil` stands for a nil result. +#[track_caller] +fn assert_values(vm: &BuiltVm, cases: &[(&str, &str)]) { + for (expression, want) in cases { + let got: String = vm.eval(&format!( + "local value = {expression}\n\ + if value == nil then return 'nil' end\n\ + return json.encode(value)" + )); + assert_eq!(got, *want, "{expression}"); + } +} + +/// Asserts that each Lua expression raises a `lua` error whose text is +/// the message beside it. +#[track_caller] +fn assert_raises(vm: &BuiltVm, cases: &[(&str, &str)]) { + for (expression, message) in cases { + assert_eq!(caught(vm, expression), lua_error(message), "{expression}"); + } +} + +#[test] +fn section_returns_the_markdown_and_raises_for_a_path_that_names_no_section() { + let vm = reading_vm(); + assert_values( + &vm, + &[ + ("prompt.section('Rules')", r#""Be brief.""#), + ("prompt.section('Empty')", r#""""#), + ], + ); + assert_raises(&vm, &[("prompt.section('Missing')", MISSING)]); +} + +#[test] +fn section_or_returns_the_markdown_and_nil_for_a_path_that_names_no_section() { + let vm = reading_vm(); + assert_values( + &vm, + &[ + ("prompt.section_or('Rules')", r#""Be brief.""#), + ("prompt.section_or('Missing')", "nil"), + ("prompt.section_or('Missing', 'fence')", "nil"), + ("prompt.section_or('Missing', 'fence', 2)", "nil"), + ], + ); +} + +#[test] +fn both_reads_raise_for_a_refused_path_and_for_a_target_that_is_not_a_string() { + // The reader of this VM refuses one path with an error of its own. + let vm = TestVm::new().build(); + vm.install_control_globals(|_: String, _| -> Result { + Err(crate::make_lua_runtime_error(REFUSED)) + }) + .expect("the control globals install"); + for read in ["section", "section_or"] { + let refused: String = vm.eval(&format!( + "local ok, err = pcall(prompt.{read}, 'bad/')\n\ + assert(not ok, 'the read must raise')\n\ + return tostring(err)" + )); + assert!(refused.contains(REFUSED), "prompt.{read} got {refused}"); + } + let vm = reading_vm(); + for read in ["prompt.section", "prompt.section_or"] { + for (argument, kind) in [("42", "integer"), ("nil", "nil"), ("{}", "table")] { + assert_raises( + &vm, + &[( + &format!("{read}({argument})"), + &format!("section target must be a string, got {kind}"), + )], + ); + } + } +} + +#[test] +fn a_selector_picks_one_block_of_the_sections_own_content() { + let vm = reading_vm(); + assert_values( + &vm, + &[ + ( + "prompt.section('Template', 'fence markdown').text", + r##""# Title\n\n## Summary""##, + ), + ("prompt.section('Template', 'fence').lang", r#""markdown""#), + ( + "prompt.section('Template', 'prose').text", + r#""Fill in this outline.""#, + ), + // An index is allowed when there is one match. + ( + "prompt.section('Template', 'fence', 1).lang", + r#""markdown""#, + ), + ("prompt.section('Template', 'fence', -1).kind", r#""fence""#), + ( + "prompt.section_or('Template', 'fence markdown').info", + r#""markdown""#, + ), + // A list beside prose is found, and an index picks among two. + ( + "prompt.section('Topics', 'list', 1).items", + r#"["allocators\n- arenas","coroutines",""]"#, + ), + ("prompt.section('Topics', 'list', 2).ordered", "true"), + ("prompt.section('Topics', 'list', -1).items[2]", r#""two""#), + // A Lua fence is a fence like any other, and it is not run. + ( + "prompt.section('Topics', 'fence lua').text", + r#""return 'not run'""#, + ), + ( + "prompt.section(' Template ', ' fence ').kind", + r#""fence""#, + ), + ], + ); +} + +#[test] +fn a_star_picks_among_every_block_of_the_sections_own_content() { + let vm = reading_vm(); + assert_values( + &vm, + &[ + // A section with one block needs no index. + ("prompt.section('Rules', '*').text", r#""Be brief.""#), + ("prompt.section('Template', '*', 1).kind", r#""prose""#), + ("prompt.section('Template', '*', 2).lang", r#""markdown""#), + ("prompt.section('Topics', '*', -1).ordered", "true"), + ("prompt.section('Topics', '*', 3).line", "47"), + ("prompt.section_or('Topics', '*', 5)", "nil"), + ("prompt.section_or('Empty', '*')", "nil"), + ], + ); + assert_raises( + &vm, + &[ + ( + "prompt.section('Template', '*')", + "prompt.section: selector \"*\" matches 2 blocks, at lines 23 and 25; add an \ + index, such as 1 for the first", + ), + ( + "prompt.section('Empty', '*')", + "prompt.section: no block in section `Empty` matches selector \"*\"", + ), + ], + ); +} + +#[test] +fn the_sources_of_a_sections_blocks_are_its_markdown() { + let vm = reading_vm(); + vm.exec( + "function joined(path)\n\ + local parts = {}\n\ + while true do\n\ + local block = prompt.section_or(path, '*', #parts + 1)\n\ + if not block then break end\n\ + parts[#parts + 1] = block.source\n\ + end\n\ + return #parts, table.concat(parts)\n\ + end", + ); + for (path, blocks) in [ + ("Rules", 1), + ("Template", 2), + ("Topics", 4), + ("Lookalike", 3), + ] { + let joined: String = vm.eval(&format!( + "local count, text = joined('{path}')\n\ + assert(count == {blocks}, count)\n\ + assert(text == markdown.render(markdown.parse(text)))\n\ + return tostring(text == prompt.section('{path}'))" + )); + assert_eq!(joined, "true", "{path}"); + } + assert_values( + &vm, + &[ + // Each source is the block's lines and the blank lines after. + ( + "prompt.section('Template', 'prose').source", + r#""Fill in this outline.\n\n""#, + ), + ( + "prompt.section('Topics', 'list', 1).source", + r#""- allocators\n - arenas\n- coroutines\n-\n\n""#, + ), + // The last block of a section ends where its Markdown does. + ( + "prompt.section('Template', 'fence').source", + r#""```markdown\n# Title\n\n## Summary\n```""#, + ), + ( + "prompt.section('Lookalike', 'rule', 1).source", + r#""---\n\n""#, + ), + ], + ); +} + +#[test] +fn a_blocks_line_counts_lines_of_the_prompt_file() { + let vm = reading_vm(); + assert_values( + &vm, + &[ + ("prompt.section('Template', 'prose').line", "23"), + ("prompt.section('Template', 'fence markdown').line", "25"), + ("prompt.section('Topics', 'list', 1).line", "42"), + ("prompt.section('Topics', 'fence').line", "47"), + ("prompt.section('Topics', 'list', 2).line", "51"), + // Parsing the section's text counts from the section instead. + ("markdown.parse(prompt.section('Template'))[2].line", "3"), + ], + ); +} + +#[test] +fn the_block_is_a_plain_table_built_for_the_call() { + let vm = reading_vm(); + assert_values( + &vm, + &[ + ( + "prompt.section('Template', 'prose')", + r#"{"kind":"prose","line":23,"source":"Fill in this outline.\n\n","text":"Fill in this outline."}"#, + ), + ( + "prompt.section('Topics', 'list', 2)", + r#"{"items":["one","two"],"kind":"list","line":51,"ordered":true,"source":"1. one\n2. two"}"#, + ), + // Each call builds a new table, so editing one changes nothing + // else. + ( + "prompt.section('Template', 'fence') == prompt.section('Template', 'fence')", + "false", + ), + ( + "(function()\n\ + prompt.section('Template', 'fence').text = 'edited'\n\ + return prompt.section('Template', 'fence').text\n\ + end)()", + r##""# Title\n\n## Summary""##, + ), + ], + ); +} + +#[test] +fn a_block_that_is_not_there_raises_from_section_and_is_nil_from_section_or() { + let vm = reading_vm(); + let misses = [ + ( + "'Template', 'list'", + "no block in section `Template` matches selector \"list\"", + ), + ( + "'Template', 'fence', 2", + "selector \"fence\" matches 1 block in section `Template`; there is no block 2", + ), + // The only fence has a language. + ( + r#"'Template', 'fence ""'"#, + "no block in section `Template` matches selector \"fence \"\"\"", + ), + ( + "' Topics ', 'list', 3", + "selector \"list\" matches 2 blocks in section `Topics`; there is no block 3", + ), + ( + "'Empty', 'prose'", + "no block in section `Empty` matches selector \"prose\"", + ), + ( + "'Rules', 'rule', -1", + "no block in section `Rules` matches selector \"rule\"", + ), + ]; + for (arguments, message) in misses { + assert_raises( + &vm, + &[( + &format!("prompt.section({arguments})"), + &format!("prompt.section: {message}"), + )], + ); + assert_values(&vm, &[(&format!("prompt.section_or({arguments})"), "nil")]); + } +} + +#[test] +fn several_matches_without_an_index_raise_from_both_and_name_lines_of_the_prompt_file() { + let vm = reading_vm(); + let two = "selector \"list\" matches 2 blocks, at lines 42 and 51; add an index, such as 1 \ + for the first"; + assert_raises( + &vm, + &[ + ( + "prompt.section('Topics', 'list')", + &format!("prompt.section: {two}"), + ), + ( + "prompt.section_or('Topics', 'list')", + &format!("prompt.section_or: {two}"), + ), + ], + ); +} + +#[test] +fn the_kinds_a_sections_own_content_never_holds_are_refused() { + let vm = reading_vm(); + for read in ["prompt.section", "prompt.section_or"] { + // Whether or not the section is there. + for path in ["Template", "Missing"] { + assert_raises( + &vm, + &[ + ( + &format!("{read}('{path}', 'section')"), + &format!( + "{read}: a section's own content has no subsections; name a \ + subsection by its own path" + ), + ), + ( + &format!("{read}('{path}', 'frontmatter', 1)"), + &format!("{read}: a section's own content has no frontmatter"), + ), + ], + ); + } + } +} + +#[test] +fn frontmatter_is_never_recognized_in_a_sections_content() { + let vm = reading_vm(); + assert_values( + &vm, + &[ + ("prompt.section('Lookalike', 'rule', 1).line", "60"), + ("prompt.section('Lookalike', 'prose').text", r#""a: b""#), + ("prompt.section('Lookalike', 'rule', 2).line", "64"), + // The same text given to `markdown.parse` opens with it. + ( + "markdown.parse(prompt.section('Lookalike'))[1].kind", + r#""frontmatter""#, + ), + ], + ); +} + +#[test] +fn a_malformed_selector_or_index_raises_from_both_before_the_path_is_looked_up() { + let vm = reading_vm(); + for read in ["prompt.section", "prompt.section_or"] { + for path in ["Template", "Missing"] { + let cases = [ + ( + "'fences', 1", + "unknown kind \"fences\"; kinds are frontmatter, section, prose, fence, list, \ + rule, and * selects every block", + ), + ( + "'list lua'", + "only a fence selector takes a language, got \"list lua\"", + ), + ("7", "selector must be a string, got integer"), + ("nil, 1", "an index needs a selector before it"), + ("'fence', 0", "index must be a nonzero integer, got 0"), + ("'fence', 1.5", "index must be a nonzero integer, got 1.5"), + ( + "'fence', 'one'", + "index must be a nonzero integer, got string", + ), + ]; + for (arguments, message) in cases { + assert_raises( + &vm, + &[( + &format!("{read}('{path}', {arguments})"), + &format!("{read}: {message}"), + )], + ); + } + } + } +} diff --git a/crates/runvil-internal/lua/src/tests/require.rs b/crates/runvil-internal/lua/src/tests/require.rs index 00688801f..068c60c38 100644 --- a/crates/runvil-internal/lua/src/tests/require.rs +++ b/crates/runvil-internal/lua/src/tests/require.rs @@ -1,39 +1,39 @@ -//! Runvil's `require`: library blocks run in the caller's environment -//! and coroutine, return values, caching by library identity, cycles, -//! retries after a failed load, the key passed to the resolver, the `jump` -//! refusal during a load, library prose, library errors, and the +//! Runvil's `require`: library blocks run over the caller's globals and +//! in its coroutine, return values, caching by library identity, cycles, +//! retries after a failed load, the path passed to the resolver, the +//! `jump` refusal during a load, library prose, library errors, and the //! library-load reports. use super::*; +mod environment; mod errors; mod jump; mod prose; mod reports; -/// One resolver entry: the heading as written, the identity of the library -/// whose load requires it (`None` for any other call), and the library it -/// resolves to. -type Entry = (&'static str, Option<&'static str>, Library); +/// One resolver entry: the path as written, and the library it resolves +/// to. +type Entry = (&'static str, Library); -/// Every resolver call so far: the heading and the passed key's identity. -type Calls = Arc)>>>; +/// Every path the resolver was asked for so far, as written. +type Calls = Arc>>; /// A library under `identity` whose blocks are `sources`, each compiled -/// at the location `library {heading}` from prompt line 100, with empty +/// at the location `library {path}` from prompt line 100, with empty /// prose. The line keeps the chunk name `line 100` apart from the line 1 /// of the caller `program` builds. -fn library(identity: &str, heading: &str, sources: &[&str]) -> Library { +fn library(identity: &str, path: &str, sources: &[&str]) -> Library { Library { key: LibraryKey::new(identity.to_owned()), - heading: heading.to_owned(), + path: path.to_owned(), blocks: sources .iter() .map(|source| LibraryBlock { program: LuaProgram::compile( source, "prompt", - &format!("library {heading}"), + &format!("library {path}"), NonZeroU32::new(100).expect("100 is non-zero"), ) .expect("library Lua must compile"), @@ -44,34 +44,23 @@ fn library(identity: &str, heading: &str, sources: &[&str]) -> Library { } } -/// A resolver over fixed entries that records every call. A heading -/// resolves to the entry for the passed key first and to the entry with -/// no key otherwise; anything else is out of reach. +/// A resolver over fixed entries that records every call. A path resolves +/// to the entry written the same way; any other names no section. fn resolver( entries: Vec, calls: &Calls, -) -> impl Fn(String, Option) -> Result + Send + Sync + 'static { +) -> impl Fn(String) -> Result + Send + Sync + 'static { let calls = Arc::clone(calls); - move |heading, key| { - let from = key.map(|key| key.identity().to_owned()); + move |path| { calls .lock() .expect("the calls lock must not be poisoned") - .push((heading.clone(), from.clone())); + .push(path.clone()); entries .iter() - .find(|(written, at, _)| *written == heading && at.map(str::to_owned) == from) - .or_else(|| { - entries - .iter() - .find(|(written, at, _)| *written == heading && at.is_none()) - }) - .map(|(_, _, library)| library.clone()) - .ok_or_else(|| { - crate::make_lua_runtime_error(format!( - "no section `{heading}` within reach of require" - )) - }) + .find(|(written, _)| *written == path) + .map(|(_, library)| library.clone()) + .ok_or_else(|| crate::make_lua_runtime_error(format!("section `{path}` not found"))) } } @@ -117,34 +106,24 @@ fn require_hands_back_the_first_returned_value_of_any_type() { let (vm, _) = require_vm( vec![ ( - "## table", - None, - library("t", "## table", &["return { name = 'tbl' }"]), - ), - ( - "## function", - None, - library("f", "## function", &["return function() return 'fn' end"]), - ), - ( - "## string", - None, - library("s", "## string", &["return 'text'"]), + "/table", + library("t", "/table", &["return { name = 'tbl' }"]), ), ( - "## false", - None, - library("n", "## false", &["return false"]), + "/function", + library("f", "/function", &["return function() return 'fn' end"]), ), + ("/string", library("s", "/string", &["return 'text'"])), + ("/false", library("n", "/false", &["return false"])), ], &null_emitter(), ); let value = returned( &vm, - "local t = require '## table'\n\ - local f = require '## function'\n\ - return type(t) .. ':' .. t.name .. '|' .. f() .. '|' .. (require '## string') .. '|' \ - .. tostring(require '## false')", + "local t = require '/table'\n\ + local f = require '/function'\n\ + return type(t) .. ':' .. t.name .. '|' .. f() .. '|' .. (require '/string') .. '|' \ + .. tostring(require '/false')", ); assert_eq!(value.as_deref(), Some("table:tbl|fn|text|false")); } @@ -154,11 +133,10 @@ fn a_block_without_a_value_lets_the_next_block_run() { let (vm, _) = require_vm( vec![ ( - "## later", - None, + "/later", library( "later", - "## later", + "/later", &[ "first = 'ran'", "return nil", @@ -167,16 +145,15 @@ fn a_block_without_a_value_lets_the_next_block_run() { ), ), ( - "## silent", - None, - library("silent", "## silent", &["quiet = 1", "return nil"]), + "/silent", + library("silent", "/silent", &["quiet = 1", "return nil"]), ), ], &null_emitter(), ); let value = returned( &vm, - "return (require '## later') .. '|' .. tostring(require '## silent')", + "return (require '/later') .. '|' .. tostring(require '/silent')", ); assert_eq!(value.as_deref(), Some("ran then third|true")); } @@ -185,19 +162,14 @@ fn a_block_without_a_value_lets_the_next_block_run() { fn library_globals_reach_the_caller_and_its_locals_stay_inside() { let (vm, _) = require_vm( vec![( - "## a", - None, - library( - "a", - "## a", - &["local hidden = 'local'\nexported = 'global'"], - ), + "/a", + library("a", "/a", &["local hidden = 'local'\nexported = 'global'"]), )], &null_emitter(), ); let value = returned( &vm, - "require '## a'\nreturn exported .. ':' .. tostring(hidden)", + "require '/a'\nreturn exported .. ':' .. tostring(hidden)", ); assert_eq!(value.as_deref(), Some("global:nil")); } @@ -206,11 +178,10 @@ fn library_globals_reach_the_caller_and_its_locals_stay_inside() { fn a_second_require_returns_the_saved_value_without_rerunning() { let (vm, _) = require_vm( vec![( - "## a", - None, + "/a", library( "a", - "## a", + "/a", &["loads = (loads or 0) + 1\nreturn { count = loads }"], ), )], @@ -218,8 +189,8 @@ fn a_second_require_returns_the_saved_value_without_rerunning() { ); let value = returned( &vm, - "local first = require '## a'\n\ - local second = require '## a'\n\ + "local first = require '/a'\n\ + local second = require '/a'\n\ return tostring(first == second) .. ':' .. loads", ); assert_eq!(value.as_deref(), Some("true:1")); @@ -229,15 +200,14 @@ fn a_second_require_returns_the_saved_value_without_rerunning() { fn a_saved_false_comes_back_without_a_rerun() { let (vm, _) = require_vm( vec![( - "## a", - None, - library("a", "## a", &["loads = (loads or 0) + 1\nreturn false"]), + "/a", + library("a", "/a", &["loads = (loads or 0) + 1\nreturn false"]), )], &null_emitter(), ); let value = returned( &vm, - "local first, second = require '## a', require '## a'\n\ + "local first, second = require '/a', require '/a'\n\ return tostring(first) .. ':' .. tostring(second) .. ':' .. loads", ); assert_eq!(value.as_deref(), Some("false:false:1")); @@ -248,85 +218,97 @@ fn a_library_requiring_a_loaded_library_gets_its_saved_value() { let (vm, _) = require_vm( vec![ ( - "## a", - None, + "/a", library( "a", - "## a", + "/a", &["loads = (loads or 0) + 1\nreturn 'value of a'"], ), ), ( - "## b", - None, - library("b", "## b", &["return 'b sees ' .. require '## a'"]), + "/b", + library("b", "/b", &["return 'b sees ' .. require '/a'"]), ), ], &null_emitter(), ); - let value = returned( - &vm, - "require '## a'\nreturn (require '## b') .. ':' .. loads", - ); + let value = returned(&vm, "require '/a'\nreturn (require '/b') .. ':' .. loads"); assert_eq!(value.as_deref(), Some("b sees value of a:1")); } #[test] -fn nested_helpers_sharing_a_heading_stay_apart_per_library() { +fn two_helpers_with_one_title_are_two_libraries() { let (vm, _) = require_vm( vec![ + ("/a", library("a", "/a", &["return require '/a/helpers'"])), + ("/b", library("b", "/b", &["return require '/b/helpers'"])), ( - "## a", - None, - library("a", "## a", &["return require '### helpers'"]), - ), - ( - "## b", - None, - library("b", "## b", &["return require '### helpers'"]), - ), - ( - "### helpers", - Some("a"), - library("a/helpers", "### helpers", &["return 'helper of a'"]), + "/a/helpers", + library("a/helpers", "/a/helpers", &["return 'helper of a'"]), ), ( - "### helpers", - Some("b"), - library("b/helpers", "### helpers", &["return 'helper of b'"]), + "/b/helpers", + library("b/helpers", "/b/helpers", &["return 'helper of b'"]), ), ], &null_emitter(), ); - let value = returned(&vm, "return (require '## a') .. '|' .. (require '## b')"); + let value = returned(&vm, "return (require '/a') .. '|' .. (require '/b')"); assert_eq!(value.as_deref(), Some("helper of a|helper of b")); } +#[test] +fn one_library_loads_once_however_its_path_is_written() { + let counted = || library("a", "/a", &["loads = (loads or 0) + 1\nreturn {}"]); + let (vm, _) = require_vm( + vec![("/a", counted()), (" / a ", counted())], + &null_emitter(), + ); + let value = returned( + &vm, + "local first, second = require '/a', require ' / a '\n\ + return tostring(first == second) .. ':' .. loads", + ); + assert_eq!(value.as_deref(), Some("true:1")); +} + #[test] fn a_two_library_cycle_raises_naming_both_sections_in_order() { let (vm, _) = require_vm( vec![ - ("## a", None, library("a", "## a", &["require '## b'"])), - ("## b", None, library("b", "## b", &["require '## a'"])), + ("/a", library("a", "/a", &["require '/b'"])), + ("/b", library("b", "/b", &["require '/a'"])), ], &null_emitter(), ); - let error = failure(&vm, "require '## a'"); + let error = failure(&vm, "require '/a'"); assert!( - error.contains("require cycle: ## a -> ## b -> ## a"), + error.contains("require cycle: /a -> /b -> /a"), "the cycle must name both sections in order, got {error}" ); } +#[test] +fn a_library_that_requires_itself_meets_the_cycle_error() { + let (vm, _) = require_vm( + vec![("/Main", library("main", "/Main", &["require '/Main'"]))], + &null_emitter(), + ); + let error = failure(&vm, "require '/Main'"); + assert!( + error.contains("require cycle: /Main -> /Main"), + "a self-require must close a cycle of one, got {error}" + ); +} + #[test] fn a_failed_load_stays_out_of_the_cache_and_runs_again() { let (vm, _) = require_vm( vec![( - "## a", - None, + "/a", library( "a", - "## a", + "/a", &[ "runs = (runs or 0) + 1\npartial = 'kept'\nif broken then error('library fault') end\nreturn 'loaded'", ], @@ -337,10 +319,10 @@ fn a_failed_load_stays_out_of_the_cache_and_runs_again() { let value = returned( &vm, "broken = true\n\ - local ok, err = pcall(require, '## a')\n\ + local ok, err = pcall(require, '/a')\n\ local kept = partial\n\ broken = false\n\ - return tostring(ok) .. '|' .. tostring(err) .. '|' .. kept .. '|' .. (require '## a') \ + return tostring(ok) .. '|' .. tostring(err) .. '|' .. kept .. '|' .. (require '/a') \ .. '|' .. runs", ); assert_eq!( @@ -352,62 +334,48 @@ fn a_failed_load_stays_out_of_the_cache_and_runs_again() { #[test] fn an_uncaught_library_failure_keeps_the_library_position() { let (vm, _) = require_vm( - vec![( - "## a", - None, - library("a", "## a", &["\nerror('library fault')"]), - )], + vec![("/a", library("a", "/a", &["\nerror('library fault')"]))], &null_emitter(), ); - let error = failure(&vm, "require '## a'"); + let error = failure(&vm, "require '/a'"); assert!( - error.contains("library ## a:101: library fault"), + error.contains("library /a:101: library fault"), "the raise must still name the library block, got {error}" ); } #[test] -fn only_a_require_during_a_load_passes_the_loading_library_key() { +fn the_resolver_gets_each_path_as_written_during_a_load_and_after_it() { let (vm, calls) = require_vm( vec![ ( - "## a", - None, + "/a", library( "a", - "## a", - &["require '### helpers'\nfunction later() return require '### late' end"], + "/a", + &["require '/a/helpers'\nfunction later() return require ' /a/late ' end"], ), ), ( - "### helpers", - Some("a"), - library("a/helpers", "### helpers", &["return 'h'"]), - ), - ( - "### late", - None, - library("late", "### late", &["return 'l'"]), + "/a/helpers", + library("a/helpers", "/a/helpers", &["return 'h'"]), ), + (" /a/late ", library("late", "/a/late", &["return 'l'"])), ], &null_emitter(), ); assert_eq!( - returned(&vm, "require '## a'\nreturn later()").as_deref(), + returned(&vm, "require '/a'\nreturn later()").as_deref(), Some("l") ); assert_eq!( *calls.lock().expect("the calls lock must not be poisoned"), - [ - ("## a".to_owned(), None), - ("### helpers".to_owned(), Some("a".to_owned())), - ("### late".to_owned(), None), - ] + ["/a", "/a/helpers", " /a/late "] ); } #[test] -fn a_non_string_heading_raises_without_reaching_the_resolver() { +fn a_path_that_is_not_a_string_raises_without_reaching_the_resolver() { let (vm, calls) = require_vm(Vec::new(), &null_emitter()); let value = returned( &vm, @@ -417,17 +385,15 @@ fn a_non_string_heading_raises_without_reaching_the_resolver() { .expect("the block must return its text"); assert!( value.starts_with("false|") - && value.contains( - "require takes a section heading string such as '## helpers', got integer" - ), - "a number heading must raise the heading-type error, got {value}" + && value.contains("require takes a section path such as '/helpers', got integer"), + "a number must raise the path-type error, got {value}" ); assert!( calls .lock() .expect("the calls lock must not be poisoned") .is_empty(), - "a number heading must never reach the resolver" + "a number must never reach the resolver" ); } @@ -435,20 +401,19 @@ fn a_non_string_heading_raises_without_reaching_the_resolver() { fn a_suspending_library_call_yields_from_the_callers_coroutine() { let (vm, _) = require_vm( vec![( - "## a", - None, - library("a", "## a", &["answer = call('## worker', 'job')"]), + "/a", + library("a", "/a", &["answer = call('../worker', 'job')"]), )], &null_emitter(), ); - let block = program("require '## a'\nreturn answer"); + let block = program("require '/a'\nreturn answer"); let CoroStep::Yielded(thread, values) = vm.start_block_coro(&block).expect("the block starts") else { panic!("the library's call must suspend the caller's block"); }; match vm.request_from_yield(&values) { YieldParse::Request(Request::Call { target, input, .. }) => { - assert_eq!(target, "## worker"); + assert_eq!(target, "../worker"); assert_eq!(input.as_deref(), Some("job")); } other => panic!("expected a call request, got {other:?}"), diff --git a/crates/runvil-internal/lua/src/tests/require/environment.rs b/crates/runvil-internal/lua/src/tests/require/environment.rs new file mode 100644 index 000000000..0011ec1a5 --- /dev/null +++ b/crates/runvil-internal/lua/src/tests/require/environment.rs @@ -0,0 +1,561 @@ +//! A library's blocks load with the library's environment. +//! +//! Two things hold. A library's code sees the VM's globals exactly as a +//! section's own block does, through the `_G` guard: plain globals both +//! ways, `argv`, `messages`, `prose`, and a metatable the author set on +//! `_G`. And the suspending calls and the prompt reads that a library's +//! code makes say which library made them, while the library loads and +//! from a function called after the load, a tail call included. + +use super::*; + +/// A shim VM built from `vm` with `require` installed over `entries`, +/// rendering library prose verbatim. +#[track_caller] +fn env_vm(vm: TestVm<'_>, entries: Vec) -> BuiltVm { + let vm = vm.shims().build(); + install(&vm, entries); + vm +} + +/// Installs `require` over `entries` on a built VM. +#[track_caller] +fn install(vm: &BuiltVm, entries: Vec) { + vm.install_require( + resolver(entries, &Calls::default()), + |template, _, _| Ok(template.to_owned()), + &null_emitter(), + TEST_SECTION, + ) + .expect("require must install"); +} + +/// The one library most cases load: `/kit`, under the identity `kit`. +fn kit(sources: &[&str]) -> Vec { + vec![("/kit", library("kit", "/kit", sources))] +} + +/// Runs `source` as a block to its first yield and returns the request. +#[track_caller] +fn first_request(vm: &SectionVm, source: &str) -> Request { + let step = vm + .start_block_coro(&program(source)) + .expect("the block starts"); + let CoroStep::Yielded(_, values) = step else { + panic!("the block must suspend on a request, got {step:?}"); + }; + match vm.request_from_yield(&values) { + YieldParse::Request(request) => request, + other => panic!("the yield must be a request, got {other:?}"), + } +} + +/// The target of a `call` or `spawn` request and the identity of the +/// library whose code made it. +#[track_caller] +fn origin(request: &Request) -> (String, Option) { + match request { + Request::Call { target, from, .. } | Request::Spawn { target, from, .. } => ( + target.clone(), + from.as_ref().map(|key| key.identity().to_owned()), + ), + other => panic!("expected a call or a spawn, got {other:?}"), + } +} + +/// `(target, Some(identity))`. +fn from(target: &str, identity: &str) -> (String, Option) { + (target.to_owned(), Some(identity.to_owned())) +} + +// ---------------------------------------------------------------- globals + +#[test] +fn library_code_reads_the_callers_globals_as_they_are_now() { + let vm = env_vm( + TestVm::new(), + kit(&["at_load = seen\nfunction now() return seen end"]), + ); + let value = returned( + &vm, + "seen = 'first'\nrequire '/kit'\nseen = 'second'\nreturn at_load .. '|' .. now()", + ); + assert_eq!(value.as_deref(), Some("first|second")); +} + +#[test] +fn a_library_global_lands_in_the_callers_globals_for_every_later_block() { + let vm = env_vm( + TestVm::new(), + kit(&[ + "function greet(name) return 'hello ' .. name end\ncount = 1\n\ + function bump() count = count + 1 end", + ]), + ); + assert_eq!( + returned( + &vm, + "require '/kit'\nbump()\ncount = count + 10\nbump()\nreturn greet('a') .. count" + ) + .as_deref(), + Some("hello a13") + ); + assert_eq!( + returned(&vm, "bump()\nreturn greet('b') .. count").as_deref(), + Some("hello b14"), + "a later block of the same VM sees the library's globals and its functions' writes" + ); +} + +#[test] +fn library_code_reaches_every_engine_global_it_does_not_bind() { + let vm = env_vm( + TestVm::new(), + kit(&["function kinds()\n\ + local names = { 'args', 'json', 'log', 'models', 'store', 'sys', 'tools', 'var',\n\ + 'untrusted', 'jump', 'require', 'pcall', 'string', 'table', 'math' }\n\ + local missing = {}\n\ + for _, name in ipairs(names) do\n\ + if _ENV[name] == nil then missing[#missing + 1] = name end\n\ + if _ENV[name] ~= _G[name] then missing[#missing + 1] = name .. ' differs' end\n\ + end\n\ + return table.concat(missing, ',')\n\ + end"]), + ); + let value = returned(&vm, "require '/kit'\nreturn kinds()"); + assert_eq!( + value.as_deref(), + Some(""), + "every other global is the VM's own, read through the environment" + ); +} + +#[test] +fn library_code_reads_the_frozen_argv_and_cannot_assign_it() { + let argv = json!({ "topic": "x" }); + let vm = env_vm( + TestVm::new().argv(Argv::Frozen(Some(&argv))), + kit(&[ + "topic_at_load = argv.topic\nfunction assign() argv = { topic = 'y' } end\n\ + function topic() return argv.topic end", + ]), + ); + let value = returned( + &vm, + "require '/kit'\nlocal ok, err = pcall(assign)\n\ + return topic_at_load .. '|' .. tostring(ok) .. '|' .. tostring(err) .. '|' .. topic()", + ) + .expect("the block returns its text"); + assert!( + value.starts_with("x|false|") && value.ends_with("|x"), + "the read serves the frozen value and the write is refused, got {value}" + ); + assert!( + value.contains(crate::argv::ASSIGNMENT_REFUSAL), + "the refusal is the guard's own, got {value}" + ); +} + +#[test] +fn a_library_loaded_by_the_h1_pass_repairs_argv_in_the_vms_globals() { + let vm = env_vm( + TestVm::new().argv(Argv::Writable(None)), + kit(&["argv = { repaired = true }"]), + ); + let value = returned(&vm, "require '/kit'\nreturn tostring(argv.repaired)"); + assert_eq!(value.as_deref(), Some("true")); + assert_eq!( + vm.argv_json().expect("the repaired argv reads back"), + Some(json!({ "repaired": true })), + "the write went to the VM's globals, where the executor reads the repair" + ); +} + +#[test] +fn library_code_reads_the_sections_messages_and_cannot_assign_them() { + let vm = env_vm( + TestVm::new(), + kit(&["function same() return messages == _G.messages end\n\ + function assign() messages = {} end\n\ + function add() messages:user('from the library') return #messages end"]), + ); + let value = returned( + &vm, + "require '/kit'\nlocal ok, err = pcall(assign)\n\ + return tostring(same()) .. '|' .. tostring(ok) .. '|' .. add() .. '|' .. #messages \ + .. '|' .. tostring(err)", + ) + .expect("the block returns its text"); + assert!( + value.starts_with("true|false|1|1|"), + "the library reads and appends to the section's one list, got {value}" + ); + assert!( + value.contains(crate::messages::ASSIGNMENT_REFUSAL), + "the refusal is the guard's own, got {value}" + ); +} + +#[test] +fn library_code_cannot_assign_prose() { + let vm = env_vm( + TestVm::new(), + kit(&["function assign() prose = 'mine' end"]), + ); + let value = returned( + &vm, + "require '/kit'\nlocal ok, err = pcall(assign)\nreturn tostring(ok) .. '|' .. tostring(err)", + ) + .expect("the block returns its text"); + assert!( + value.starts_with("false|") && value.contains(crate::prose::ASSIGNMENT_REFUSAL), + "the refusal is the guard's own, got {value}" + ); +} + +#[test] +fn the_authors_strict_globals_handler_catches_an_undefined_global_in_library_code() { + let vm = env_vm( + TestVm::new(), + kit(&["function touch()\n return undefined_here\nend"]), + ); + let value = returned( + &vm, + "require '/kit'\n\ + setmetatable(_G, { __index = function(_, key) error('undefined global ' .. key, 2) end })\n\ + local ok, err = pcall(touch)\n\ + return tostring(ok) .. '|' .. tostring(err)", + ); + assert_eq!( + value.as_deref(), + Some("false|[string \"line 100\"]:2: undefined global undefined_here"), + "the handler runs for library code and its level-2 position is the library's line" + ); +} + +#[test] +fn the_authors_newindex_handler_receives_a_new_global_from_library_code() { + let vm = env_vm(TestVm::new(), kit(&["function define() fresh = 1 end"])); + let value = returned( + &vm, + "require '/kit'\n\ + local seen\n\ + setmetatable(_G, { __newindex = function(t, key, value)\n\ + seen = key .. '=' .. tostring(value) .. ' on _G: ' .. tostring(t == _G)\n\ + end })\n\ + define()\n\ + return seen .. '|' .. tostring(fresh)", + ); + assert_eq!( + value.as_deref(), + Some("fresh=1 on _G: true|nil"), + "the handler sees the VM's globals table, and decides the write as it does for a section" + ); +} + +#[test] +fn a_library_can_set_the_metatable_of_the_vms_globals() { + let vm = env_vm( + TestVm::new(), + kit(&["setmetatable(_G, { __index = function(_, key) return 'default:' .. key end })"]), + ); + let value = returned( + &vm, + "require '/kit'\nreturn tostring(anything_unset) .. '|' .. type(getmetatable(_G))", + ); + assert_eq!(value.as_deref(), Some("default:anything_unset|table")); +} + +#[test] +fn a_librarys_environment_is_not_the_globals_table_and_its_metatable_is_closed() { + let vm = env_vm( + TestVm::new(), + kit(&["env_is_g = (_ENV == _G)\n\ + meta = getmetatable(_ENV)\n\ + set_ok, set_err = pcall(setmetatable, _ENV, {})"]), + ); + let value = returned( + &vm, + "require '/kit'\n\ + return tostring(_ENV == _G) .. '|' .. tostring(env_is_g) .. '|' .. tostring(meta) \ + .. '|' .. tostring(set_ok) .. '|' .. tostring(set_err)", + ) + .expect("the block returns its text"); + assert!( + value.starts_with("true|false|a library's environment is guarded|false|"), + "a section's own environment is _G, a library's is not, got {value}" + ); + assert!( + value.contains("cannot change a protected metatable"), + "library code cannot replace the environment's metatable, got {value}" + ); +} + +#[test] +fn the_three_bound_names_are_the_librarys_own() { + let vm = env_vm( + TestVm::new(), + kit(&[ + "lib_call, lib_tasks, lib_prompt = call, tasks, prompt\nfunction drop() call = nil end", + ]), + ); + let value = returned( + &vm, + "require '/kit'\nlocal mine = call\ndrop()\n\ + return table.concat({\n\ + tostring(lib_call ~= mine), tostring(call == mine),\n\ + tostring(lib_tasks ~= tasks), tostring(lib_tasks.spawn ~= tasks.spawn),\n\ + tostring(lib_tasks.fanout ~= tasks.fanout), tostring(lib_tasks.join == tasks.join),\n\ + tostring(lib_prompt ~= prompt), tostring(lib_prompt.section ~= prompt.section),\n\ + }, ',')", + ); + assert_eq!( + value.as_deref(), + Some("true,true,true,true,true,true,true,true"), + "a library's call, tasks.spawn, tasks.fanout, and prompt are bound copies; dropping one \ + inside the library leaves the caller's" + ); +} + +#[test] +fn a_callers_rebound_call_is_not_what_library_code_calls() { + // A library's `call` is its own, so a section's replacement of the + // global is not what the library's code calls. + let vm = env_vm( + TestVm::new(), + kit(&["function which() return call == wrapper end"]), + ); + let value = returned( + &vm, + "require '/kit'\nwrapper = function() end\ncall = wrapper\nreturn tostring(which())", + ); + assert_eq!(value.as_deref(), Some("false")); +} + +// ------------------------------------------------------- what a yield says + +#[test] +fn a_sections_own_call_names_no_library() { + let vm = env_vm(TestVm::new(), kit(&["return true"])); + let request = first_request(&vm, "require '/kit'\nreturn call('x')"); + assert_eq!(origin(&request), ("x".to_owned(), None)); +} + +#[test] +fn a_function_the_caller_passes_to_a_library_keeps_the_callers_place() { + // A function keeps the place it was written in: a callback written in + // the section names the section's neighbors, even when a library's + // code is what calls it. + let vm = env_vm( + TestVm::new(), + kit(&["function each(f) local value = f() return value end"]), + ); + let request = first_request( + &vm, + "require '/kit'\nreturn each(function() return call('mine') end)", + ); + assert_eq!(origin(&request), ("mine".to_owned(), None)); +} + +#[test] +fn a_call_made_while_a_library_loads_names_the_library() { + let vm = env_vm(TestVm::new(), kit(&["answer = call('x')"])); + let request = first_request(&vm, "require '/kit'"); + assert_eq!(origin(&request), from("x", "kit")); +} + +#[test] +fn a_library_function_called_after_the_load_names_the_library_in_a_tail_call_too() { + for body in [ + "function later() local value = call('x') return value end", + "function later() return call('x') end", + "local go = call\nfunction later() return go('x') end", + "function later() local ok, value = pcall(call, 'x') return value end", + ] { + let vm = env_vm(TestVm::new(), kit(&[body])); + let request = first_request(&vm, "require '/kit'\nreturn later()"); + assert_eq!(origin(&request), from("x", "kit"), "{body}"); + } +} + +#[test] +fn a_bound_call_handed_to_the_caller_still_names_the_library() { + let vm = env_vm(TestVm::new(), kit(&["return { call = call }"])); + let request = first_request(&vm, "local kit = require '/kit'\nreturn kit.call('x')"); + assert_eq!(origin(&request), from("x", "kit")); +} + +#[test] +fn a_name_the_caller_passes_in_is_resolved_where_it_is_used() { + let vm = env_vm( + TestVm::new(), + kit(&["function run(name) return call(name) end"]), + ); + let request = first_request(&vm, "require '/kit'\nreturn run('mine')"); + assert_eq!(origin(&request), from("mine", "kit")); +} + +#[test] +fn a_library_fanout_and_spawn_name_the_library_in_every_spawn() { + let vm = env_vm( + TestVm::new(), + kit(&["function run(items) return tasks.fanout('Worker', items) end"]), + ); + let request = first_request(&vm, "require '/kit'\nreturn run({ 'a', 'b' })"); + assert_eq!(origin(&request), from("Worker", "kit")); + assert!( + matches!(&request, Request::Spawn { item: Some(item), index: Some(1), .. } if item == "a"), + "the first arm's spawn carries its item and index as before, got {request:?}" + ); + + let vm = env_vm( + TestVm::new(), + kit(&["function start() return tasks.spawn('Worker', { item = 'x' }) end"]), + ); + let request = first_request(&vm, "require '/kit'\nreturn start()"); + assert_eq!(origin(&request), from("Worker", "kit")); + + let vm = env_vm(TestVm::new(), kit(&["return true"])); + let request = first_request( + &vm, + "require '/kit'\nreturn tasks.fanout('Worker', { 'a' })", + ); + assert_eq!( + origin(&request), + ("Worker".to_owned(), None), + "the section's own fanout names no library" + ); +} + +#[test] +fn each_library_names_itself_when_one_library_requires_another() { + let entries = || { + vec![ + ( + "/a", + library( + "a", + "/a", + &[ + "local b = require '/b'\nvia_b = b.go\nfunction via_a() return call('x') end", + ], + ), + ), + ( + "/b", + library( + "b", + "/b", + &["return { go = function() return call('x') end }"], + ), + ), + ] + }; + let vm = env_vm(TestVm::new(), entries()); + assert_eq!( + origin(&first_request(&vm, "require '/a'\nreturn via_a()")), + from("x", "a") + ); + let vm = env_vm(TestVm::new(), entries()); + assert_eq!( + origin(&first_request(&vm, "require '/a'\nreturn via_b()")), + from("x", "b"), + "a function keeps the environment of the library it was written in" + ); +} + +#[test] +fn a_library_call_still_suspends_and_resumes_the_callers_block() { + let vm = env_vm( + TestVm::new(), + kit(&["function ask() return 'kit got ' .. call('worker', 'job') end"]), + ); + let block = program("require '/kit'\nreturn ask()"); + let CoroStep::Yielded(thread, values) = vm.start_block_coro(&block).expect("the block starts") + else { + panic!("the library function's call must suspend the caller's block"); + }; + assert!(matches!( + vm.request_from_yield(&values), + YieldParse::Request(Request::Call { ref input, .. }) if input.as_deref() == Some("job") + )); + let step = vm + .resume_block_coro(&block, &thread, (true, "hi")) + .expect("resuming finishes the block"); + assert!( + matches!(step, CoroStep::Done(LuaBlockResult::Returned(Some(ref text))) if text == "kit got hi"), + "the answer resumes inside the library function, got {step:?}" + ); +} + +// ------------------------------------------------------------ prompt reads + +/// Every read so far: the path as written, and the identity of the +/// library whose code made the read. +type Reads = Arc)>>>; + +#[test] +fn a_prompt_read_says_which_library_made_it() { + let reads = Reads::default(); + let log = Arc::clone(&reads); + let vm = TestVm::new() + .reading(move |path, from| { + let from = from.map(|key| key.identity().to_owned()); + let reader = from.as_deref().unwrap_or("the section"); + let found = if path == "Missing" { + SectionRead::Missing(crate::make_lua_runtime_error(format!( + "no `{path}` for {reader}" + ))) + } else { + let markdown = format!("{path} for {reader}"); + SectionRead::Found(SectionText::new( + Arc::from(markdown.as_str()), + 0..markdown.len(), + NonZeroU32::MIN, + )) + }; + log.lock() + .expect("the reads lock must not be poisoned") + .push((path, from)); + found + }) + .build(); + install( + &vm, + kit(&["at_load = prompt.section('T')\n\ + function later() return prompt.section_or('T') .. '+' .. prompt.section('L', 'prose').text end\n\ + function users() return _G.prompt.section('T') end\n\ + function miss() return select(2, pcall(prompt.section, 'Missing')) end\n\ + function users_miss() return select(2, pcall(_G.prompt.section, 'Missing')) end"]), + ); + let value = returned( + &vm, + "local own = prompt.section('T')\nrequire '/kit'\n\ + return own .. '|' .. at_load .. '|' .. later() .. '|' .. users()", + ); + assert_eq!( + value.as_deref(), + Some("T for the section|T for kit|T for kit+L for kit|T for the section") + ); + assert_eq!( + *reads.lock().expect("the reads lock must not be poisoned"), + [ + ("T".to_owned(), None), + ("T".to_owned(), Some("kit".to_owned())), + ("T".to_owned(), Some("kit".to_owned())), + ("L".to_owned(), Some("kit".to_owned())), + ("T".to_owned(), None), + ], + "the section's own read and a read through _G.prompt name no library" + ); + let misses = returned( + &vm, + "return tostring(miss()) .. '|' .. tostring(users_miss())", + ) + .expect("the block returns its text"); + assert!( + misses.contains("no `Missing` for kit") && misses.contains("no `Missing` for the section"), + "a miss in a library's code is the library's, and one through _G is the section's, got \ + {misses}" + ); +} diff --git a/crates/runvil-internal/lua/src/tests/require/errors.rs b/crates/runvil-internal/lua/src/tests/require/errors.rs index b9435c377..7553ae07f 100644 --- a/crates/runvil-internal/lua/src/tests/require/errors.rs +++ b/crates/runvil-internal/lua/src/tests/require/errors.rs @@ -19,10 +19,10 @@ fn at(source: &str, location: &str, line: u32) -> LuaProgram { /// A library under `identity` whose blocks are `blocks`, each a source /// with its location and first prompt line. -fn placed(identity: &str, heading: &str, blocks: &[(&str, &str, u32)]) -> Library { +fn placed(identity: &str, path: &str, blocks: &[(&str, &str, u32)]) -> Library { Library { key: LibraryKey::new(identity.to_owned()), - heading: heading.to_owned(), + path: path.to_owned(), blocks: blocks .iter() .map(|(source, location, line)| LibraryBlock { @@ -46,11 +46,10 @@ fn failing(vm: &SectionVm, program: &LuaProgram) -> String { fn a_raise_during_the_load_and_from_a_later_call_reports_the_library_line() { let (vm, _) = require_vm( vec![( - "## lib", - None, + "/lib", placed( "lib", - "## lib", + "/lib", &[( "function boom()\n error('later fault')\nend\n\ if fail_now then error('load fault') end", @@ -61,13 +60,13 @@ fn a_raise_during_the_load_and_from_a_later_call_reports_the_library_line() { )], &null_emitter(), ); - let during = failing(&vm, &program("fail_now = true\nrequire '## lib'")); + let during = failing(&vm, &program("fail_now = true\nrequire '/lib'")); assert!( during.starts_with("section `lib` prologue:43: ") && during.contains("section `lib` prologue:43: load fault"), "a raise during the load must report the library's line 43, got {during}" ); - let later = failing(&vm, &program("fail_now = false\nrequire '## lib'\nboom()")); + let later = failing(&vm, &program("fail_now = false\nrequire '/lib'\nboom()")); assert!( later.starts_with("section `lib` prologue:41: ") && later.contains("section `lib` prologue:41: later fault"), @@ -79,11 +78,10 @@ fn a_raise_during_the_load_and_from_a_later_call_reports_the_library_line() { fn a_raise_in_the_second_of_two_middle_blocks_maps_to_its_own_line() { let (vm, _) = require_vm( vec![( - "## lib", - None, + "/lib", placed( "lib", - "## lib", + "/lib", &[ ("first = 1", "section `lib` lua 2", 10), ( @@ -96,7 +94,7 @@ fn a_raise_in_the_second_of_two_middle_blocks_maps_to_its_own_line() { )], &null_emitter(), ); - let error = failing(&vm, &program("require '## lib'")); + let error = failing(&vm, &program("require '/lib'")); assert!( error.starts_with("section `lib` lua 3:21: ") && error.contains("section `lib` lua 3:21: second fault"), @@ -108,11 +106,10 @@ fn a_raise_in_the_second_of_two_middle_blocks_maps_to_its_own_line() { fn library_and_caller_markers_each_map_once_under_one_leading_tag() { let (vm, _) = require_vm( vec![( - "## lib", - None, + "/lib", placed( "lib", - "## lib", + "/lib", &[( "function boom()\n error('deep fault')\nend", "section `lib` prologue", @@ -122,7 +119,7 @@ fn library_and_caller_markers_each_map_once_under_one_leading_tag() { )], &null_emitter(), ); - let caller = at("require '## lib'\nboom()", "section `Main` prologue", 30); + let caller = at("require '/lib'\nboom()", "section `Main` prologue", 30); let error = failing(&vm, &caller); assert!( error.starts_with("section `lib` prologue:51: "), @@ -154,7 +151,7 @@ fn a_parent_that_loaded_the_same_library_keeps_a_childs_mapped_line() { let lib = || { placed( "lib", - "## lib", + "/lib", &[( "function boom()\n error('deep fault')\nend", "section `lib` prologue", @@ -162,16 +159,16 @@ fn a_parent_that_loaded_the_same_library_keeps_a_childs_mapped_line() { )], ) }; - let (child, _) = require_vm(vec![("## lib", None, lib())], &null_emitter()); - let surfaced = failing(&child, &program("require '## lib'\nboom()")); + let (child, _) = require_vm(vec![("/lib", lib())], &null_emitter()); + let surfaced = failing(&child, &program("require '/lib'\nboom()")); assert!( surfaced.contains("section `lib` prologue:51: deep fault"), "the child must map the library line first, got {surfaced}" ); - let (parent, _) = require_vm(vec![("## lib", None, lib())], &null_emitter()); + let (parent, _) = require_vm(vec![("/lib", lib())], &null_emitter()); let error = failing( &parent, - &program(&format!("require '## lib'\nerror([==[{surfaced}]==], 0)")), + &program(&format!("require '/lib'\nerror([==[{surfaced}]==], 0)")), ); assert!( error.contains(&surfaced), diff --git a/crates/runvil-internal/lua/src/tests/require/jump.rs b/crates/runvil-internal/lua/src/tests/require/jump.rs index b2e083898..a5769cf27 100644 --- a/crates/runvil-internal/lua/src/tests/require/jump.rs +++ b/crates/runvil-internal/lua/src/tests/require/jump.rs @@ -7,22 +7,22 @@ use super::*; #[test] fn jump_during_a_load_raises_and_the_refusal_ends_with_the_load() { let (vm, _) = require_vm( - vec![("## a", None, library("a", "## a", &["jump '## elsewhere'"]))], + vec![("/a", library("a", "/a", &["jump '../elsewhere'"]))], &null_emitter(), ); - let error = failure(&vm, "require '## a'"); + let error = failure(&vm, "require '/a'"); assert!( error.contains("jump is unavailable while a library loads"), "jump must refuse during a load, got {error}" ); let step = vm - .start_block_coro(&program("pcall(require, '## a')\njump '## after'")) + .start_block_coro(&program("pcall(require, '/a')\njump '../after'")) .expect("the jump after a failed load must transfer"); assert!( matches!( step, - CoroStep::Done(LuaBlockResult::Jump(ref heading, line)) - if heading == "## after" && line == NonZeroU32::new(2) + CoroStep::Done(LuaBlockResult::Jump(ref path, line)) + if path == "../after" && line == NonZeroU32::new(2) ), "a jump after the load must transfer from its own line, got {step:?}" ); @@ -32,20 +32,19 @@ fn jump_during_a_load_raises_and_the_refusal_ends_with_the_load() { fn a_library_function_that_jumps_after_the_load_jumps_for_the_caller() { let (vm, _) = require_vm( vec![( - "## a", - None, - library("a", "## a", &["function go() jump '## next' end"]), + "/a", + library("a", "/a", &["function go() jump '../next' end"]), )], &null_emitter(), ); let step = vm - .start_block_coro(&program("require '## a'\ngo()")) + .start_block_coro(&program("require '/a'\ngo()")) .expect("the jump must transfer"); assert!( matches!( step, - CoroStep::Done(LuaBlockResult::Jump(ref heading, line)) - if heading == "## next" && line == NonZeroU32::new(100) + CoroStep::Done(LuaBlockResult::Jump(ref path, line)) + if path == "../next" && line == NonZeroU32::new(100) ), "the library function's jump must transfer the caller from the library's line, got \ {step:?}" diff --git a/crates/runvil-internal/lua/src/tests/require/prose.rs b/crates/runvil-internal/lua/src/tests/require/prose.rs index 20f258563..6e70f1393 100644 --- a/crates/runvil-internal/lua/src/tests/require/prose.rs +++ b/crates/runvil-internal/lua/src/tests/require/prose.rs @@ -40,12 +40,11 @@ fn fill_var_x( fn a_library_block_reads_its_own_prose_filled_from_the_callers_var() { let (vm, _) = require_vm_rendering( vec![( - "## lib", - None, + "/lib", with_prose( library( "lib", - "## lib", + "/lib", &["local ok = pcall(function() prose = 'x' end)\n\ return prose .. '|' .. tostring(ok)"], ), @@ -59,7 +58,7 @@ fn a_library_block_reads_its_own_prose_filled_from_the_callers_var() { .expect("the caller's prose must install"); let value = returned( &vm, - "var.x = 'seven'\nlocal seen = require '## lib'\nreturn seen .. '|' .. prose", + "var.x = 'seven'\nlocal seen = require '/lib'\nreturn seen .. '|' .. prose", ); assert_eq!(value.as_deref(), Some("lib sees seven|false|caller text")); } @@ -69,19 +68,19 @@ fn a_library_blocks_prose_renders_with_the_line_its_template_starts_on() { let mut lib = with_prose( library( "lib", - "## lib", + "/lib", &["first = prose", "return first .. '|' .. prose"], ), &["one", "two"], ); lib.blocks[0].line = NonZeroU32::new(40); let (vm, _) = require_vm_rendering( - vec![("## lib", None, lib)], + vec![("/lib", lib)], |template, line, _| Ok(format!("{template}@{line:?}")), &null_emitter(), ); assert_eq!( - returned(&vm, "return require '## lib'").as_deref(), + returned(&vm, "return require '/lib'").as_deref(), Some("one@Some(40)|two@None"), "a block with no prose before it renders with no line" ); @@ -92,9 +91,8 @@ fn the_callers_read_prose_keeps_its_memo_across_a_require() { let renders = Arc::new(AtomicUsize::new(0)); let (vm, _) = require_vm_rendering( vec![( - "## lib", - None, - with_prose(library("lib", "## lib", &["return prose"]), &["lib text"]), + "/lib", + with_prose(library("lib", "/lib", &["return prose"]), &["lib text"]), )], fill_var_x, &null_emitter(), @@ -108,7 +106,7 @@ fn the_callers_read_prose_keeps_its_memo_across_a_require() { let value = returned( &vm, "var.x = 'one'\nlocal before = prose\nvar.x = 'two'\n\ - local seen = require '## lib'\nreturn before .. '|' .. seen .. '|' .. prose", + local seen = require '/lib'\nreturn before .. '|' .. seen .. '|' .. prose", ); assert_eq!( value.as_deref(), @@ -125,10 +123,9 @@ fn the_callers_read_prose_keeps_its_memo_across_a_require() { fn a_load_that_raises_still_puts_the_callers_prose_back() { let (vm, _) = require_vm_rendering( vec![( - "## bad", - None, + "/bad", with_prose( - library("bad", "## bad", &["error('fault: ' .. prose)"]), + library("bad", "/bad", &["error('fault: ' .. prose)"]), &["bad text"], ), )], @@ -139,7 +136,7 @@ fn a_load_that_raises_still_puts_the_callers_prose_back() { .expect("the caller's prose must install"); let value = returned( &vm, - "local ok, err = pcall(require, '## bad')\n\ + "local ok, err = pcall(require, '/bad')\n\ return tostring(ok) .. '|' .. tostring(err):match('fault: .*$') .. '|' .. prose", ); assert_eq!(value.as_deref(), Some("false|fault: bad text|caller text")); diff --git a/crates/runvil-internal/lua/src/tests/require/reports.rs b/crates/runvil-internal/lua/src/tests/require/reports.rs index d532d16a0..171fbf4cd 100644 --- a/crates/runvil-internal/lua/src/tests/require/reports.rs +++ b/crates/runvil-internal/lua/src/tests/require/reports.rs @@ -25,19 +25,19 @@ fn a_first_load_reports_its_start_and_success_and_a_cached_call_nothing() { let recorder = Recorder::default(); let (vm, _) = require_vm( vec![ - ("## a", None, library("a", "## a", &["require '## b'"])), - ("## b", None, library("b", "## b", &["return 'b'"])), + ("/a", library("a", "/a", &["require '/b'"])), + ("/b", library("b", "/b", &["return 'b'"])), ], recorder.emitter(), ); - returned(&vm, "require '## a'\nrequire '## a'\nrequire '## b'"); + returned(&vm, "require '/a'\nrequire '/a'\nrequire '/b'"); assert_eq!( library_loads(&recorder), [ - load("started", "## a"), - load("started", "## b"), - load("succeeded", "## b"), - load("succeeded", "## a"), + load("started", "/a"), + load("started", "/b"), + load("succeeded", "/b"), + load("succeeded", "/a"), ] ); } @@ -46,17 +46,17 @@ fn a_first_load_reports_its_start_and_success_and_a_cached_call_nothing() { fn a_failing_load_reports_its_start_and_failure_on_every_try() { let recorder = Recorder::default(); let (vm, _) = require_vm( - vec![("## a", None, library("a", "## a", &["error('fault')"]))], + vec![("/a", library("a", "/a", &["error('fault')"]))], recorder.emitter(), ); - returned(&vm, "pcall(require, '## a')\npcall(require, '## a')"); + returned(&vm, "pcall(require, '/a')\npcall(require, '/a')"); assert_eq!( library_loads(&recorder), [ - load("started", "## a"), - load("failed", "## a"), - load("started", "## a"), - load("failed", "## a"), + load("started", "/a"), + load("failed", "/a"), + load("started", "/a"), + load("failed", "/a"), ] ); } diff --git a/crates/runvil-internal/lua/src/tests/sandbox.rs b/crates/runvil-internal/lua/src/tests/sandbox.rs index bff11230c..057124ff6 100644 --- a/crates/runvil-internal/lua/src/tests/sandbox.rs +++ b/crates/runvil-internal/lua/src/tests/sandbox.rs @@ -71,9 +71,9 @@ fn dangerous_globals_absent() { fn an_installed_require_is_runvils_and_loads_no_module() { let vm = TestVm::new().shims().build(); vm.install_require( - |heading, _| { + |path| { Err::(crate::make_lua_runtime_error(format!( - "no section `{heading}` within reach of require" + "section `{path}` not found" ))) }, |template, _, _| Ok(template.to_owned()), @@ -94,7 +94,7 @@ fn an_installed_require_is_runvils_and_loads_no_module() { assert_eq!( returned, format!( - "function|false|prompt:1: no section `os` within reach of require. {}|nil|nil", + "function|false|prompt:1: section `os` not found. {}|nil|nil", ErrorCode::LuaRuntime.prose() ) ); diff --git a/crates/runvil-internal/lua/src/tests/section_vm.rs b/crates/runvil-internal/lua/src/tests/section_vm.rs index fd9e7a648..dc85ae7bf 100644 --- a/crates/runvil-internal/lua/src/tests/section_vm.rs +++ b/crates/runvil-internal/lua/src/tests/section_vm.rs @@ -352,11 +352,11 @@ fn section_lifecycle_failures_report_their_phase() { fn call_with_a_non_string_target_errors() { // The control callback resolves its target through the same // `resolve_section_target` boundary as the Engine: a number is not a - // heading, and the error says so. + // section path, and the error says so. let vm = TestVm::new().build(); vm.install_test_control_globals( |target, _, _| resolve_section_target(target).map_err(crate::error::make_lua_error), - |_| { + |_, _| { Err(crate::make_lua_runtime_error( "prompt reads are not needed here", )) diff --git a/crates/runvil-internal/lua/src/vm.rs b/crates/runvil-internal/lua/src/vm.rs index 67b5362c7..357e53194 100644 --- a/crates/runvil-internal/lua/src/vm.rs +++ b/crates/runvil-internal/lua/src/vm.rs @@ -50,22 +50,13 @@ fn log_byte_budget(log_events: u32) -> usize { (log_events as usize).saturating_mul(LUA_LOG_CHARACTER_LIMIT) } -/// A `jump` the running chunk recorded: the target heading, and the prompt -/// line of the author's `jump` call. +/// A `jump` the running chunk recorded: the target's section path, and the +/// prompt line of the author's `jump` call. type Jump = (String, Option); /// The failure of a poisoned jump slot. const JUMP_SLOT_POISONED: &str = "jump slot poisoned"; -/// Collects owned values into a 1-based Lua sequence table. -fn pack_sequence(lua: &Lua, values: Vec) -> mlua::Result { - let table = lua.create_table_with_capacity(values.len(), 0)?; - for (index, value) in values.into_iter().enumerate() { - table.raw_set(index + 1, value)?; - } - Ok(table) -} - /// A section VM's stage before Engine injection: construction, the run's /// limits, and the raw-model-id opt-in. #[derive(Debug)] @@ -386,7 +377,7 @@ mod tests { let vm = TestVm::new().shims().build(); poison(&vm.jump_slot); let kind: String = vm.eval( - "local ok, err = pcall(jump, '## Next')\n\ + "local ok, err = pcall(jump, '../Next')\n\ assert(not ok, 'jump must raise')\n\ return tostring(err.kind)", ); diff --git a/crates/runvil-internal/lua/src/vm/install.rs b/crates/runvil-internal/lua/src/vm/install.rs index a3399ccda..e16745389 100644 --- a/crates/runvil-internal/lua/src/vm/install.rs +++ b/crates/runvil-internal/lua/src/vm/install.rs @@ -11,7 +11,7 @@ use runvil_types::emitter::Emitter; use runvil_vfs::ScopeHandle; use serde_json::Value as Json; -use super::{JUMP_SLOT_POISONED, SectionVm, pack_sequence}; +use super::{JUMP_SLOT_POISONED, SectionVm}; use crate::coro::install_shims; use crate::engine_globals::install_log; use crate::engine_globals_store::install_store_table; @@ -19,14 +19,14 @@ use crate::engine_globals_store::install_store_table; use crate::error::Error; use crate::error::{self, Result}; use crate::prose::ProseState; -use crate::section::{Library, LibraryKey, SectionText, resolve_section_target}; +use crate::section::{Library, LibraryKey, SectionRead, resolve_section_target}; use crate::sys::{SYS_LIVE_POISONED, seal_sys}; #[cfg(test)] use crate::var::var_to_json; impl SectionVm { - /// Installs `log`, `store`, and `json` as persistent globals for the - /// section's whole lifecycle. + /// Installs `log`, `store`, `json`, and `markdown` as persistent + /// globals for the section's whole lifecycle. /// /// Called once after Engine injection. /// The closures capture owned strings, a clone of the emitter, and Arc @@ -67,7 +67,8 @@ impl SectionVm { section, &self.store_conflict, )?; - crate::json::install(&self.lua) + crate::json::install(&self.lua)?; + crate::markdown::install(&self.lua) } /// Takes the claims-model conflict the direct store closures recorded @@ -91,8 +92,8 @@ impl SectionVm { } /// Installs `call`, `jump`, and the `prompt` reads (`prompt.section` - /// and `prompt.list`) as persistent globals for the section's whole - /// lifecycle. + /// and `prompt.section_or`) as persistent globals for the section's + /// whole lifecycle. /// /// Test-only: production installs `jump` and the `prompt` reads through /// [`install_control_globals`](Self::install_control_globals) @@ -112,7 +113,10 @@ impl SectionVm { pub(crate) fn install_test_control_globals(&self, call_callback: E, read: R) -> Result<()> where E: Fn(Value, Option, Json) -> std::result::Result + Send + 'static, - R: Fn(String) -> std::result::Result, Error> + Send + Sync + 'static, + R: Fn(String, Option) -> std::result::Result + + Send + + Sync + + 'static, { let globals = self.lua.globals(); let call_fn = self @@ -130,7 +134,10 @@ impl SectionVm { } /// Installs the control surface: `jump` and the `prompt` reads as Rust - /// callbacks (none suspends). + /// callbacks (none suspends). `read` looks up the section a path + /// names, starting from the library whose code made the read or, when + /// a section's own code did, from that section. A path it refuses is + /// its error, raised at the call. /// /// The suspending calls are the yield shims /// [`install_coro_shims`](Self::install_coro_shims) installs, and the @@ -142,7 +149,10 @@ impl SectionVm { /// Returns a `LuaRuntime` failure if any global cannot be installed. pub fn install_control_globals(&self, read: R) -> Result<()> where - R: Fn(String) -> std::result::Result, E> + Send + Sync + 'static, + R: Fn(String, Option) -> std::result::Result + + Send + + Sync + + 'static, E: std::error::Error + Send + Sync + 'static, { let globals = self.lua.globals(); @@ -174,13 +184,11 @@ impl SectionVm { /// Installs Runvil's `require` as a persistent global. /// - /// `resolver` finds a library from the heading as written. While a - /// library loads, a `require` passes that library's key, so the - /// library's own helpers resolve from its position wherever it is - /// required from; every other call passes `None`. An out-of-reach or - /// malformed heading is the resolver's error, raised at the call. Each - /// first load reports through a clone of `emitter` under `section`, and - /// `jump` refuses while any library loads. + /// `resolver` finds a library from the section path as written. A + /// path that is malformed, or that names no section, is the + /// resolver's error, raised at the call. Each first load reports + /// through a clone of `emitter` under `section`, and `jump` refuses + /// while any library loads. /// /// While a library block runs, its `prose` is its own: a lazy read of /// the block's prose template, rendered at most once through `render` @@ -202,10 +210,7 @@ impl SectionVm { section: &str, ) -> Result<()> where - R: Fn(String, Option) -> std::result::Result - + Send - + Sync - + 'static, + R: Fn(String) -> std::result::Result + Send + Sync + 'static, E: std::error::Error + Send + Sync + 'static, P: Fn(&str, Option, ProseState<'_>) -> mlua::Result + Send @@ -245,12 +250,12 @@ impl SectionVm { library and call jump from the section after require returns", )); } - let heading = resolve_section_target(target)?; + let path = resolve_section_target(target)?; let line = error::author_line(lua); let mut slot = jump_slot.lock().map_err(|_| { mlua::Error::external(error::make_internal_error(JUMP_SLOT_POISONED)) })?; - *slot = Some((heading, line)); + *slot = Some((path, line)); Err(mlua::Error::external("jump transfer")) }) .map_err(error::make_lua_error)?; @@ -259,47 +264,18 @@ impl SectionVm { .map_err(error::make_lua_error) } + /// Installs the `prompt` table over `read` (see + /// [`prompt_reads`](crate::prompt_reads)). fn install_prompt_table(&self, globals: &mlua::Table, read: R) -> Result<()> where - R: Fn(String) -> std::result::Result, E> + Send + Sync + 'static, + R: Fn(String, Option) -> std::result::Result + + Send + + Sync + + 'static, E: std::error::Error + Send + Sync + 'static, { - let read = Arc::new(read); - let read_markdown = Arc::clone(&read); - let section = self - .lua - .create_function(move |_, target: Value| { - let text = read_markdown(resolve_section_target(target)?) - .map_err(mlua::Error::external)?; - Ok(text.map(|text| text.markdown)) - }) - .map_err(error::make_lua_error)?; - let list = self - .lua - .create_function(move |lua, target: Value| { - let heading = resolve_section_target(target)?; - match read(heading.clone()).map_err(mlua::Error::external)? { - None => Ok(Value::Nil), - Some(text) if text.items.is_empty() => Err(mlua::Error::external(format!( - "section `{}` has no pre-parsed items", - heading.trim() - ))), - Some(text) => pack_sequence(lua, text.items).map(Value::Table), - } - }) - .map_err(error::make_lua_error)?; - let prompt = self.lua.create_table().map_err(error::make_lua_error)?; - prompt - .raw_set("section", section) - .map_err(error::make_lua_error)?; - prompt - .raw_set("list", list) - .map_err(error::make_lua_error)?; - globals - .raw_set("prompt", prompt) - .map_err(error::make_lua_error) + crate::prompt_reads::install(&self.lua, globals, read) } - /// Replaces the sealed Lua `sys` global at the section entry's first /// tool call, to expose `sys.model` once the section's current model is /// fixed. diff --git a/crates/runvil-internal/lua/src/vm/run.rs b/crates/runvil-internal/lua/src/vm/run.rs index 330890270..ea56eb571 100644 --- a/crates/runvil-internal/lua/src/vm/run.rs +++ b/crates/runvil-internal/lua/src/vm/run.rs @@ -169,8 +169,8 @@ impl SectionVm { Ok(Ok(CoroStep::Yielded(thread, values))) } result => { - if let Some((heading, line)) = self.take_jump()? { - return Ok(Ok(CoroStep::Done(LuaBlockResult::Jump(heading, line)))); + if let Some((path, line)) = self.take_jump()? { + return Ok(Ok(CoroStep::Done(LuaBlockResult::Jump(path, line)))); } match result { Ok(returned) => Ok(Ok(CoroStep::Done(LuaBlockResult::Returned( diff --git a/crates/runvil-internal/markdown/Cargo.toml b/crates/runvil-internal/markdown/Cargo.toml new file mode 100644 index 000000000..2548f621e --- /dev/null +++ b/crates/runvil-internal/markdown/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "runvil-markdown" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +pulldown-cmark.workspace = true +serde_json.workspace = true +serde_yaml_ng.workspace = true + +[lints] +workspace = true diff --git a/crates/runvil-internal/markdown/README.md b/crates/runvil-internal/markdown/README.md new file mode 100644 index 000000000..eff70577d --- /dev/null +++ b/crates/runvil-internal/markdown/README.md @@ -0,0 +1,3 @@ +# Markdown blocks + +This crate reads Markdown text into a tree of plain blocks. Each block holds its source, the lines it was read from with the blank lines after them, and the sources of a parse are the whole text, so writing them back gives the text that was read. The crate also holds the two grammars that name things in that tree: the section path and the block selector. `pulldown-cmark` reads the Markdown with the options the prompt loader uses, so a heading or a fence means the same thing in both. It holds no Lua and does no execution. diff --git a/crates/runvil-internal/markdown/src/block.rs b/crates/runvil-internal/markdown/src/block.rs new file mode 100644 index 000000000..8d60d6dfb --- /dev/null +++ b/crates/runvil-internal/markdown/src/block.rs @@ -0,0 +1,219 @@ +//! The block model: what a parsed document is made of. + +use serde_json::Value as Json; + +/// One block of a parsed document. +/// +/// A document is a list of blocks. A section holds the blocks under its +/// heading, subsections included, so the list is a tree. Every block +/// records the line it starts on: a 1-based line of the text that was +/// parsed. +/// +/// Every block also holds its `source`: the lines it was read from, +/// exactly as written and each with its line ending, and the blank lines +/// after them. Blank lines at the very top of the text are in the first +/// block's source. A section's source is its heading's, since the blocks +/// under the heading hold their own. So no character is in two sources, +/// and the sources of a document, in file order, are the whole text. +#[derive(Clone, Debug, PartialEq)] +pub enum Block { + /// The YAML mapping that opens the text. It is only ever the first + /// block of a document. + Frontmatter { + /// The line of the opening `---`. + line: usize, + /// Both delimiter lines, the lines between them, and the blank + /// lines after. + source: String, + /// The mapping, as a JSON object. + data: Json, + }, + /// A heading and everything under it. + Section { + /// The heading's line. + line: usize, + /// The heading's lines and the blank lines after them. + source: String, + /// The heading's level, 1 to 6. + level: u8, + /// The heading's text with inline markup removed, trimmed. + title: String, + /// The blocks under the heading: the section's own content, then + /// its subsections. + blocks: Vec, + }, + /// A run of Markdown that is no other kind: paragraphs, block quotes, + /// indented code, HTML, and link definitions. + Prose { + /// The first non-blank line of the run. + line: usize, + /// The run's lines and the blank lines after them. + source: String, + /// The source without leading blank lines or trailing + /// whitespace. It is never empty. + text: String, + }, + /// A fenced code block. + Fence { + /// The line of the opening fence. + line: usize, + /// Both fence lines, the lines between them, and the blank lines + /// after. + source: String, + /// The first word of the info string, or empty. + lang: String, + /// The whole info string, trimmed. + info: String, + /// The lines between the two fence lines, without the final line + /// ending. + text: String, + }, + /// A bullet or numbered list. + List { + /// The line of the first marker. + line: usize, + /// The list's lines, markers included, and the blank lines after + /// them. + source: String, + /// Whether the list is numbered. + ordered: bool, + /// Each item's source with its marker removed. + items: Vec, + }, + /// A thematic break. + Rule { + /// The break's line. + line: usize, + /// The break's line and the blank lines after it. + source: String, + }, +} + +impl Block { + /// The block's kind. + #[must_use] + pub fn kind(&self) -> Kind { + match self { + Block::Frontmatter { .. } => Kind::Frontmatter, + Block::Section { .. } => Kind::Section, + Block::Prose { .. } => Kind::Prose, + Block::Fence { .. } => Kind::Fence, + Block::List { .. } => Kind::List, + Block::Rule { .. } => Kind::Rule, + } + } + + /// The line the block starts on. + #[must_use] + pub fn line(&self) -> usize { + match self { + Block::Frontmatter { line, .. } + | Block::Section { line, .. } + | Block::Prose { line, .. } + | Block::Fence { line, .. } + | Block::List { line, .. } + | Block::Rule { line, .. } => *line, + } + } + + /// The lines the block was read from, as written. A section's are + /// its heading's. + #[must_use] + pub fn source(&self) -> &str { + match self { + Block::Frontmatter { source, .. } + | Block::Section { source, .. } + | Block::Prose { source, .. } + | Block::Fence { source, .. } + | Block::List { source, .. } + | Block::Rule { source, .. } => source, + } + } +} + +/// The kind of a block, which is also the word a selector names it by. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + /// The YAML mapping that opens the text. + Frontmatter, + /// A heading and everything under it. + Section, + /// A run of Markdown that is no other kind. + Prose, + /// A fenced code block. + Fence, + /// A bullet or numbered list. + List, + /// A thematic break. + Rule, +} + +impl Kind { + /// Every kind, in the order messages list them. + pub const ALL: [Kind; 6] = [ + Kind::Frontmatter, + Kind::Section, + Kind::Prose, + Kind::Fence, + Kind::List, + Kind::Rule, + ]; + + /// The kind's name: the value of a block's `kind` field. + #[must_use] + pub fn name(self) -> &'static str { + match self { + Kind::Frontmatter => "frontmatter", + Kind::Section => "section", + Kind::Prose => "prose", + Kind::Fence => "fence", + Kind::List => "list", + Kind::Rule => "rule", + } + } + + /// The kind `name` names, or `None` for any other text. + #[must_use] + pub fn from_name(name: &str) -> Option { + Kind::ALL.into_iter().find(|kind| kind.name() == name) + } +} + +#[cfg(test)] +mod tests { + use super::{Block, Kind}; + + #[test] + fn every_kind_is_found_by_its_own_name_and_no_other_text_names_one() { + for kind in Kind::ALL { + assert_eq!(Kind::from_name(kind.name()), Some(kind)); + } + for other in ["", "Fence", "fences", "break", "code"] { + assert_eq!(Kind::from_name(other), None, "{other:?}"); + } + } + + #[test] + fn a_block_reports_its_kind_its_line_and_its_source() { + let block = Block::Rule { + line: 7, + source: "---\n\n".to_owned(), + }; + assert_eq!( + (block.kind(), block.line(), block.source()), + (Kind::Rule, 7, "---\n\n") + ); + // A section's source is its heading's, not its members'. + let block = Block::Section { + line: 3, + source: "## A\n".to_owned(), + level: 2, + title: "A".to_owned(), + blocks: vec![block], + }; + assert_eq!( + (block.kind(), block.line(), block.source()), + (Kind::Section, 3, "## A\n") + ); + } +} diff --git a/crates/runvil-internal/markdown/src/index.rs b/crates/runvil-internal/markdown/src/index.rs new file mode 100644 index 000000000..571beb5a3 --- /dev/null +++ b/crates/runvil-internal/markdown/src/index.rs @@ -0,0 +1,82 @@ +//! How an index counts: 1 is the first and -1 is the last. + +/// Returns the position, from 0, of the `n`th of `len` matches, counting +/// from 1 at the front, or from -1 at the back when `n` is negative. +/// Zero, and an `n` past either end, names nothing. +#[must_use] +pub fn position(len: usize, n: i64) -> Option { + let count = i64::try_from(len).ok()?; + let at = if n > 0 { n - 1 } else { count.checked_add(n)? }; + usize::try_from(at).ok().filter(|at| *at < len) +} + +/// Returns the `n`th of `list`, as [`position`] counts. +#[must_use] +pub fn nth(list: &[T], n: i64) -> Option<&T> { + list.get(position(list.len(), n)?) +} + +/// Says where several matches are, for the message that asks for an +/// index: `, at lines 20, 24, and 30`. Each of `lines` is the line of one +/// match, or `None` for a match that records none. The text is empty when +/// any line is unknown, so a message never names a line it cannot. +pub(crate) fn at_lines(lines: &[Option]) -> String { + let Some(lines) = lines + .iter() + .map(|line| line.map(|line| line.to_string())) + .collect::>>() + else { + return String::new(); + }; + match lines.as_slice() { + [] => String::new(), + [one] => format!(", at line {one}"), + [first, second] => format!(", at lines {first} and {second}"), + [rest @ .., last] => format!(", at lines {}, and {last}", rest.join(", ")), + } +} + +#[cfg(test)] +mod tests { + use super::{at_lines, nth, position}; + + #[test] + fn a_position_counts_from_zero_and_only_inside_the_matches() { + assert_eq!(position(3, 1), Some(0)); + assert_eq!(position(3, -1), Some(2)); + assert_eq!(position(3, 4), None); + assert_eq!(position(0, 1), None); + } + + #[test] + fn the_lines_of_several_matches_read_as_a_sentence_lists_them() { + assert_eq!(at_lines(&[Some(3), Some(7)]), ", at lines 3 and 7"); + assert_eq!( + at_lines(&[Some(20), Some(24), Some(30)]), + ", at lines 20, 24, and 30" + ); + assert_eq!(at_lines(&[Some(5)]), ", at line 5"); + // One unknown line leaves the lines out altogether. + assert_eq!(at_lines(&[Some(3), None]), ""); + assert_eq!(at_lines(&[]), ""); + } + + #[test] + fn a_positive_index_counts_from_the_front_and_a_negative_one_from_the_back() { + let list = ["a", "b", "c"]; + assert_eq!(nth(&list, 1), Some(&"a")); + assert_eq!(nth(&list, 3), Some(&"c")); + assert_eq!(nth(&list, -1), Some(&"c")); + assert_eq!(nth(&list, -3), Some(&"a")); + } + + #[test] + fn zero_and_an_index_past_either_end_name_nothing() { + let list = ["a", "b", "c"]; + for n in [0, 4, -4, i64::MAX, i64::MIN] { + assert_eq!(nth(&list, n), None, "{n}"); + } + assert_eq!(nth::<&str>(&[], 1), None); + assert_eq!(nth::<&str>(&[], -1), None); + } +} diff --git a/crates/runvil-internal/markdown/src/lib.rs b/crates/runvil-internal/markdown/src/lib.rs new file mode 100644 index 000000000..8e00ed1d4 --- /dev/null +++ b/crates/runvil-internal/markdown/src/lib.rs @@ -0,0 +1,47 @@ +//! Markdown as plain data: text read into blocks, blocks written back as +//! text, and the two grammars that name a section and a block. +//! +//! [`parse`](fn@parse) reads any Markdown text into a list of [`Block`]s. +//! Each block holds its source, the lines it was read from, and +//! [`render`](fn@render) writes those sources back through a [`Writer`]. +//! `pulldown-cmark` reads the Markdown and `serde_yaml_ng` reads the +//! frontmatter; this crate only groups what they report. +//! [`split`] is the section path grammar, which every function that names +//! a section shares, and [`Path`] is the form that names a section of +//! parsed text. A [`Selector`] names one kind of block or every block, +//! [`pick`] says which of its matches a lookup keeps, and [`nth`] is how +//! an index counts. +//! +//! The crate holds no Lua. Its caller turns blocks into Lua tables and +//! back. +//! +//! ## Invariants +//! +//! - Depends on no workspace crate. `cargo test -p build-xtask` enforces +//! the product and container boundaries. +//! - `pulldown-cmark` is named in one module, `parse::events`. +//! - Parsing never fails: any text is a document. +//! - The sources of a parse are the text. Every character of the text +//! that was read is in the source of exactly one block, so +//! `render(&parse(text))` is `text` without its byte order mark and +//! with `\n` for every line ending. Text that holds nothing but blank +//! lines is the one exception: it has no block to hold them. +//! - A grammar refuses text with a message that says what was wrong and +//! what was required. The caller puts its own function's name in front +//! and raises it as the universal error. + +mod block; +mod index; +mod parse; +mod path; +mod render; +mod selector; +#[cfg(test)] +mod test_support; + +pub use block::{Block, Kind}; +pub use index::{nth, position}; +pub use parse::{parse, parse_body}; +pub use path::{Path, Step, Steps, escape_title, split}; +pub use render::{Writer, render}; +pub use selector::{Pick, Selector, pick}; diff --git a/crates/runvil-internal/markdown/src/parse-tests-source.rs b/crates/runvil-internal/markdown/src/parse-tests-source.rs new file mode 100644 index 000000000..83bfeb120 --- /dev/null +++ b/crates/runvil-internal/markdown/src/parse-tests-source.rs @@ -0,0 +1,454 @@ +//! Tests of a parse's sources: which lines each block holds, that the +//! sources are the whole text, and that each is the text of its block +//! and of no other. + +use serde_json::json; + +use crate::parse::BLANK; +use crate::test_support::{ + BODIES, GUIDE, SEED, Seeded, bullets, fence, flat, frontmatter, numbered, prose, rule, section, + sourced, without_sources, +}; +use crate::{Block, Kind, parse, parse_body}; + +/// The source of every block of `text`, in file order. +fn sources(text: &str) -> Vec { + flat(&parse(text)) + .into_iter() + .map(|block| block.source().to_owned()) + .collect() +} + +#[test] +fn a_source_is_the_blocks_own_lines_and_the_blank_lines_after_them() { + let text = "# Fruit\n\nSome words.\n\n- apples\n- pears\n\n## Citrus\n\n### Lemon\n"; + let lemon = sourced(section(10, 3, "Lemon", vec![]), "### Lemon\n"); + let citrus = sourced(section(8, 2, "Citrus", vec![lemon]), "## Citrus\n\n"); + let members = vec![ + sourced(prose(3, "Some words."), "Some words.\n\n"), + sourced(bullets(5, &["apples", "pears"]), "- apples\n- pears\n\n"), + citrus, + ]; + assert_eq!( + parse(text), + [sourced(section(1, 1, "Fruit", members), "# Fruit\n\n")] + ); +} + +#[test] +fn every_kind_of_block_takes_the_blank_lines_after_it() { + let text = "---\na: 1\n---\n\n# H\n \nwords\n\n```\nx\n```\n\t\n- a\n\n***\n\n\nend"; + assert_eq!( + sources(text), + [ + "---\na: 1\n---\n\n", + "# H\n \n", + "words\n\n", + "```\nx\n```\n\t\n", + "- a\n\n", + "***\n\n\n", + "end", + ] + ); + // A block with no blank line after it holds its own lines alone. + assert_eq!( + sources("# H\nwords\n- a\n```\nx\n```\n***\nend\n"), + [ + "# H\n", + "words\n", + "- a\n", + "```\nx\n```\n", + "***\n", + "end\n" + ] + ); +} + +#[test] +fn the_blank_lines_between_two_headings_are_in_the_upper_headings_source() { + assert_eq!( + sources("# A\n\n\n## B\n \n# C"), + ["# A\n\n\n", "## B\n \n", "# C"] + ); + // No block is made for them, so the lower heading is still the first + // member of the upper one. + let blocks = parse("# A\n\n## B\n"); + assert_eq!( + without_sources(&blocks), + [section(1, 1, "A", vec![section(3, 2, "B", vec![])])] + ); +} + +#[test] +fn the_blank_lines_at_the_top_are_in_the_first_blocks_source_and_its_line_is_its_own() { + for (text, line) in [ + ("\n \n# H\n", 3), + ("\n\nwords", 3), + ("\t\n- a\n", 2), + ("\n```\nx\n```\n", 2), + ("\n\n\n***\n", 4), + ] { + let blocks = parse(text); + let [block] = blocks.as_slice() else { + panic!("{text:?} is one block"); + }; + assert_eq!((block.source(), block.line()), (text, line), "{text:?}"); + } + // Only the first block's source starts with them. + assert_eq!(sources("\n\n# A\n\nwords\n"), ["\n\n# A\n\n", "words\n"]); + // A blank line above `---` leaves no frontmatter, so the rule holds it. + assert_eq!(sources("\n---\na: 1\n---\n"), ["\n---\n", "a: 1\n---\n"]); +} + +#[test] +fn text_of_blank_lines_alone_has_no_block_to_hold_them() { + for text in ["", "\n", "\n\n \n\t\n", " "] { + assert_eq!(parse(text), [], "{text:?}"); + assert_eq!(parse_body(text, 1), [], "{text:?}"); + } + // After frontmatter they have a block above them. + let text = "---\na: 1\n---\n\n \n"; + assert_eq!(parse(text), [sourced(frontmatter(json!({"a": 1})), text)]); +} + +#[test] +fn the_last_block_has_no_line_ending_when_the_text_has_none() { + assert_eq!(sources("# A\nwords"), ["# A\n", "words"]); + assert_eq!(sources("# A"), ["# A"]); + assert_eq!(sources("words "), ["words "]); + assert_eq!(sources("- a\n- b"), ["- a\n- b"]); + assert_eq!(sources("```\nx\n```"), ["```\nx\n```"]); + assert_eq!(sources("---\na: 1\n---"), ["---\na: 1\n---"]); + // Blank lines after it still end the text, with or without their own + // line ending. + assert_eq!(sources("words\n\n "), ["words\n\n "]); +} + +#[test] +fn a_headings_source_is_every_line_of_the_heading() { + assert_eq!( + sources("Line one\nline two\n===\n\nbody\n"), + ["Line one\nline two\n===\n\n", "body\n"] + ); + // A paragraph line directly above `---` is a heading, and both lines + // are its source. + assert_eq!(sources("words\n---\nafter"), ["words\n---\n", "after"]); + assert_eq!(sources("## Title ## \n\nx"), ["## Title ## \n\n", "x"]); +} + +#[test] +fn a_source_starts_at_the_start_of_a_line_and_holds_the_whole_of_its_last_line() { + // The indentation before a heading, a fence, a list, and a rule. + assert_eq!( + sources(" # H\n ```\n x\n ```\n - a\n ***\n"), + [" # H\n", " ```\n x\n ```\n", " - a\n", " ***\n"] + ); + // The spaces after a closing fence and after a rule. + assert_eq!( + sources("```\nx\n``` \nafter\n*** \n"), + ["```\nx\n``` \n", "after\n", "*** \n"] + ); + // Prose keeps the indentation its text keeps. + assert_eq!( + sources("# H\n\n code\n\n- a\n"), + ["# H\n\n", " code\n\n", "- a\n"] + ); +} + +#[test] +fn a_fence_with_no_closing_line_holds_the_rest_of_the_text() { + assert_eq!( + sources("# A\n\n```lua\nreturn 1\n\n# not a heading\n\n"), + ["# A\n\n", "```lua\nreturn 1\n\n# not a heading\n\n"] + ); +} + +#[test] +fn the_source_keeps_what_the_other_fields_do_not() { + // Each text is one block, whose fields read the same as those of a + // block written another way. + let cases = [ + ("3. x\n4. y\n", numbered(1, &["x", "y"])), + ("5) x\n6) y\n", numbered(1, &["x", "y"])), + ("1. x\n1. y\n", numbered(1, &["x", "y"])), + ("* x\n* y\n", bullets(1, &["x", "y"])), + ("+ x\n\n+ y\n", bullets(1, &["x", "y"])), + ("Title\n=====\n", section(1, 1, "Title", vec![])), + ("# Title #\n", section(1, 1, "Title", vec![])), + ( + "## See [API](http://x) *now*\n", + section(1, 2, "See API now", vec![]), + ), + ( + "---\n# a comment\nb: 2\na: 1\n---\n", + frontmatter(json!({"a": 1, "b": 2})), + ), + ( + "---\n{ b: 2, a: 1 }\n---\n", + frontmatter(json!({"a": 1, "b": 2})), + ), + ("~~~lua\nreturn 1\n~~~\n", fence(1, "lua", "return 1")), + ("````lua\nreturn 1\n````\n", fence(1, "lua", "return 1")), + ("___\n", rule(1)), + ("- - -\n", rule(1)), + ("*****\n", rule(1)), + ]; + for (text, fields) in cases { + assert_eq!(parse(text), [sourced(fields, text)], "{text:?}"); + } +} + +#[test] +fn a_source_is_cut_from_the_text_as_it_was_read() { + // Without the byte order mark, and with `\n` for every line ending. + assert_eq!( + sources("\u{feff}# T\r\n\r\nwords\r\n- a\r- b\r"), + ["# T\n\n", "words\n", "- a\n- b\n"] + ); + assert_eq!( + sources("---\r\na: 1\r\n---\r\n\r\nwords"), + ["---\na: 1\n---\n\n", "words"] + ); +} + +#[test] +fn a_bodys_sources_are_the_body() { + let own = "\n\nFill in this outline.\n\n```markdown\n# Title\n\n## Summary\n```"; + let found: Vec = parse_body(own, 23) + .iter() + .map(|block| block.source().to_owned()) + .collect(); + assert_eq!( + found, + [ + "\n\nFill in this outline.\n\n", + "```markdown\n# Title\n\n## Summary\n```" + ] + ); + // What opens like frontmatter is ordinary Markdown in a body. + let found: Vec = flat(&parse_body("---\na: b\n---\n\nx", 1)) + .into_iter() + .map(|block| block.source().to_owned()) + .collect(); + assert_eq!(found, ["---\n", "a: b\n---\n\n", "x"]); +} + +/// The bytes the leading blank lines of `source` take. +fn blank_above(source: &str) -> usize { + source + .split_inclusive('\n') + .take_while(|line| line.trim_matches(BLANK).is_empty()) + .map(str::len) + .sum() +} + +/// `block` by its own fields alone: no line, no source, and no blocks +/// under it. +fn own_fields(block: &Block) -> Block { + match sourced(block.clone(), "") { + Block::Frontmatter { data, .. } => frontmatter(data), + Block::Section { level, title, .. } => section(0, level, &title, vec![]), + Block::Prose { text, .. } => prose(0, &text), + Block::Fence { + lang, info, text, .. + } => Block::Fence { + line: 0, + source: String::new(), + lang, + info, + text, + }, + Block::List { ordered, items, .. } => Block::List { + line: 0, + source: String::new(), + ordered, + items, + }, + Block::Rule { .. } => rule(0), + } +} + +/// Asserts everything a parse promises of its sources, for `blocks` read +/// from `text`, whose line endings are all `\n` and whose first line is +/// line `first_line`. `context` names the text in a failure. +#[track_caller] +fn assert_sources(text: &str, blocks: &[Block], first_line: usize, context: &str) { + let found = flat(blocks); + if text.trim_matches(BLANK).is_empty() { + assert!(found.is_empty(), "{context}: blank text has no block"); + return; + } + // The sources are the text: nothing is missing, nothing is in two. + let joined: String = found.iter().map(|block| block.source()).collect(); + assert_eq!(joined, text, "{context}: the sources are the text"); + let mut offset = 0; + let mut before: Option<&Block> = None; + for (at, block) in found.iter().copied().enumerate() { + let source = block.source(); + let name = format!("{context}: block {at}, {source:?}"); + let start = offset; + offset += source.len(); + let odd = before.is_some_and(|before| continues_a_paragraph(before, block)); + // The spacing a writer adds between two prose blocks never applies + // inside one parse. + let both = + matches!(before, Some(Block::Prose { .. })) && matches!(block, Block::Prose { .. }); + assert!(!both, "{name} is prose right after prose"); + before = Some(block); + // Each is whole lines, so each starts at the start of a line. + if at + 1 < found.len() { + assert!(source.ends_with('\n'), "{name} ends with a line ending"); + } + let above = blank_above(source); + assert!(above < source.len(), "{name} holds a line of its own"); + if odd { + continue; + } + // Blank lines are above a block's own only at the top of the text. + if at > 0 { + assert_eq!(above, 0, "{name} starts with its own line"); + } + // The block's line is the line its own first line is on. + let line = first_line + text[..start + above].matches('\n').count(); + assert_eq!(block.line(), line, "{name} is on its line"); + // Prose is its source without the blank lines around it. + if let Block::Prose { text: words, .. } = block { + assert_eq!(words, source[above..].trim_end_matches(BLANK), "{name}"); + } + // The source alone reads as this block and nothing else. + assert_reads_alone(block, &name); + } +} + +/// Whether `block` is a heading whose text lines continue the paragraph +/// that `before` holds the opening of: an underlined heading right below +/// prose, with no blank line between. Only link definitions open a +/// paragraph without being part of what it becomes, and the library +/// reads what follows them in its own way: a line of spaces above `---` +/// is the text of a heading there. The caller checks of such a heading +/// only what holds for it. +fn continues_a_paragraph(before: &Block, block: &Block) -> bool { + let underline = block + .source() + .lines() + .rev() + .map(|line| line.trim_matches(BLANK)) + .find(|line| !line.is_empty()) + .unwrap_or_default(); + let underlined = ["=", "-"] + .iter() + .any(|mark| !underline.is_empty() && underline.replace(mark, "").is_empty()); + let parted = before + .source() + .split_inclusive('\n') + .next_back() + .is_some_and(|line| line.trim_matches(BLANK).is_empty()); + matches!(block, Block::Section { .. }) + && underlined + && matches!(before, Block::Prose { .. }) + && !parted +} + +/// Asserts that the source of `block`, read alone, is `block` and nothing +/// else, by every field but its line. `name` names the block in a failure. +/// +/// A section's title is compared only when its source holds no `[`: a +/// title may use a link that the rest of the text defines. The caller +/// leaves out a heading that continues a paragraph, which +/// `a_heading_below_a_link_definition_is_the_one_block_its_source_alone_is_not` +/// states. +#[track_caller] +fn assert_reads_alone(block: &Block, name: &str) { + let source = block.source(); + let alone = match block { + Block::Frontmatter { .. } => parse(source), + _ => parse_body(source, 1), + }; + let alone = flat(&alone); + let [only] = alone.as_slice() else { + panic!("{name} reads alone as {} blocks", alone.len()); + }; + match (only, block) { + (Block::Section { level: alone, .. }, Block::Section { level, .. }) + if source.contains('[') => + { + assert_eq!(alone, level, "{name} reads alone"); + } + _ => assert_eq!(own_fields(only), own_fields(block), "{name} reads alone"), + } +} + +#[test] +fn a_heading_below_a_link_definition_is_the_one_block_its_source_alone_is_not() { + // A link definition opens a paragraph and is no part of what the + // paragraph becomes, so the heading starts on the line after it. That + // line continues a paragraph here, and alone it starts a list. + let text = "[ref]: http://x\n10. ten\n=====\n"; + let blocks = parse(text); + assert_eq!( + blocks, + [ + sourced(prose(1, "[ref]: http://x"), "[ref]: http://x\n"), + sourced(section(2, 1, "10. ten", vec![]), "10. ten\n=====\n"), + ] + ); + assert_eq!( + without_sources(&parse("10. ten\n=====\n")), + [numbered(1, &["ten\n====="])] + ); + // The two sources together read as the two blocks. + let joined: String = blocks.iter().map(Block::source).collect(); + assert_eq!(parse(&joined), blocks); +} + +#[test] +fn a_title_reads_a_link_that_the_rest_of_the_text_defines() { + // With the definition in the text the brackets are a link, and with + // the heading's source alone they are text. + let definition = sourced(prose(3, "[ref]: http://x"), "[ref]: http://x\n"); + assert_eq!( + parse("# See [ref]\n\n[ref]: http://x\n"), + [sourced( + section(1, 1, "See ref", vec![definition]), + "# See [ref]\n\n" + )] + ); + assert_eq!( + without_sources(&parse("# See [ref]\n\n")), + [section(1, 1, "See [ref]", vec![])] + ); +} + +#[test] +fn the_sources_of_every_sample_are_its_text_cut_into_whole_blocks() { + assert_sources(GUIDE, &parse(GUIDE), 1, "the guide"); + for body in BODIES { + assert_sources(body, &parse(body), 1, &format!("{body:?}")); + assert_sources(body, &parse_body(body, 7), 7, &format!("body {body:?}")); + } +} + +#[test] +fn the_sources_of_generated_texts_are_each_text_cut_into_whole_blocks() { + let mut rng = Seeded::new(SEED); + let mut seen = [0_usize; Kind::ALL.len()]; + for number in 0..2000 { + let text = rng.text(); + let context = format!("seed {SEED:#x}, text {number}, {text:?}"); + let parsed = parse(&text); + for block in flat(&parsed) { + seen[block.kind() as usize] += 1; + } + assert_sources(&text, &parsed, 1, &context); + assert_sources(&text, &parse_body(&text, 7), 7, &format!("body, {context}")); + } + // The texts hold every kind of block, each many times over. + for kind in Kind::ALL { + let count = seen[kind as usize]; + assert!( + count >= 100, + "seed {SEED:#x}: {count} {} blocks", + kind.name() + ); + } +} diff --git a/crates/runvil-internal/markdown/src/parse-tests.rs b/crates/runvil-internal/markdown/src/parse-tests.rs new file mode 100644 index 000000000..528fdfe50 --- /dev/null +++ b/crates/runvil-internal/markdown/src/parse-tests.rs @@ -0,0 +1,527 @@ +//! Tests of the parse as a whole: what each kind of text becomes. The +//! tests here state a block by its fields other than its source, which +//! `parse-tests-source.rs` tests. + +use serde_json::json; + +use super::events::marks; +use super::{parse, parse_body}; +use crate::Block; +use crate::test_support::{ + BODIES, GUIDE, bullets, fence, frontmatter, numbered, prose, rule, section, without_sources, +}; + +#[path = "parse-tests-source.rs"] +mod source; + +/// The blocks of `text`, without their sources. +fn fields(text: &str) -> Vec { + without_sources(&parse(text)) +} + +#[test] +fn a_document_with_every_kind_of_block_parses_to_its_tree() { + let expected = vec![ + frontmatter(json!({"title": "Notes", "tags": ["a", "b"]})), + prose(6, "Preface paragraph."), + section( + 8, + 1, + "Guide", + vec![ + prose(10, "Intro text\non two lines."), + bullets(13, &["alpha", "beta"]), + section( + 16, + 2, + "Templates", + vec![ + prose(18, "Use these."), + fence(20, "lua", "return 1"), + fence(24, "markdown", "# Hello"), + prose(28, "Then the fallback:"), + fence(30, "json strict", "{ \"ok\": true }"), + section( + 34, + 3, + "Extras", + vec![rule(36), fence(38, "lua", "return 2")], + ), + ], + ), + section(42, 2, "Usage", vec![numbered(44, &["first", "second"])]), + ], + ), + ]; + assert_eq!(fields(GUIDE), expected); +} + +#[test] +fn empty_text_and_blank_lines_are_an_empty_document() { + assert_eq!(fields(""), []); + assert_eq!(fields("\n\n \n\t\n"), []); +} + +#[test] +fn prose_is_a_maximal_run() { + // Two paragraphs separated by a blank line are one block. + assert_eq!(fields("first\n\nsecond\n"), [prose(1, "first\n\nsecond")]); + assert_eq!(fields("one paragraph"), [prose(1, "one paragraph")]); + // A second prose block exists only when another block sits between. + assert_eq!( + fields("text\n- a\n- b\n"), + [prose(1, "text"), bullets(2, &["a", "b"])] + ); + assert_eq!( + fields("text\n```\ncode\n```\nafter\n"), + [prose(1, "text"), fence(2, "", "code"), prose(5, "after")] + ); +} + +#[test] +fn what_follows_a_list_is_prose_from_its_first_line() { + // A link definition right after a list is not an item's text, so it + // is prose, with or without a paragraph after it. + assert_eq!( + fields("- a\n\n[ref]: http://x\ntext\n"), + [bullets(1, &["a"]), prose(3, "[ref]: http://x\ntext")] + ); + assert_eq!( + fields("-\n[ref]: http://x\ntext\n"), + [bullets(1, &[""]), prose(2, "[ref]: http://x\ntext")] + ); + assert_eq!( + fields("1. a\n\n[one]: http://x\n[two]: http://y\n"), + [ + numbered(1, &["a"]), + prose(3, "[one]: http://x\n[two]: http://y") + ] + ); + // A paragraph indented too little to be the item's keeps its + // indentation, as prose after any other block does. + assert_eq!( + fields("- a\n\n text\n"), + [bullets(1, &["a"]), prose(3, " text")] + ); + assert_eq!( + fields("-\n\n text\n"), + [bullets(1, &[""]), prose(3, " text")] + ); +} + +#[test] +fn everything_that_is_no_other_kind_is_prose() { + for text in [ + " code line\n\ntext", + "
\nraw\n
", + "| a | b |\n|---|---|\n| 1 | 2 |", + "[ref]: http://example.com", + "\n\ntext", + "\\# not heading", + " # five spaces", + "#NotHeading", + ] { + assert_eq!(fields(text), [prose(1, text)], "{text:?}"); + } +} + +#[test] +fn a_construct_inside_a_block_quote_stays_in_the_prose() { + for text in [ + "> # Quoted", + "> - a\n> - b\n>\n> ```\n> code\n> ```", + "> ---", + ] { + assert_eq!(fields(text), [prose(1, text)], "{text:?}"); + } +} + +#[test] +fn a_heading_opens_a_section_and_its_title_has_no_markup() { + assert_eq!( + fields("## The *big* `foo` API ##\n\ntext\n"), + [section(1, 2, "The big foo API", vec![prose(3, "text")])] + ); + assert_eq!( + fields("## Title ##\n\n### C#\n\n#### A #\n"), + [section( + 1, + 2, + "Title", + vec![section(3, 3, "C#", vec![section(5, 4, "A", vec![])])] + )] + ); + assert_eq!( + fields(" # indented\n"), + [section(1, 1, "indented", vec![])] + ); +} + +#[test] +fn a_heading_with_no_text_has_an_empty_title() { + assert_eq!( + fields("##\n\ntext\n\n# \n"), + [ + section(1, 2, "", vec![prose(3, "text")]), + section(5, 1, "", vec![]) + ] + ); +} + +#[test] +fn an_underlined_line_is_a_heading() { + assert_eq!( + fields("Title\n=====\n\ntext\n\nSub\n---\n\nmore\n"), + [section( + 1, + 1, + "Title", + vec![ + prose(4, "text"), + section(6, 2, "Sub", vec![prose(9, "more")]) + ] + )] + ); + // A line break inside a heading reads as a space. + assert_eq!( + fields("Line one\nline two\n===\n"), + [section(1, 1, "Line one line two", vec![])] + ); + // A paragraph line directly above `---` is a heading, not a rule. + assert_eq!( + fields("text\n---\n\nafter\n"), + [section(1, 2, "text", vec![prose(4, "after")])] + ); +} + +#[test] +fn a_section_runs_to_the_next_heading_of_its_level_or_a_smaller_one() { + // A heading that skips levels is a member of the section above it. + assert_eq!( + fields("## A\n\n#### B\n\nb\n\n### C\n\n# D\n\n###### E\n"), + [ + section( + 1, + 2, + "A", + vec![ + section(3, 4, "B", vec![prose(5, "b")]), + section(7, 3, "C", vec![]) + ] + ), + section(9, 1, "D", vec![section(11, 6, "E", vec![])]), + ] + ); + // A shallower heading after a deeper one starts a section of its own. + assert_eq!( + fields("### A\n\na\n\n# B\n\nb\n"), + [ + section(1, 3, "A", vec![prose(3, "a")]), + section(5, 1, "B", vec![prose(7, "b")]) + ] + ); + // Parsed text may repeat a title among siblings. + assert_eq!( + fields("# Log\n\n## Entry\n\nfirst\n\n## Entry\n\nsecond\n"), + [section( + 1, + 1, + "Log", + vec![ + section(3, 2, "Entry", vec![prose(5, "first")]), + section(7, 2, "Entry", vec![prose(9, "second")]), + ] + )] + ); +} + +#[test] +fn a_fence_keeps_its_info_string_and_the_lines_between_its_fence_lines() { + assert_eq!( + fields("~~~\nplain\n~~~\n\n```\nbare\n```\n\n``` rust ignore \nfn x() {}\n```\n"), + [ + fence(1, "", "plain"), + fence(5, "", "bare"), + fence(9, "rust ignore", "fn x() {}"), + ] + ); + // The fence's own indentation is removed from its text. + assert_eq!( + fields(" ```py\n x = 1\n y = 2\n ```\n"), + [fence(1, "py", "x = 1\n y = 2")] + ); + // A longer fence holds a shorter one as text. + assert_eq!( + fields("````md\n```lua\nx\n```\n````\n"), + [fence(1, "md", "```lua\nx\n```")] + ); +} + +#[test] +fn a_fence_with_no_lines_inside_has_empty_text() { + assert_eq!(fields("```json\n```\n"), [fence(1, "json", "")]); + // The final line ending is not part of the text, so one blank line + // inside reads as empty text too, and two as one line ending. + assert_eq!(fields("```\n\n```\n"), [fence(1, "", "")]); + assert_eq!(fields("```\n\n\n```\n"), [fence(1, "", "\n")]); + assert_eq!(fields("```\na\n\n```\n"), [fence(1, "", "a\n")]); +} + +#[test] +fn a_fence_is_opaque_and_one_with_no_closing_line_runs_to_the_end() { + assert_eq!( + fields("# A\n\n```lua\nreturn 1\n\n# not a heading\n"), + [section( + 1, + 1, + "A", + vec![fence(3, "lua", "return 1\n\n# not a heading")] + )] + ); +} + +#[test] +fn a_list_is_one_block_and_an_item_is_its_source_without_the_marker() { + assert_eq!(fields("+ a\n+ b\n"), [bullets(1, &["a", "b"])]); + assert_eq!(fields(" - a\n - b\n"), [bullets(1, &["a", "b"])]); + assert_eq!( + fields("- a\n\n- b\n\n- c\n"), + [bullets(1, &["a", "b", "c"])] + ); + assert_eq!( + fields("- [ ] todo\n- [x] done\n"), + [bullets(1, &["[ ] todo", "[x] done"])] + ); + // The start number is not kept. + assert_eq!(fields("5) five\n6) six\n"), [numbered(1, &["five", "six"])]); + assert_eq!( + fields("10. ten\n11. eleven\n"), + [numbered(1, &["ten", "eleven"])] + ); +} + +#[test] +fn an_item_with_no_content_is_the_empty_string() { + assert_eq!( + fields("-\n- b\n-\n later\n"), + [bullets(1, &["", "b", "later"])] + ); + assert_eq!(fields("- a\n- \n"), [bullets(1, &["a", ""])]); +} + +#[test] +fn an_item_that_spans_lines_keeps_them_as_markdown() { + assert_eq!( + fields("- first line\n second line\n\n second paragraph\n- next\n"), + [bullets( + 1, + &["first line\nsecond line\n\nsecond paragraph", "next"] + )] + ); + // A nested list, a heading, a rule, and a fence stay in the item. + assert_eq!( + fields("- a\n - a1\n - a2\n- b\n 1. b1\n"), + [bullets(1, &["a\n- a1\n- a2", "b\n1. b1"])] + ); + assert_eq!( + fields("- # Item heading\n- one\n\n ---\n- two\n"), + [bullets(1, &["# Item heading", "one\n\n---", "two"])] + ); + assert_eq!( + fields("1. step\n\n ```sh\n run\n ```\n2. done\n"), + [numbered(1, &["step\n\n```sh\nrun\n```", "done"])] + ); + // An item's text parses again as Markdown. + assert_eq!( + fields("a\n- a1\n- a2"), + [prose(1, "a"), bullets(2, &["a1", "a2"])] + ); +} + +#[test] +fn a_change_of_marker_starts_a_new_list() { + assert_eq!( + fields("- a\n- b\n\n* c\n* d\n\n1. e\n\n1) f\n"), + [ + bullets(1, &["a", "b"]), + bullets(4, &["c", "d"]), + numbered(7, &["e"]), + numbered(9, &["f"]), + ] + ); + assert_eq!( + fields("1. one\n- bullet\n2. two\n"), + [ + numbered(1, &["one"]), + bullets(2, &["bullet"]), + numbered(3, &["two"]) + ] + ); +} + +#[test] +fn a_rule_is_a_block_of_its_own() { + assert_eq!( + fields("***\n\n___\n\n- - -\n\ntext\n"), + [rule(1), rule(3), rule(5), prose(7, "text")] + ); +} + +#[test] +fn frontmatter_is_the_first_block_and_its_lines_are_counted() { + assert_eq!( + fields("---\ntitle: Notes\nnested: { a: [1, 2], b: null }\n---\nbody\n"), + [ + frontmatter(json!({"title": "Notes", "nested": {"a": [1, 2], "b": null}})), + prose(5, "body"), + ] + ); + assert_eq!(fields("---\na: 1\n---"), [frontmatter(json!({"a": 1}))]); + // A second `---` pair after the frontmatter is ordinary Markdown. + assert_eq!( + fields("---\ntitle: x\n---\n---\n\ntext\n"), + [ + frontmatter(json!({"title": "x"})), + rule(4), + prose(6, "text") + ] + ); +} + +#[test] +fn text_that_only_looks_like_frontmatter_is_ordinary_markdown() { + // No closing delimiter: the first line is a rule. + assert_eq!( + fields("---\na: 1\n\ntext\n"), + [rule(1), prose(2, "a: 1\n\ntext")] + ); + // The lines between are not a mapping. + assert_eq!( + fields("---\njust words\n---\n\ntext\n"), + [rule(1), section(2, 2, "just words", vec![prose(5, "text")])] + ); + assert_eq!( + fields("---\n---\n\ntext\n"), + [rule(1), rule(2), prose(4, "text")] + ); + // Not at the very start. + assert_eq!( + fields("\n---\na: 1\n---\n"), + [rule(2), section(3, 2, "a: 1", vec![])] + ); +} + +#[test] +fn line_endings_are_read_as_newlines_and_a_byte_order_mark_is_dropped() { + assert_eq!( + fields("# T\r\n\r\npara one\r\npara two\r\n\r\n```\r\ncode\r\n```\r\n\r\n- a\r\n- b\r\n"), + [section( + 1, + 1, + "T", + vec![ + prose(3, "para one\npara two"), + fence(6, "", "code"), + bullets(10, &["a", "b"]), + ] + )] + ); + assert_eq!( + fields("# T\r\rtext\r"), + [section(1, 1, "T", vec![prose(3, "text")])] + ); + assert_eq!( + fields("\u{feff}---\na: 1\n---\n\n# T\n"), + [frontmatter(json!({"a": 1})), section(5, 1, "T", vec![])] + ); +} + +#[test] +fn a_body_has_no_frontmatter_and_its_lines_count_from_the_line_given() { + let own = "Fill in this outline.\n\n```markdown\n# Title\n\n## Summary\n```"; + assert_eq!( + without_sources(&parse_body(own, 23)), + [ + prose(23, "Fill in this outline."), + fence(25, "markdown", "# Title\n\n## Summary") + ] + ); + assert_eq!( + without_sources(&parse_body("---\na: b\n---\n", 1)), + [rule(1), section(2, 2, "a: b", vec![])] + ); + assert_eq!(parse_body("", 7), []); +} + +/// The number of non-blank lines of `text`. +fn non_blank(text: &str) -> usize { + text.split('\n') + .filter(|line| !line.trim_matches([' ', '\t']).is_empty()) + .count() +} + +/// Adds the prose text of `blocks`, at every depth, to `out`. +fn prose_texts<'a>(blocks: &'a [Block], out: &mut Vec<&'a str>) { + for block in blocks { + match block { + Block::Prose { text, .. } => out.push(text), + Block::Section { blocks, .. } => prose_texts(blocks, out), + _ => {} + } + } +} + +/// Adds the line of each of `blocks`, at every depth and in file order, +/// to `out`. +fn lines(blocks: &[Block], out: &mut Vec) { + for block in blocks { + out.push(block.line()); + if let Block::Section { blocks, .. } = block { + lines(blocks, out); + } + } +} + +#[test] +fn every_non_blank_line_belongs_to_exactly_one_block() { + for body in BODIES { + // A line is in a heading, fence, list, or rule when a mark covers + // it, and otherwise it has to be in a prose block. + let marked: usize = marks(body) + .iter() + .map(|mark| non_blank(&body[mark.range.clone()])) + .sum(); + let blocks = parse_body(body, 1); + let mut texts = Vec::new(); + prose_texts(&blocks, &mut texts); + let in_prose: usize = texts.iter().map(|text| non_blank(text)).sum(); + assert_eq!(marked + in_prose, non_blank(body), "{body:?}"); + } +} + +#[test] +fn blocks_are_in_file_order_and_each_starts_on_a_non_blank_line() { + for body in BODIES { + let mut found = Vec::new(); + lines(&parse_body(body, 1), &mut found); + assert!(found.is_sorted_by(|a, b| a < b), "{body:?}: {found:?}"); + let source: Vec<&str> = body.split('\n').collect(); + for line in found { + assert!(!source[line - 1].trim().is_empty(), "{body:?}: line {line}"); + } + } +} + +#[test] +fn no_block_is_empty_where_the_model_says_it_cannot_be() { + for body in BODIES { + let blocks = parse_body(body, 1); + let mut texts = Vec::new(); + prose_texts(&blocks, &mut texts); + for text in texts { + assert!(!text.is_empty(), "{body:?}"); + assert_eq!(text, text.trim_end(), "{body:?}"); + assert!(!text.starts_with('\n'), "{body:?}"); + } + } +} diff --git a/crates/runvil-internal/markdown/src/parse.rs b/crates/runvil-internal/markdown/src/parse.rs new file mode 100644 index 000000000..b7ff2476a --- /dev/null +++ b/crates/runvil-internal/markdown/src/parse.rs @@ -0,0 +1,86 @@ +//! Text to blocks. +//! +//! The work is done in layers. Each is a child module whose items only +//! this module and the layer above it use: +//! +//! 1. `events` runs `pulldown-cmark` and reports each top-level heading, +//! fence, list, and rule by its byte range. It is the only module that +//! names the library, and it knows nothing of lines or blocks. +//! 2. `flat` turns those marks into the body's blocks in file order: the +//! text between two marks is prose, and every line number is counted +//! and every source is cut here. Nothing above it sees a byte range. +//! 3. `tree` folds that sequence into sections. +//! +//! `frontmatter` splits the YAML mapping off the front before the layers +//! see the text. + +mod events; +mod flat; +mod frontmatter; +mod tree; + +use std::borrow::Cow; + +use crate::Block; + +/// The characters that leave a Markdown line blank. +pub(crate) const BLANK: [char; 3] = [' ', '\t', '\n']; + +/// Parses Markdown text into blocks. +/// +/// The text is read as CommonMark with no extensions. A byte order mark +/// at the very start is dropped, and `\r\n` and a lone `\r` are read as +/// `\n`. Only a top-level heading, fence, list, or rule becomes a block of +/// its kind; everything else is prose, so every non-blank line belongs to +/// exactly one block. Any text is a document: nothing here fails. +/// +/// Each block's source is the lines it was read from and the blank lines +/// after them, so the sources in file order are the text as it was read, +/// which is what [`render`](fn@crate::render) writes. Text that holds +/// nothing but blank lines is the one exception: it has no block. +#[must_use] +pub fn parse(text: &str) -> Vec { + let text = normalize(text); + match frontmatter::split(&text) { + Some(opening) => { + let root = vec![Block::Frontmatter { + line: 1, + source: text.get(..opening.end).unwrap_or_default().to_owned(), + data: opening.data, + }]; + let rest = text.get(opening.end..).unwrap_or_default(); + body(rest, opening.lines + 1, root) + } + None => body(&text, 1, Vec::new()), + } +} + +/// Parses text that has no frontmatter, such as one section's own +/// content, into blocks. +/// +/// `first_line` is the line number of the text's first line, so each +/// block's line counts in the file the text was cut from. The sources are +/// the text, as [`parse`]'s are. +#[must_use] +pub fn parse_body(text: &str, first_line: usize) -> Vec { + body(&normalize(text), first_line, Vec::new()) +} + +/// Runs the three layers over `text`, adding its blocks after `root`. +fn body(text: &str, first_line: usize, root: Vec) -> Vec { + tree::fold(flat::flatten(text, events::marks(text), first_line), root) +} + +/// Drops a leading byte order mark and reads `\r\n` and `\r` as `\n`. +fn normalize(text: &str) -> Cow<'_, str> { + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + if text.contains('\r') { + Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n")) + } else { + Cow::Borrowed(text) + } +} + +#[cfg(test)] +#[path = "parse-tests.rs"] +mod tests; diff --git a/crates/runvil-internal/markdown/src/parse/events.rs b/crates/runvil-internal/markdown/src/parse/events.rs new file mode 100644 index 000000000..c45497987 --- /dev/null +++ b/crates/runvil-internal/markdown/src/parse/events.rs @@ -0,0 +1,319 @@ +//! Layer 1: what `pulldown-cmark` reports. +//! +//! This is the only module that names the library. It makes one pass over +//! the events and records each top-level heading, fence, list, and rule +//! as a [`Mark`]: what the construct is and the bytes it covers. Anything +//! nested in another construct is left alone, and so is everything the +//! library reports that is none of the four. + +use std::ops::Range; + +use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag}; + +/// One top-level construct of a body. +pub(super) struct Mark { + /// The bytes of the body the construct covers. + pub(super) range: Range, + /// What the construct is. + pub(super) what: What, +} + +impl Mark { + /// Returns the mark with its range ended where its construct ends. + /// + /// The library ends a list where the next block starts. That is past + /// the link definitions that follow the list, and past the + /// indentation of the paragraph after it. It ends an item where the + /// item ends, so a list ends with its last item. + fn ended(mut self) -> Self { + if let What::List { items, .. } = &self.what + && let Some(last) = items.last() + { + self.range.end = self.range.end.min(last.range.end); + } + self + } +} + +/// What a [`Mark`] records. +pub(super) enum What { + /// A heading. + Heading { + /// The heading's level, 1 to 6. + level: u8, + /// The heading's text and code spans, with a line break read as a + /// space. + title: String, + }, + /// A fenced code block. + Fence { + /// The info string as written. + info: String, + /// The lines between the fence lines, each with its line ending. + text: String, + }, + /// A bullet or numbered list. + List { + /// Whether the list is numbered. + ordered: bool, + /// The list's own items, in order. + items: Vec, + }, + /// A thematic break. + Rule, +} + +/// One item of a top-level list. +pub(super) struct Item { + /// The bytes of the body the item covers, its marker included. + pub(super) range: Range, + /// The byte the item's content starts at, or `None` for an item that + /// has none. + pub(super) content: Option, +} + +/// Returns the top-level headings, fences, lists, and rules of `body`, in +/// file order. No two overlap. +/// +/// The body is read as CommonMark with no extensions, the options the +/// prompt parser uses. +pub(super) fn marks(body: &str) -> Vec { + let mut reader = Reader::default(); + for (event, range) in Parser::new_ext(body, Options::empty()).into_offset_iter() { + reader.read(event, range); + } + reader.marks +} + +/// The state of one pass over the events. +#[derive(Default)] +struct Reader { + /// The marks recorded so far. + marks: Vec, + /// How many constructs are open around the next event. + depth: usize, + /// The top-level construct being read, when it is one a mark records. + open: Option, +} + +impl Reader { + /// Takes the next event and the bytes it covers. + fn read(&mut self, event: Event<'_>, range: Range) { + match event { + Event::Start(tag) => { + if self.depth == 0 { + self.open = opened(&tag).map(|what| Mark { range, what }); + } else { + self.inside(self.depth == 1 && matches!(tag, Tag::Item), range); + } + self.depth += 1; + } + Event::End(_) => { + self.depth = self.depth.saturating_sub(1); + if self.depth == 0 { + self.marks.extend(self.open.take().map(Mark::ended)); + } + } + Event::Rule if self.depth == 0 => self.marks.push(Mark { + range, + what: What::Rule, + }), + Event::Text(text) | Event::Code(text) => self.text(&text, range), + Event::SoftBreak | Event::HardBreak => self.text(" ", range), + _ => self.inside(false, range), + } + } + + /// Takes a run of text inside the open construct. + fn text(&mut self, piece: &str, range: Range) { + match self.open.as_mut().map(|mark| &mut mark.what) { + Some(What::Heading { title, .. }) => title.push_str(piece), + Some(What::Fence { text, .. }) => text.push_str(piece), + _ => self.inside(false, range), + } + } + + /// Takes an event inside the open construct that is not a heading's + /// or a fence's text. In a list, an event that `opens_item` starts + /// the next item, and any other event is content of the last one. + fn inside(&mut self, opens_item: bool, range: Range) { + let Some(What::List { items, .. }) = self.open.as_mut().map(|mark| &mut mark.what) else { + return; + }; + if opens_item { + items.push(Item { + range, + content: None, + }); + } else if let Some(item) = items.last_mut() { + item.content.get_or_insert(range.start); + } + } +} + +/// Returns what a mark records for the top-level construct `tag` opens, +/// or `None` when it is not one of the four. +fn opened(tag: &Tag<'_>) -> Option { + match tag { + Tag::Heading { level, .. } => Some(What::Heading { + level: *level as u8, + title: String::new(), + }), + Tag::CodeBlock(CodeBlockKind::Fenced(info)) => Some(What::Fence { + info: info.to_string(), + text: String::new(), + }), + Tag::List(first) => Some(What::List { + ordered: first.is_some(), + items: Vec::new(), + }), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::{Mark, What, marks}; + + /// Each mark of `body` as the text it covers and a word for its kind. + fn covered(body: &str) -> Vec<(&str, &'static str)> { + marks(body) + .iter() + .map(|Mark { range, what }| { + let kind = match what { + What::Heading { .. } => "heading", + What::Fence { .. } => "fence", + What::List { .. } => "list", + What::Rule => "rule", + }; + (&body[range.clone()], kind) + }) + .collect() + } + + #[test] + fn each_top_level_construct_is_one_mark_and_a_paragraph_is_none() { + let body = "Intro.\n\n# Title\n\n- a\n- b\n\n```lua\nx\n```\n\n---\n\nOutro.\n"; + // A mark covers whole lines of its construct, and may take the + // line ending or the blank line after it. + assert_eq!( + covered(body), + [ + ("# Title\n", "heading"), + ("- a\n- b\n\n", "list"), + ("```lua\nx\n```", "fence"), + ("---\n", "rule"), + ] + ); + } + + #[test] + fn a_list_ends_with_its_last_item_and_not_where_the_next_block_starts() { + // A link definition after a list is not in it, with or without a + // blank line between, and neither is the next paragraph's + // indentation. + for (body, list) in [ + ("-\n[ref]: http://x\ntext\n", "-\n"), + ("- a\n\n[ref]: http://x\ntext\n", "- a\n\n"), + ("- a\n - b\n\n[ref]: http://x\n tail\n", "- a\n - b\n\n"), + ("-\n\n text\n", "-\n"), + ("- a\n\n text\n", "- a\n\n"), + ( + "1. a\n\n ```\n x\n ```\n\n[r]: http://x\nnext\n", + "1. a\n\n ```\n x\n ```\n\n", + ), + ] { + assert_eq!(covered(body), [(list, "list")], "{body:?}"); + } + // A line that continues an item is in it. + let body = "- a\n[ref]: http://x\n\n more\n"; + assert_eq!(covered(body), [(body, "list")]); + } + + #[test] + fn a_dashed_line_after_a_bullet_marker_is_a_rule_not_an_item() { + assert_eq!( + covered("- a\n- ---\n- b\n"), + [("- a\n", "list"), ("- ---\n", "rule"), ("- b\n", "list")] + ); + } + + #[test] + fn a_construct_inside_a_block_quote_or_a_list_item_is_not_a_mark() { + assert!(marks("> # Quoted\n>\n> - a\n>\n> ```\n> x\n> ```\n>\n> ---\n").is_empty()); + let body = "- a\n\n # Inner\n\n ---\n\n ```\n x\n ```\n"; + assert_eq!(covered(body), [(body, "list")]); + } + + #[test] + fn a_heading_reports_its_level_and_its_text_without_markup() { + let titles: Vec<(u8, String)> = + marks("## The *big* `foo` [API](x) ##\n\nA\nB\n===\n\n###\n") + .into_iter() + .filter_map(|mark| match mark.what { + What::Heading { level, title } => Some((level, title)), + _ => None, + }) + .collect(); + assert_eq!( + titles, + [ + (2, "The big foo API".to_owned()), + (1, "A B".to_owned()), + (3, String::new()), + ] + ); + } + + #[test] + fn a_fence_reports_its_info_string_and_its_lines() { + let fences: Vec<(String, String)> = marks("~~~json strict\n{ }\n\n# not a heading\n~~~\n") + .into_iter() + .filter_map(|mark| match mark.what { + What::Fence { info, text } => Some((info, text)), + _ => None, + }) + .collect(); + assert_eq!( + fences, + [( + "json strict".to_owned(), + "{ }\n\n# not a heading\n".to_owned() + )] + ); + } + + #[test] + fn a_list_reports_each_of_its_own_items_and_where_its_content_starts() { + let body = "1. first\n - nested\n2.\n3. third\n"; + let marks = marks(body); + let [ + Mark { + what: What::List { ordered, items }, + .. + }, + ] = marks.as_slice() + else { + panic!("the body is one list"); + }; + assert!(*ordered); + // Each item as the text it covers, and its content when it has any. + let items: Vec<(&str, Option<&str>)> = items + .iter() + .map(|item| { + ( + &body[item.range.clone()], + item.content.map(|at| &body[at..item.range.end]), + ) + }) + .collect(); + assert_eq!( + items, + [ + ("1. first\n - nested\n", Some("first\n - nested\n")), + ("2.\n", None), + ("3. third\n", Some("third\n")), + ] + ); + } +} diff --git a/crates/runvil-internal/markdown/src/parse/flat.rs b/crates/runvil-internal/markdown/src/parse/flat.rs new file mode 100644 index 000000000..7c23e3780 --- /dev/null +++ b/crates/runvil-internal/markdown/src/parse/flat.rs @@ -0,0 +1,343 @@ +//! Layer 2: a body's blocks in file order, before sections nest. +//! +//! [`flatten`] takes the marks of layer 1 and gives the intermediate form +//! the tree is folded from: each heading, and each leaf block as it will +//! appear in the result. The text between two marks is prose, which is +//! what keeps every line the Markdown library reports nothing for, such +//! as a link definition. +//! +//! Each entry starts on the first line of its construct, or of its prose, +//! and its source runs to the line the next entry starts on. So the +//! sources are the body cut at line starts: nothing lies between two of +//! them, and blank lines fall to the entry above. Byte ranges end here: +//! everything this layer hands on is counted in lines. + +use std::ops::Range; + +use super::BLANK; +use super::events::{Item, Mark, What}; +use crate::Block; + +/// One entry of a body's flat form. +pub(super) enum Flat { + /// A heading, which opens a section. + Heading { + /// The heading's line. + line: usize, + /// The heading's lines and the blank lines after them. + source: String, + /// The heading's level, 1 to 6. + level: u8, + /// The heading's text, trimmed. + title: String, + }, + /// A block that holds no other block: prose, a fence, a list, or a + /// rule. + Leaf(Block), +} + +/// Returns the flat form of `body`, whose top-level constructs are +/// `marks`, in file order. `first_line` is the line number of the body's +/// first line. A body of blank lines alone has no entry. +pub(super) fn flatten(body: &str, marks: Vec, first_line: usize) -> Vec { + let mut lines = Lines::new(body, first_line); + // Each entry beside the byte its first line starts at. Its source is + // empty until every start is known. + let mut starts = Vec::with_capacity(marks.len() * 2 + 1); + let mut cursor = 0; + for mark in marks { + // The library starts a mark after the indentation of its first + // line. The indentation is the mark's too, so the entry starts + // where the line does. + let start = line_start(body, mark.range.start); + starts.extend(prose(body, cursor..start, &mut lines)); + cursor = cursor.max(mark.range.end); + let line = lines.at(mark.range.start); + let source = String::new(); + let entry = match mark.what { + What::Heading { level, title } => Flat::Heading { + line, + source, + level, + title: title.trim().to_owned(), + }, + What::Fence { info, text } => Flat::Leaf(fence(line, &info, text)), + What::List { ordered, items } => Flat::Leaf(Block::List { + line, + source, + ordered, + items: items.iter().map(|item| item_text(body, item)).collect(), + }), + What::Rule => Flat::Leaf(Block::Rule { line, source }), + }; + starts.push((start, entry)); + } + starts.extend(prose(body, cursor..body.len(), &mut lines)); + cut(body, starts) +} + +/// Returns the byte at which the line that holds `offset` starts. +fn line_start(body: &str, offset: usize) -> usize { + body.get(..offset) + .and_then(|before| before.rfind('\n')) + .map_or(0, |ending| ending + 1) +} + +/// Gives each entry its source and returns the entries. Each is beside +/// the byte its first line starts at. +/// +/// A source runs to the start of the next entry, or to the end of the +/// body, and the first one starts at the top of the body, so it takes the +/// blank lines above it. Each source starts where the one before ended, +/// which is what makes the sources the whole body. +fn cut(body: &str, starts: Vec<(usize, Flat)>) -> Vec { + let mut ends: Vec = starts.iter().skip(1).map(|(start, _)| *start).collect(); + ends.push(body.len()); + let mut from = 0; + starts + .into_iter() + .zip(ends) + .map(|((_, mut entry), end)| { + let end = end.max(from); + let source = body.get(from..end).unwrap_or_default(); + from = end; + match &mut entry { + Flat::Heading { source: own, .. } + | Flat::Leaf( + Block::Frontmatter { source: own, .. } + | Block::Section { source: own, .. } + | Block::Prose { source: own, .. } + | Block::Fence { source: own, .. } + | Block::List { source: own, .. } + | Block::Rule { source: own, .. }, + ) => source.clone_into(own), + } + entry + }) + .collect() +} + +/// Counts lines for offsets that only move forward, so a whole body is +/// counted once. +struct Lines<'a> { + /// The body. + body: &'a str, + /// The offset counted up to. + counted: usize, + /// The line number at `counted`. + line: usize, +} + +impl<'a> Lines<'a> { + /// Starts at the front of `body`, on line `first_line`. + fn new(body: &'a str, first_line: usize) -> Self { + Self { + body, + counted: 0, + line: first_line, + } + } + + /// Returns the line number of the byte at `offset`, which is at or + /// past every offset asked for before. + fn at(&mut self, offset: usize) -> usize { + if let Some(passed) = self.body.get(self.counted..offset) { + self.line += passed.matches('\n').count(); + self.counted = offset; + } + self.line + } +} + +/// Returns the prose block for the text of `gap`, the bytes between two +/// marks, beside the byte its first line starts at, or `None` when the +/// gap holds only blank lines. +/// +/// The text is the source as written, without leading blank lines or +/// trailing whitespace, so its first line keeps its indentation. +fn prose(body: &str, gap: Range, lines: &mut Lines<'_>) -> Option<(usize, Flat)> { + let text = body.get(gap.clone())?; + let blank: usize = text + .split_inclusive('\n') + .take_while(|line| line.trim_matches(BLANK).is_empty()) + .map(str::len) + .sum(); + let text = text.get(blank..)?.trim_end_matches(BLANK); + if text.is_empty() { + return None; + } + let start = gap.start + blank; + let block = Block::Prose { + line: lines.at(start), + source: String::new(), + text: text.to_owned(), + }; + Some((start, Flat::Leaf(block))) +} + +/// Returns the fence block on `line` for an info string and the lines +/// between the fence lines. +fn fence(line: usize, info: &str, mut text: String) -> Block { + if text.ends_with('\n') { + text.pop(); + } + let info = info.trim(); + Block::Fence { + line, + source: String::new(), + lang: info + .split_whitespace() + .next() + .unwrap_or_default() + .to_owned(), + info: info.to_owned(), + text, + } +} + +/// Returns an item's source with its marker removed. +/// +/// The text starts where the item's content starts. Each later line loses +/// as much leading whitespace as the marker gave the first, so nested +/// content keeps its own shape and parses again as Markdown. +fn item_text(body: &str, item: &Item) -> String { + let Some(start) = item.content else { + return String::new(); + }; + let (Some(before), Some(source)) = (body.get(..start), body.get(start..item.range.end)) else { + return String::new(); + }; + let width = start - before.rfind('\n').map_or(0, |at| at + 1); + let mut lines = source.split('\n'); + let mut text = lines.next().unwrap_or_default().to_owned(); + for line in lines { + let indent = line + .bytes() + .take(width) + .take_while(|byte| matches!(byte, b' ' | b'\t')) + .count(); + text.push('\n'); + text.push_str(line.get(indent..).unwrap_or_default()); + } + text.truncate(text.trim_end_matches(BLANK).len()); + text +} + +#[cfg(test)] +mod tests { + use super::super::events::marks; + use super::{Flat, flatten}; + use crate::Block; + + /// The flat form of `body`, each entry as a short word and its line. + fn outline(body: &str, first_line: usize) -> Vec<(String, usize)> { + flatten(body, marks(body), first_line) + .into_iter() + .map(|entry| match entry { + Flat::Heading { + line, level, title, .. + } => (format!("h{level} {title}"), line), + Flat::Leaf(block) => (block.kind().name().to_owned(), block.line()), + }) + .collect() + } + + /// The source of each entry of the flat form of `body`. + fn sources(body: &str) -> Vec { + flatten(body, marks(body), 1) + .into_iter() + .map(|entry| match entry { + Flat::Heading { source, .. } => source, + Flat::Leaf(block) => block.source().to_owned(), + }) + .collect() + } + + #[test] + fn a_source_runs_to_the_line_the_next_entry_starts_on() { + let body = "Intro.\n\n# Title\n\n\n- a\n[ref]: http://x\n \n---\ntail"; + assert_eq!( + sources(body), + [ + "Intro.\n\n", + "# Title\n\n\n", + "- a\n[ref]: http://x\n \n", + "---\n", + "tail", + ] + ); + assert_eq!(sources(body).concat(), body); + } + + #[test] + fn the_first_entry_takes_the_blank_lines_above_it_and_keeps_its_line() { + let body = "\n \t\n# Title\ntext\n"; + assert_eq!(sources(body), ["\n \t\n# Title\n", "text\n"]); + assert_eq!( + outline(body, 1), + [("h1 Title".to_owned(), 3), ("prose".to_owned(), 4)] + ); + assert_eq!(sources("\n\ntext"), ["\n\ntext"]); + } + + #[test] + fn the_indentation_of_a_marks_first_line_is_in_its_own_source() { + assert_eq!( + sources("text\n - a\n ## H\n ***\n"), + ["text\n", " - a\n", " ## H\n", " ***\n"] + ); + } + + #[test] + fn a_body_of_blank_lines_alone_has_no_entry() { + for body in ["", "\n", " \n\t\n", " "] { + assert!(sources(body).is_empty(), "{body:?}"); + } + } + + #[test] + fn the_text_between_two_marks_is_prose_and_blank_gaps_are_nothing() { + let body = "\n\nIntro.\n\n# Title\n\n\n- a\n[ref]: http://x\n\n---\ntail"; + assert_eq!( + outline(body, 1), + [ + ("prose".to_owned(), 3), + ("h1 Title".to_owned(), 5), + ("list".to_owned(), 8), + ("rule".to_owned(), 11), + ("prose".to_owned(), 12), + ] + ); + } + + #[test] + fn lines_count_from_the_first_line_given() { + assert_eq!( + outline("text\n\n```\nx\n```\n", 40), + [("prose".to_owned(), 40), ("fence".to_owned(), 42)] + ); + } + + #[test] + fn prose_keeps_its_source_without_leading_blank_lines_or_trailing_whitespace() { + let body = " \n\t\n indented code\n\nA paragraph. \n\n \n# H\n"; + let flat = flatten(body, marks(body), 1); + let Some(Flat::Leaf(Block::Prose { line, text, .. })) = flat.first() else { + panic!("the body opens with prose"); + }; + assert_eq!( + (*line, text.as_str()), + (3, " indented code\n\nA paragraph.") + ); + } + + #[test] + fn a_line_of_other_whitespace_is_not_blank() { + let body = "\u{a0}\n\n# H\n"; + let flat = flatten(body, marks(body), 1); + assert!(matches!( + flat.first(), + Some(Flat::Leaf(Block::Prose { line: 1, text, .. })) if text == "\u{a0}" + )); + } +} diff --git a/crates/runvil-internal/markdown/src/parse/frontmatter.rs b/crates/runvil-internal/markdown/src/parse/frontmatter.rs new file mode 100644 index 000000000..92ed96918 --- /dev/null +++ b/crates/runvil-internal/markdown/src/parse/frontmatter.rs @@ -0,0 +1,133 @@ +//! The frontmatter that may open a text. + +use serde_json::Value as Json; + +use super::BLANK; + +/// The frontmatter split off the front of a text. +pub(super) struct Opening { + /// The YAML mapping, as a JSON object. + pub(super) data: Json, + /// The lines the frontmatter takes: both delimiters, the lines + /// between them, and the blank lines after. + pub(super) lines: usize, + /// The byte the rest of the text starts at: its first line that is + /// not blank, or the end of the text. + pub(super) end: usize, +} + +/// Splits the frontmatter off the front of `text`, whose line endings are +/// all `\n`. +/// +/// There is frontmatter only when the first line is `---`, a later line is +/// `---`, and the lines between are a YAML mapping. The delimiter rule is +/// the prompt parser's: the opening line may have spaces on either side of +/// its dashes, and the closing line only after them. In every other case +/// the text has no frontmatter and its first lines are ordinary Markdown. +/// +/// The frontmatter takes the blank lines after its closing delimiter, as +/// every block takes the blank lines after it. +pub(super) fn split(text: &str) -> Option { + let mut rest = text.split_inclusive('\n'); + let first = rest.next()?; + if first.trim() != "---" { + return None; + } + let mut end = first.len(); + let mut lines = 1; + let mut yaml = String::new(); + while let Some(line) = rest.next() { + end += line.len(); + lines += 1; + if line.trim_end() == "---" { + let data: Json = serde_yaml_ng::from_str(&yaml).ok()?; + if !data.is_object() { + return None; + } + for blank in rest.take_while(|line| line.trim_matches(BLANK).is_empty()) { + end += blank.len(); + lines += 1; + } + return Some(Opening { data, lines, end }); + } + yaml.push_str(line); + } + None +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::split; + + #[test] + fn a_mapping_between_two_delimiters_is_split_off_with_its_lines_counted() { + let text = "---\ntitle: Notes\ntags: [a, b]\n---\nBody.\n"; + let opening = split(text).unwrap(); + assert_eq!(opening.data, json!({"title": "Notes", "tags": ["a", "b"]})); + assert_eq!(opening.lines, 4); + assert_eq!(&text[opening.end..], "Body.\n"); + } + + #[test] + fn the_blank_lines_after_the_closing_delimiter_are_split_off_with_it() { + let text = "---\na: 1\n---\n\n \t\n Body.\n\nMore.\n"; + let opening = split(text).unwrap(); + assert_eq!(opening.lines, 5); + // The first line that is not blank keeps its indentation. + assert_eq!(&text[opening.end..], " Body.\n\nMore.\n"); + // Blank lines alone after the delimiter are all taken, with or + // without a final line ending. + for text in ["---\na: 1\n---\n\n\n", "---\na: 1\n---\n\n "] { + let opening = split(text).unwrap(); + assert_eq!((opening.lines, opening.end), (5, text.len()), "{text:?}"); + } + } + + #[test] + fn the_delimiters_follow_the_prompt_parsers_rule() { + // Spaces may sit on either side of the opening dashes, and only + // after the closing ones: an indented `---` is YAML text. + assert!(split(" --- \na: 1\n--- \n").is_some()); + assert!(split("---\na: 1\n ---\n").is_none()); + assert!(split("---\na: |\n x\n ---\n---\n").is_some()); + // The closing delimiter may be the last line, with no line ending. + let opening = split("---\na: 1\n---").unwrap(); + assert_eq!((opening.lines, opening.end), (3, 12)); + } + + #[test] + fn text_without_both_delimiters_or_without_a_mapping_has_no_frontmatter() { + for text in [ + "", + "# Title\n", + "\n---\na: 1\n---\n", + "---\na: 1\n", + "---\n---\n", + "---\njust words\n---\n", + "---\n- a\n- b\n---\n", + "---\na: [1\n---\n", + ] { + assert!(split(text).is_none(), "{text:?}"); + } + } + + #[test] + fn yaml_values_read_as_json() { + let data = |yaml: &str| split(&format!("---\n{yaml}\n---\n")).unwrap().data; + // A key that is not a string reads as its text. + assert_eq!(data("1: a\ntrue: b"), json!({"1": "a", "true": "b"})); + // A number JSON cannot hold is null, and a date is a string. + assert_eq!( + data("a: .inf\nb: .nan\nc: 2026-11-11\nd: ~"), + json!({"a": null, "b": null, "c": "2026-11-11", "d": null}) + ); + // A repeated key keeps its last value. + assert_eq!(data("a: 1\na: 2"), json!({"a": 2})); + assert_eq!( + data("a: {b: [1, 2.5, x]}"), + json!({"a": {"b": [1, 2.5, "x"]}}) + ); + } +} diff --git a/crates/runvil-internal/markdown/src/parse/tree.rs b/crates/runvil-internal/markdown/src/parse/tree.rs new file mode 100644 index 000000000..b36778013 --- /dev/null +++ b/crates/runvil-internal/markdown/src/parse/tree.rs @@ -0,0 +1,169 @@ +//! Layer 3: the flat form folded into sections. + +use super::flat::Flat; +use crate::Block; + +/// A section whose heading has been read and whose end has not. +struct Open { + /// The heading's line. + line: usize, + /// The heading's source. + source: String, + /// The heading's level. + level: u8, + /// The heading's title. + title: String, + /// The blocks placed in the section so far. + blocks: Vec, +} + +/// Folds `flat` into a tree and returns it after the blocks of `root`. +/// +/// A heading opens a section, which holds every entry up to the next +/// heading of the same or a smaller level. A heading of a larger level +/// opens a subsection inside it, however many levels it skips. +pub(super) fn fold(flat: Vec, mut root: Vec) -> Vec { + let mut open: Vec = Vec::new(); + for entry in flat { + match entry { + Flat::Heading { + line, + source, + level, + title, + } => { + close(&mut open, &mut root, level); + open.push(Open { + line, + source, + level, + title, + blocks: Vec::new(), + }); + } + Flat::Leaf(block) => place(&mut open, &mut root, block), + } + } + close(&mut open, &mut root, 0); + root +} + +/// Closes every open section at `level` or deeper, innermost first. +fn close(open: &mut Vec, root: &mut Vec, level: u8) { + while let Some(section) = open.pop_if(|section| section.level >= level) { + let block = Block::Section { + line: section.line, + source: section.source, + level: section.level, + title: section.title, + blocks: section.blocks, + }; + place(open, root, block); + } +} + +/// Puts `block` in the innermost open section, or in `root` when none is +/// open. +fn place(open: &mut [Open], root: &mut Vec, block: Block) { + match open.last_mut() { + Some(section) => section.blocks.push(block), + None => root.push(block), + } +} + +#[cfg(test)] +mod tests { + use super::super::flat::Flat; + use super::fold; + use crate::Block; + + fn heading(level: u8, title: &str) -> Flat { + Flat::Heading { + line: 0, + source: format!("{} {title}\n", "#".repeat(usize::from(level))), + level, + title: title.to_owned(), + } + } + + fn rule() -> Flat { + Flat::Leaf(Block::Rule { + line: 0, + source: "---\n".to_owned(), + }) + } + + /// The tree as nested text: a section is `title(members)` and a rule + /// is `-`. + fn shape(blocks: &[Block]) -> String { + blocks + .iter() + .map(|block| match block { + Block::Section { title, blocks, .. } => format!("{title}({})", shape(blocks)), + _ => "-".to_owned(), + }) + .collect::>() + .join(" ") + } + + #[test] + fn a_heading_closes_every_open_section_at_its_level_or_deeper() { + let flat = vec![ + rule(), + heading(1, "A"), + rule(), + heading(2, "B"), + heading(3, "C"), + rule(), + heading(2, "D"), + heading(1, "E"), + rule(), + ]; + assert_eq!(shape(&fold(flat, Vec::new())), "- A(- B(C(-)) D()) E(-)"); + } + + #[test] + fn a_heading_that_skips_levels_is_a_member_of_the_section_above_it() { + let flat = vec![heading(2, "A"), heading(4, "B"), heading(3, "C")]; + let tree = fold(flat, Vec::new()); + assert_eq!(shape(&tree), "A(B() C())"); + let Some(Block::Section { blocks, .. }) = tree.first() else { + panic!("the tree opens with a section"); + }; + assert!(matches!( + blocks.first(), + Some(Block::Section { level: 4, .. }) + )); + } + + #[test] + fn a_shallower_heading_after_a_deeper_one_is_a_section_of_its_own() { + let flat = vec![heading(3, "A"), heading(1, "B")]; + assert_eq!(shape(&fold(flat, Vec::new())), "A() B()"); + } + + #[test] + fn the_tree_follows_the_blocks_it_was_given() { + let root = vec![Block::Rule { + line: 0, + source: "---\n".to_owned(), + }]; + assert_eq!(shape(&fold(vec![heading(1, "A")], root)), "- A()"); + } + + #[test] + fn a_section_keeps_its_headings_source_and_folding_moves_no_source() { + let flat = vec![heading(2, "A"), rule(), heading(3, "B"), heading(1, "C")]; + let tree = fold(flat, Vec::new()); + let [ + Block::Section { source, blocks, .. }, + Block::Section { source: last, .. }, + ] = tree.as_slice() + else { + panic!("the tree is two sections"); + }; + assert_eq!((source.as_str(), last.as_str()), ("## A\n", "# C\n")); + let sources: Vec<&str> = blocks.iter().map(Block::source).collect(); + assert_eq!(sources, ["---\n", "### B\n"]); + } +} diff --git a/crates/runvil-internal/markdown/src/path.rs b/crates/runvil-internal/markdown/src/path.rs new file mode 100644 index 000000000..c7f16f9be --- /dev/null +++ b/crates/runvil-internal/markdown/src/path.rs @@ -0,0 +1,501 @@ +//! Section paths: titles separated by `/`. +//! +//! [`split`] is the one grammar every path shares: the steps, the +//! escapes, the trimming, and what makes an index. What a path may start +//! with and which forms it may use depend on what takes it, so each taker +//! checks the [`Steps`] it gets back. [`Path`] is the check for a path +//! over parsed text. + +use std::fmt; + +use crate::index::at_lines; + +/// One step of a path: a section's title, and how the step finds it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Step { + /// Whether the step was written after `//`, which searches at any + /// depth. + pub deep: bool, + /// The title the step names, with its escapes read and its + /// surrounding whitespace removed. It is never empty. + pub title: String, + /// The `[n]` written after the title, which is never zero. + pub index: Option, +} + +/// A path split into its steps. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Steps { + /// Whether the path starts with `/`. + pub from_top: bool, + /// The steps, in order. There is at least one. + pub steps: Vec, +} + +/// Splits a path into its steps. +/// +/// Whitespace around the path and around each step is ignored. `\/` is a +/// slash inside a title, `\\` a backslash, and `\[` a bracket that would +/// otherwise open an index. A bracketed integer at the very end of a step +/// is its index, and an index too large to count with is kept as the +/// largest one, which no match reaches. +/// +/// # Errors +/// Returns the message for an empty step, a backslash that starts none of +/// the three escapes, or an index of zero. +pub fn split(given: &str) -> Result { + let trimmed = given.trim(); + let (from_top, mut rest) = match trimmed.strip_prefix('/') { + Some(rest) => (true, rest), + None => (false, trimmed), + }; + let mut steps = Vec::new(); + loop { + let (deep, text) = match rest.strip_prefix('/') { + Some(text) => (true, text), + None => (false, rest), + }; + let (step, after) = step(given, deep, text)?; + steps.push(step); + match after { + Some(after) => rest = after, + None => return Ok(Steps { from_top, steps }), + } + } +} + +/// Reads one step from the front of `text`. Returns it with the text +/// after the slash that ends it, or `None` when the step ends the path. +fn step<'a>(given: &str, deep: bool, text: &'a str) -> Result<(Step, Option<&'a str>), String> { + let mut title = String::new(); + // Where the last `[` sits in `title`, and whether it was escaped. + let mut bracket = None; + let mut after = None; + let mut chars = text.char_indices(); + while let Some((at, c)) = chars.next() { + match c { + '/' => { + after = text.get(at + 1..); + break; + } + '\\' => match chars.next() { + Some((_, escaped @ ('/' | '\\' | '['))) => { + if escaped == '[' { + bracket = Some((title.len(), true)); + } + title.push(escaped); + } + Some((_, other)) => { + return Err(format!( + "path \"{given}\" has an unknown escape \"\\{other}\"; the escapes are \ + \"\\/\", \"\\\\\", and \"\\[\"" + )); + } + None => return Err(format!("path \"{given}\" ends in a backslash")), + }, + _ => { + if c == '[' { + bracket = Some((title.len(), false)); + } + title.push(c); + } + } + } + let index = index(given, &mut title, bracket)?; + let title = title.trim(); + if title.is_empty() { + return Err(format!("path \"{given}\" has an empty step")); + } + let step = Step { + deep, + title: title.to_owned(), + index, + }; + Ok((step, after)) +} + +/// Takes the index off the end of a step's text, when it has one: a +/// bracketed integer at the very end whose bracket, at `bracket`, was not +/// escaped. +fn index( + given: &str, + title: &mut String, + bracket: Option<(usize, bool)>, +) -> Result, String> { + let Some((open, false)) = bracket else { + return Ok(None); + }; + let Some(digits) = title + .trim_end() + .get(open + 1..) + .and_then(|inner| inner.strip_suffix(']')) + .filter(|inner| is_integer(inner)) + else { + return Ok(None); + }; + let n = digits.parse::().unwrap_or(if digits.starts_with('-') { + i64::MIN + } else { + i64::MAX + }); + if n == 0 { + return Err(format!( + "path \"{given}\" has index 0; an index is a nonzero integer" + )); + } + title.truncate(open); + Ok(Some(n)) +} + +/// Whether `text` is digits, with or without a `-` in front. +fn is_integer(text: &str) -> bool { + let digits = text.strip_prefix('-').unwrap_or(text); + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) +} + +/// Writes a title as a path step: `\` and `/` escaped, and the bracket of +/// a bracketed integer at its end escaped, so the step reads back as the +/// title. +#[must_use] +pub fn escape_title(title: &str) -> String { + let mut step = title.replace('\\', "\\\\").replace('/', "\\/"); + if let Some(open) = step.rfind('[') + && step + .get(open + 1..) + .and_then(|inner| inner.strip_suffix(']')) + .is_some_and(is_integer) + { + step.insert(open, '\\'); + } + step +} + +impl fmt::Display for Step { + /// Writes the step as a path shows it: `//` in front when it searches + /// at any depth, and its index behind. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.deep { + f.write_str("//")?; + } + f.write_str(&escape_title(&self.title))?; + match self.index { + Some(n) => write!(f, "[{n}]"), + None => Ok(()), + } + } +} + +/// A path over parsed text: from the top of a list of blocks, with `//` +/// and `[n]` allowed and no `..`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Path { + /// The steps, in order. There is at least one. + steps: Vec, +} + +impl Path { + /// Reads a path over parsed text. + /// + /// # Errors + /// Returns the message for a path [`split`] refuses, one with no + /// leading `/`, or one with a `..` step. + pub fn parse(given: &str) -> Result { + let Steps { from_top, steps } = split(given)?; + if !from_top { + return Err(format!("a path starts with \"/\", got \"{given}\"")); + } + if steps.iter().any(|step| step.title == "..") { + return Err(format!( + "path \"{given}\" uses \"..\"; parsed text has no parent to step to" + )); + } + Ok(Path { steps }) + } + + /// The steps, in order. There is at least one. + #[must_use] + pub fn steps(&self) -> &[Step] { + &self.steps + } + + /// The message for a path that names no section because step `number`, + /// counted from 1, kept nothing. `given` is the path as it was + /// written. + #[must_use] + pub fn no_match(&self, given: &str, number: usize) -> String { + let step = number + .checked_sub(1) + .and_then(|at| self.steps.get(at)) + .map(Step::to_string) + .unwrap_or_default(); + format!( + "path \"{}\" matches no section; step {number}, \"{step}\", matches nothing", + given.trim() + ) + } + + /// The message for a path whose last step kept several sections, one + /// on each of `lines`. A match that records no line is `None`. + #[must_use] + pub fn ambiguous(given: &str, lines: &[Option]) -> String { + let given = given.trim(); + format!( + "path \"{given}\" is ambiguous; it matches {} sections{}; add an index such as \ + \"{given}[1]\"", + lines.len(), + at_lines(lines) + ) + } +} + +#[cfg(test)] +mod tests { + use super::{Path, Step, Steps, escape_title, split}; + + fn step(title: &str) -> Step { + Step { + deep: false, + title: title.to_owned(), + index: None, + } + } + + fn deep(title: &str) -> Step { + Step { + deep: true, + ..step(title) + } + } + + fn indexed(title: &str, n: i64) -> Step { + Step { + index: Some(n), + ..step(title) + } + } + + /// Asserts that `given` has no leading slash and splits into `steps`. + #[track_caller] + fn assert_relative(given: &str, steps: &[Step]) { + let expected = Steps { + from_top: false, + steps: steps.to_vec(), + }; + assert_eq!(split(given), Ok(expected), "{given:?}"); + } + + /// Asserts that `given` starts with a slash and splits into `steps`. + #[track_caller] + fn assert_from_top(given: &str, steps: &[Step]) { + let expected = Steps { + from_top: true, + steps: steps.to_vec(), + }; + assert_eq!(split(given), Ok(expected), "{given:?}"); + } + + #[test] + fn a_path_is_steps_separated_by_slashes() { + assert_relative("Template", &[step("Template")]); + assert_relative("../Review", &[step(".."), step("Review")]); + assert_relative("a/b/c", &[step("a"), step("b"), step("c")]); + assert_from_top("/Guide/Setup", &[step("Guide"), step("Setup")]); + assert_relative("## Rules", &[step("## Rules")]); + } + + #[test] + fn a_doubled_slash_marks_the_step_after_it() { + assert_from_top("//Extras", &[deep("Extras")]); + assert_from_top("/Guide//Extras", &[step("Guide"), deep("Extras")]); + assert_from_top("//A//B", &[deep("A"), deep("B")]); + assert_relative("a//b", &[step("a"), deep("b")]); + } + + #[test] + fn whitespace_around_the_path_and_around_each_step_is_ignored() { + assert_relative(" .. / Review ", &[step(".."), step("Review")]); + assert_from_top("\t/ Guide / Set up \n", &[step("Guide"), step("Set up")]); + assert_from_top("/Log/ Entry [2] ", &[step("Log"), indexed("Entry", 2)]); + } + + #[test] + fn a_bracketed_integer_at_the_end_of_a_step_is_its_index() { + assert_from_top("/Log/Entry[1]", &[step("Log"), indexed("Entry", 1)]); + assert_from_top("/Log/Entry[-1]", &[step("Log"), indexed("Entry", -1)]); + let second = Step { + index: Some(2), + ..deep("A") + }; + assert_from_top("//A[2]/B[12]", &[second, indexed("B", 12)]); + assert_relative("Topics[1]", &[indexed("Topics", 1)]); + assert_from_top("/a[1][2]", &[indexed("a[1]", 2)]); + } + + #[test] + fn any_other_bracket_is_part_of_the_title() { + for title in [ + "[Draft] Notes", + "Notes [old]", + "a[1]b", + "a[]", + "a[1.5]", + "a[+1]", + "a[ 1 ]", + "a[--1]", + "a]", + "a[1", + ] { + assert_from_top(&format!("/{title}"), &[step(title)]); + } + } + + #[test] + fn the_three_escapes_put_a_slash_a_backslash_and_a_bracket_in_a_title() { + assert_relative(r"Input\/Output", &[step("Input/Output")]); + assert_from_top(r"/a\\b/c", &[step(r"a\b"), step("c")]); + assert_from_top(r"/Appendix \[2]", &[step("Appendix [2]")]); + assert_from_top(r"/a\[1][2]", &[indexed("a[1]", 2)]); + assert_from_top(r"/a[1]\[2]", &[step("a[1][2]")]); + assert_relative(r"\/\/", &[step("//")]); + } + + #[test] + fn an_index_too_large_to_count_with_is_kept_as_the_largest() { + assert_from_top("/a[99999999999999999999]", &[indexed("a", i64::MAX)]); + assert_from_top("/a[-99999999999999999999]", &[indexed("a", i64::MIN)]); + } + + #[test] + fn an_empty_step_is_refused() { + for path in [ + "", " ", "/", "//", "Guide/", "../", "/Guide/", "///a", "/a/ /b", "[1]", "/a/[2]", + ] { + assert_eq!( + split(path), + Err(format!("path \"{path}\" has an empty step")), + "{path:?}" + ); + } + } + + #[test] + fn any_other_backslash_is_refused() { + for (path, escape) in [(r"/a\b", r"\b"), (r"a\]", r"\]"), (r"\.\./a", r"\.")] { + assert_eq!( + split(path), + Err(format!( + r#"path "{path}" has an unknown escape "{escape}"; the escapes are "\/", "\\", and "\[""# + )), + "{path}" + ); + } + assert_eq!( + split(r"/a\"), + Err(r#"path "/a\" ends in a backslash"#.to_owned()) + ); + } + + #[test] + fn an_index_of_zero_is_refused() { + for path in ["/Log/Entry[0]", "/Log/Entry[-0]", "/Log/Entry[000]"] { + assert_eq!( + split(path), + Err(format!( + "path \"{path}\" has index 0; an index is a nonzero integer" + )), + "{path}" + ); + } + } + + #[test] + fn a_step_is_written_as_a_path_shows_it() { + assert_eq!(step("Nope").to_string(), "Nope"); + assert_eq!(deep("Nope").to_string(), "//Nope"); + assert_eq!(indexed("Entry", 3).to_string(), "Entry[3]"); + assert_eq!(indexed("Entry", -1).to_string(), "Entry[-1]"); + assert_eq!(step("Input/Output").to_string(), r"Input\/Output"); + } + + #[test] + fn an_escaped_title_reads_back_as_the_title() { + for title in [ + "Plain", + "Input/Output", + r"back\slash", + "Appendix [2]", + "Appendix [-2]", + "[Draft] Notes", + "Notes [old]", + r"a\/b[3]", + "a[1][2]", + "## Rules", + ] { + assert_from_top(&format!("/{}", escape_title(title)), &[step(title)]); + } + assert_eq!(escape_title("Appendix [2]"), r"Appendix \[2]"); + assert_eq!(escape_title("Notes [old]"), "Notes [old]"); + assert_eq!(escape_title(r"C:\Users"), r"C:\\Users"); + } + + #[test] + fn a_path_over_parsed_text_starts_at_the_top_and_has_no_parent_step() { + let path = Path::parse("/Guide//Extras[1]").unwrap(); + let last = Step { + index: Some(1), + ..deep("Extras") + }; + assert_eq!(path.steps(), [step("Guide"), last]); + assert_eq!( + Path::parse("Guide/Setup"), + Err(r#"a path starts with "/", got "Guide/Setup""#.to_owned()) + ); + assert_eq!( + Path::parse("/Guide/../Usage"), + Err( + r#"path "/Guide/../Usage" uses ".."; parsed text has no parent to step to"# + .to_owned() + ) + ); + assert_eq!( + Path::parse("/Guide/"), + Err(r#"path "/Guide/" has an empty step"#.to_owned()) + ); + } + + #[test] + fn a_path_that_names_nothing_says_which_step_kept_nothing() { + for (given, number, message) in [ + ( + "/Guide/Nope", + 2, + r#"path "/Guide/Nope" matches no section; step 2, "Nope", matches nothing"#, + ), + ( + "/Log/Entry[3]", + 2, + r#"path "/Log/Entry[3]" matches no section; step 2, "Entry[3]", matches nothing"#, + ), + ( + " //Nope ", + 1, + r#"path "//Nope" matches no section; step 1, "//Nope", matches nothing"#, + ), + ] { + let path = Path::parse(given).unwrap(); + assert_eq!(path.no_match(given, number), message); + } + } + + #[test] + fn a_path_that_names_several_sections_asks_for_an_index() { + assert_eq!( + Path::ambiguous("//Items", &[Some(5), Some(12)]), + r#"path "//Items" is ambiguous; it matches 2 sections, at lines 5 and 12; add an index such as "//Items[1]""# + ); + assert_eq!( + Path::ambiguous("/Log/Entry", &[Some(3), None, Some(9)]), + r#"path "/Log/Entry" is ambiguous; it matches 3 sections; add an index such as "/Log/Entry[1]""# + ); + } +} diff --git a/crates/runvil-internal/markdown/src/render-tests.rs b/crates/runvil-internal/markdown/src/render-tests.rs new file mode 100644 index 000000000..1ee788a19 --- /dev/null +++ b/crates/runvil-internal/markdown/src/render-tests.rs @@ -0,0 +1,278 @@ +//! Tests of the writer: that it writes sources and nothing else, the two +//! line endings it adds, and that parsed text is written back as it was. + +use super::{Writer, render}; +use crate::test_support::{ + BODIES, GUIDE, SEED, Seeded, bullets, fence, flat, numbered, prose, rule, section, sourced, +}; +use crate::{Block, parse, parse_body}; + +/// A prose block whose source is `source`. +fn words(source: &str) -> Block { + sourced(prose(0, source.trim()), source) +} + +#[test] +fn an_empty_list_is_empty_text() { + assert_eq!(render(&[]), ""); + assert_eq!(Writer::new().finish(), ""); +} + +#[test] +fn a_block_is_written_as_its_source_and_a_section_before_its_blocks() { + let inner = sourced(section(0, 3, "B", vec![words("b\n")]), "### B\n\n"); + let outer = sourced( + section(0, 2, "A", vec![sourced(rule(0), "***\n\n"), inner]), + "## A\n\n", + ); + let after = sourced(bullets(0, &["after"]), "- after\n"); + assert_eq!( + render(&[outer.clone(), after]), + "## A\n\n***\n\n### B\n\nb\n- after\n" + ); + // A section's blocks are written without its heading. + let Block::Section { blocks, .. } = &outer else { + panic!("the outer block is a section"); + }; + assert_eq!(render(blocks), "***\n\n### B\n\nb\n"); +} + +#[test] +fn no_field_but_the_source_is_written() { + // Each block's other fields say something else than its source. + let blocks = [ + sourced(section(9, 4, "Other", vec![]), "Usage\n-----\n\n"), + sourced(numbered(9, &["x"]), "3) first\n4) second\n\n"), + sourced(bullets(9, &["y", "z"]), "* only\n"), + sourced(fence(9, "lua", "return 2"), "~~~ ~`\nreturn 1\n~~~\n"), + sourced(rule(9), "___\n"), + sourced(prose(9, "other"), " kept as written \n"), + ]; + assert_eq!( + render(&blocks), + "Usage\n-----\n\n3) first\n4) second\n\n* only\n~~~ ~`\nreturn 1\n~~~\n___\n kept as written \n" + ); +} + +#[test] +fn a_block_with_an_empty_source_writes_nothing() { + assert_eq!(render(&[rule(0), prose(0, "x"), fence(0, "lua", "y")]), ""); + // The blocks under a section with no source are still written. + let group = section(0, 1, "A", vec![words("a\n"), sourced(rule(0), "---\n")]); + assert_eq!(render(&[group]), "a\n---\n"); +} + +#[test] +fn every_block_starts_on_a_new_line() { + // Each of the first three sources lacks its line ending. + let blocks = [ + sourced(section(0, 1, "A", vec![sourced(rule(0), "---")]), "# A"), + sourced(fence(0, "", "x"), "```\nx\n```"), + sourced(bullets(0, &["a"]), "- a\n"), + sourced(numbered(0, &["b"]), "1. b"), + ]; + assert_eq!(render(&blocks), "# A\n---\n```\nx\n```\n- a\n1. b"); + // Nothing is added after the last block, or before the first. + assert_eq!(render(&[sourced(rule(0), "---")]), "---"); + // The line ending goes before a block that starts with a blank line too. + assert_eq!( + render(&[sourced(rule(0), "---"), sourced(rule(0), "\n***\n")]), + "---\n\n***\n" + ); +} + +#[test] +fn a_blank_line_sits_between_two_prose_blocks() { + assert_eq!(render(&[words("one\n"), words("two\n")]), "one\n\ntwo\n"); + // With no line ending on the first, both are added. + assert_eq!(render(&[words("one"), words("two")]), "one\n\ntwo"); + assert_eq!( + render(&[words("one\n"), words("two\n"), words("three")]), + "one\n\ntwo\n\nthree" + ); + // The last prose block of a section and the block after the section. + let section = sourced(section(0, 1, "A", vec![words("in\n")]), "# A\n"); + assert_eq!(render(&[section, words("out\n")]), "# A\nin\n\nout\n"); + // A block with no source between them does not part them. + assert_eq!( + render(&[words("one\n"), rule(0), words("two\n")]), + "one\n\ntwo\n" + ); +} + +#[test] +fn no_blank_line_is_added_where_one_already_parts_two_prose_blocks() { + for (first, second, written) in [ + ("one\n\n", "two\n", "one\n\ntwo\n"), + ("one\n\n\n", "two\n", "one\n\n\ntwo\n"), + // A line of spaces and tabs is a blank line. + ("one\n \t\n", "two\n", "one\n \t\ntwo\n"), + // The second block may start with it. + ("one\n", "\ntwo\n", "one\n\ntwo\n"), + ("one", " \ntwo", "one\n \ntwo"), + ] { + assert_eq!(render(&[words(first), words(second)]), written); + } +} + +#[test] +fn no_blank_line_is_added_between_any_other_two_blocks() { + let heading = || sourced(section(0, 1, "H", vec![]), "# H\n"); + let list = || sourced(bullets(0, &["a"]), "- a\n"); + let code = || sourced(fence(0, "", "x"), "```\nx\n```\n"); + let line = || sourced(rule(0), "---\n"); + for (blocks, written) in [ + // Prose right above a list is common, and is two blocks as written. + (vec![words("see:\n"), list()], "see:\n- a\n"), + (vec![list(), words("after\n")], "- a\nafter\n"), + (vec![heading(), words("text\n")], "# H\ntext\n"), + (vec![words("text\n"), heading()], "text\n# H\n"), + (vec![list(), list()], "- a\n- a\n"), + (vec![code(), words("text\n")], "```\nx\n```\ntext\n"), + (vec![words("text\n"), code()], "text\n```\nx\n```\n"), + (vec![line(), words("text\n")], "---\ntext\n"), + // A block of another kind between two prose blocks parts them. + ( + vec![words("one\n"), code(), words("two\n")], + "one\n```\nx\n```\ntwo\n", + ), + ] { + assert_eq!(render(&blocks), written); + } +} + +#[test] +fn a_writer_takes_a_kind_by_its_name_and_only_prose_is_parted() { + let mut writer = Writer::new(); + writer.write("prose", "one"); + writer.write("prose", "two\n"); + // A kind the crate does not know is written like any other. + writer.write("note", "three"); + writer.write("", "four\n"); + writer.write("Prose", "five\n"); + writer.write("prose", "six"); + writer.write("prose", ""); + assert_eq!(writer.finish(), "one\n\ntwo\nthree\nfour\nfive\nsix"); +} + +#[test] +fn what_the_writer_adds_keeps_blocks_of_different_texts_apart() { + // The last block of a text has no line ending when the text has none. + let mixed = [parse("# A\nfirst"), parse("second\n\n# B")].concat(); + // Joined as they are, the two paragraphs share a line. + let joined: String = flat(&mixed).into_iter().map(Block::source).collect(); + assert_eq!(joined, "# A\nfirstsecond\n\n# B"); + assert_eq!(render(&mixed), "# A\nfirst\n\nsecond\n\n# B"); + // Written twice over, the first heading of the second copy would + // share a line with the last heading of the first. + let written = render(&[mixed.clone(), mixed].concat()); + assert_eq!( + written, + "# A\nfirst\n\nsecond\n\n# B\n# A\nfirst\n\nsecond\n\n# B" + ); + let read: Vec<(&str, usize)> = flat(&parse(&written)) + .into_iter() + .map(|block| (block.kind().name(), block.line())) + .collect(); + assert_eq!( + read, + [ + ("section", 1), + ("prose", 2), + ("section", 6), + ("section", 7), + ("prose", 8), + ("section", 12) + ] + ); +} + +/// `text` with `\r\n` for every line ending. +fn with_crlf(text: &str) -> String { + text.replace('\n', "\r\n") +} + +/// Asserts that `text`, whose line endings are all `\n`, is written back +/// as it is, whichever way it was read. Text of blank lines alone has no +/// block, so it is written as nothing. +#[track_caller] +fn assert_written_back(text: &str, context: &str) { + let want = if text.trim_matches([' ', '\t', '\n']).is_empty() { + "" + } else { + text + }; + assert_eq!(render(&parse(text)), want, "{context}"); + assert_eq!(render(&parse_body(text, 5)), want, "body, {context}"); + // A byte order mark and the other line endings are not written back. + assert_eq!(render(&parse(&with_crlf(text))), want, "crlf, {context}"); + let marked = format!("\u{feff}{text}"); + assert_eq!(render(&parse(&marked)), want, "marked, {context}"); +} + +#[test] +fn every_sample_is_written_back_as_it_was_read() { + assert_written_back(GUIDE, "the guide"); + for body in BODIES { + assert_written_back(body, &format!("{body:?}")); + } +} + +#[test] +fn generated_texts_are_written_back_as_they_were_read() { + let mut rng = Seeded::new(SEED); + for number in 0..2000 { + let text = rng.text(); + assert_written_back(&text, &format!("seed {SEED:#x}, text {number}, {text:?}")); + } +} + +#[test] +fn text_of_blank_lines_alone_is_the_one_text_not_written_back() { + for text in ["\n", "\n\n", " \n\t\n", " "] { + assert_eq!(render(&parse(text)), "", "{text:?}"); + } + // With a block above them, they are written. + for text in ["x\n\n\n", "---\na: 1\n---\n\n\n", "# H\n \n"] { + assert_eq!(render(&parse(text)), text, "{text:?}"); + } +} + +#[test] +fn what_a_rewriting_writer_changed_is_written_back_unchanged() { + for text in [ + // A list that does not start at 1, and one cut in two by a fence. + "3. third\n4. fourth\n", + "1. Install it\n\n```sh\ncargo install x\n```\n\n2. Run it\n", + "1. a\n1. b\n1. c\n", + "5) five\n6) six\n", + // Markers, fences, rules, and headings in every spelling. + "* a\n* b\n\n+ c\n", + "~~~lua\nreturn 1\n~~~\n", + "Title\n=====\n\nSub\n---\n", + "___\n\n***\n\n- - -\n", + "## See [API](http://x) and `code` ##\n", + // Frontmatter with comments, its own key order, and flow style. + "---\n# why\nz: 1\na: { b: [1, 2] }\n---\n\n\n\nbody\n", + // Blank lines in any number, and a text with no final line ending. + "# A\n\n\n\nx\n\n\n\n# B", + ] { + assert_eq!(render(&parse(text)), text, "{text:?}"); + } +} + +#[test] +fn a_tilde_fence_whose_label_starts_with_a_tilde_keeps_the_blocks_after_it() { + // Written with the fence and its label run together, the fence would + // lose its closing line and take the rest of the text. + let text = "~~~ ~`\nx\n~~~\n\n# After\n\ntail\n"; + let blocks = parse(text); + let kinds: Vec<&str> = flat(&blocks) + .into_iter() + .map(|block| block.kind().name()) + .collect(); + assert_eq!(kinds, ["fence", "section", "prose"]); + let written = render(&blocks); + assert_eq!(written, text); + assert_eq!(parse(&written), blocks); +} diff --git a/crates/runvil-internal/markdown/src/render.rs b/crates/runvil-internal/markdown/src/render.rs new file mode 100644 index 000000000..51bfe67dd --- /dev/null +++ b/crates/runvil-internal/markdown/src/render.rs @@ -0,0 +1,98 @@ +//! Blocks to text. + +use crate::parse::BLANK; +use crate::{Block, Kind}; + +/// Writes a list of blocks as Markdown: the source of each block in file +/// order, a section's before those of the blocks under it. +/// +/// Nothing is rebuilt from a block's other fields, so the blocks that +/// [`parse`](fn@crate::parse) gave are written as the text they were read +/// from. Blocks put together any other way are written through a +/// [`Writer`], which keeps two of them from running into each other. +#[must_use] +pub fn render(blocks: &[Block]) -> String { + let mut writer = Writer::new(); + write_blocks(&mut writer, blocks); + writer.finish() +} + +/// Writes `blocks` and the blocks under them to `writer`. +fn write_blocks(writer: &mut Writer, blocks: &[Block]) { + for block in blocks { + writer.write(block.kind().name(), block.source()); + if let Block::Section { blocks, .. } = block { + write_blocks(writer, blocks); + } + } +} + +/// Writes the sources of blocks one after another as one text. +/// +/// A source is written as it is. The writer adds a line ending of its own +/// in two cases, neither of which occurs between two blocks of one parse, +/// so a parsed document is written exactly: +/// +/// 1. Every block starts on a new line. When the text so far does not end +/// with a line ending, one is written first. Of parsed blocks, only +/// the last of a text can lack one. +/// 2. A blank line sits between two prose blocks, which would otherwise +/// read as one paragraph. When neither the text so far ends with a +/// blank line nor the block starts with one, one is written first. +/// Parsed prose is a maximal run, so no two prose blocks of one parse +/// are neighbors. +/// +/// Whether any other two blocks need a blank line depends on their text, +/// and the writer does not decide that. +#[derive(Debug, Default)] +pub struct Writer { + /// The text written so far. + out: String, + /// Whether the last block written was prose. + after_prose: bool, +} + +impl Writer { + /// A writer that has written nothing. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Writes the source of one block whose `kind` field is `kind`. The + /// blocks under a section are written after it, each by its own call. + /// An empty source writes nothing. + pub fn write(&mut self, kind: &str, source: &str) { + if source.is_empty() { + return; + } + let prose = kind == Kind::Prose.name(); + if !self.out.is_empty() { + if !self.out.ends_with('\n') { + self.out.push('\n'); + } + let parted = is_blank(self.out.split_inclusive('\n').next_back()) + || is_blank(source.split_inclusive('\n').next()); + if prose && self.after_prose && !parted { + self.out.push('\n'); + } + } + self.out.push_str(source); + self.after_prose = prose; + } + + /// The text written. + #[must_use] + pub fn finish(self) -> String { + self.out + } +} + +/// Whether `line` is a blank line. No line is not one. +fn is_blank(line: Option<&str>) -> bool { + line.is_some_and(|line| line.trim_matches(BLANK).is_empty()) +} + +#[cfg(test)] +#[path = "render-tests.rs"] +mod tests; diff --git a/crates/runvil-internal/markdown/src/selector.rs b/crates/runvil-internal/markdown/src/selector.rs new file mode 100644 index 000000000..1c906f494 --- /dev/null +++ b/crates/runvil-internal/markdown/src/selector.rs @@ -0,0 +1,322 @@ +//! Selectors: the word that names a kind of block, or `*` for every +//! block. + +use crate::index::{at_lines, position}; +use crate::{Block, Kind}; + +/// What a selector matches. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Selector { + /// Every block, of whatever kind. It is written `*`. + Any, + /// Every block of one kind. + Kind(Kind), + /// A fence whose language is exactly this one. The empty string is a + /// fence with no language. + Fence(String), +} + +/// The word that selects every block. +const ANY: &str = "*"; + +impl Selector { + /// Reads a selector: `*`, a kind, or `fence` and a language. The + /// language `""`, written as two quote characters, is that of a fence + /// with none. Whitespace around the selector and extra spaces between + /// its two words are ignored. + /// + /// # Errors + /// Returns the message for a first word that is neither `*` nor a + /// kind, a language on anything other than `fence`, or more than one + /// language. + pub fn parse(given: &str) -> Result { + let mut words = given.split_whitespace(); + let word = words.next().unwrap_or_default(); + let kind = Kind::from_name(word); + if kind.is_none() && word != ANY { + let kinds = Kind::ALL.map(Kind::name).join(", "); + return Err(format!( + "unknown kind \"{word}\"; kinds are {kinds}, and {ANY} selects every block" + )); + } + match (kind, words.next(), words.next()) { + (None, None, _) => Ok(Selector::Any), + (Some(kind), None, _) => Ok(Selector::Kind(kind)), + (Some(Kind::Fence), Some(lang), None) => Ok(Selector::Fence( + if lang == "\"\"" { "" } else { lang }.to_owned(), + )), + (Some(Kind::Fence), Some(_), Some(_)) => Err(format!( + "a fence selector takes one language, got \"{given}\"" + )), + _ => Err(format!( + "only a fence selector takes a language, got \"{given}\"" + )), + } + } + + /// Whether the selector matches a block whose `kind` field is `kind` + /// and whose `lang` field, when it has one, is `lang`. + /// + /// `*` matches whatever `kind` holds. A kind compares by its name, so + /// a `kind` that names none matches no other selector. A language + /// compares exactly and case-sensitively, and a block with no `lang` + /// has no language to match. + #[must_use] + pub fn matches(&self, kind: &str, lang: Option<&str>) -> bool { + match self { + Selector::Any => true, + Selector::Kind(wanted) => wanted.name() == kind, + Selector::Fence(wanted) => kind == Kind::Fence.name() && lang == Some(wanted.as_str()), + } + } + + /// Whether the selector matches `block`. + #[must_use] + pub fn selects(&self, block: &Block) -> bool { + let lang = match block { + Block::Fence { lang, .. } => Some(lang.as_str()), + _ => None, + }; + self.matches(block.kind().name(), lang) + } +} + +/// Which of a selector's matches a lookup keeps. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Pick { + /// The match at this position, counted from 0. + Match(usize), + /// None: the block is not there. The message says what is missing. + Missing(String), +} + +/// Picks one of the blocks a selector matched in one section. +/// +/// `lines` holds the line of each match, in order, or `None` for a match +/// that records none. With `n`, the pick is the `n`th match. Without it, +/// the selector has to match exactly one block. `given` is the selector +/// as it was written, and `place` names the section in a miss. +/// +/// # Errors +/// Returns the message that asks for an index when there is no `n` and +/// the selector matched several blocks. That is a choice only the caller +/// can make, not an absence. +pub fn pick( + given: &str, + place: &str, + lines: &[Option], + n: Option, +) -> Result { + let given = given.trim(); + let count = lines.len(); + if count == 0 { + return Ok(Pick::Missing(format!( + "no block in {place} matches selector \"{given}\"" + ))); + } + match n { + Some(n) => Ok(match position(count, n) { + Some(at) => Pick::Match(at), + None => Pick::Missing(format!( + "selector \"{given}\" matches {count} block{} in {place}; there is no block {n}", + if count == 1 { "" } else { "s" } + )), + }), + None if count == 1 => Ok(Pick::Match(0)), + None => Err(format!( + "selector \"{given}\" matches {count} blocks{}; add an index, such as 1 for the first", + at_lines(lines) + )), + } +} + +#[cfg(test)] +mod tests { + use super::{Pick, Selector, pick}; + use crate::{Block, Kind}; + + #[test] + fn without_an_index_the_pick_is_the_only_match() { + assert_eq!( + pick("fence", "\"/G\"", &[Some(20)], None), + Ok(Pick::Match(0)) + ); + assert_eq!( + pick(" list ", "\"/Guide/Templates\"", &[], None), + Ok(Pick::Missing( + "no block in \"/Guide/Templates\" matches selector \"list\"".to_owned() + )) + ); + assert_eq!( + pick("fence", "\"/G\"", &[Some(20), Some(24), Some(30)], None), + Err( + "selector \"fence\" matches 3 blocks, at lines 20, 24, and 30; add an index, \ + such as 1 for the first" + .to_owned() + ) + ); + assert_eq!( + pick("prose", "\"/G\"", &[None, Some(4)], None), + Err( + "selector \"prose\" matches 2 blocks; add an index, such as 1 for the first" + .to_owned() + ) + ); + } + + #[test] + fn with_an_index_the_pick_is_that_match_or_a_miss() { + let lines = [Some(20), Some(24), Some(30)]; + assert_eq!(pick("fence", "\"/G\"", &lines, Some(3)), Ok(Pick::Match(2))); + assert_eq!( + pick("fence", "\"/G\"", &lines, Some(-1)), + Ok(Pick::Match(2)) + ); + assert_eq!( + pick("fence", "\"/G\"", &lines, Some(-3)), + Ok(Pick::Match(0)) + ); + assert_eq!( + pick("fence", "\"/Guide/Templates\"", &lines, Some(4)), + Ok(Pick::Missing( + "selector \"fence\" matches 3 blocks in \"/Guide/Templates\"; there is no \ + block 4" + .to_owned() + )) + ); + assert_eq!( + pick("fence", "section `Template`", &[Some(25)], Some(2)), + Ok(Pick::Missing( + "selector \"fence\" matches 1 block in section `Template`; there is no block 2" + .to_owned() + )) + ); + assert_eq!( + pick("list", "section `Template`", &[], Some(1)), + Ok(Pick::Missing( + "no block in section `Template` matches selector \"list\"".to_owned() + )) + ); + } + + fn fence(lang: &str) -> Block { + Block::Fence { + line: 1, + source: format!("```{lang}\n```\n"), + lang: lang.to_owned(), + info: lang.to_owned(), + text: String::new(), + } + } + + fn rule() -> Block { + Block::Rule { + line: 1, + source: "---\n".to_owned(), + } + } + + #[test] + fn a_kind_alone_is_a_selector() { + for kind in Kind::ALL { + assert_eq!(Selector::parse(kind.name()), Ok(Selector::Kind(kind))); + } + assert_eq!(Selector::parse(" list\t"), Ok(Selector::Kind(Kind::List))); + } + + #[test] + fn a_star_alone_selects_every_block() { + assert_eq!(Selector::parse("*"), Ok(Selector::Any)); + assert_eq!(Selector::parse(" *\t"), Ok(Selector::Any)); + assert!(Selector::Any.selects(&fence("lua"))); + assert!(Selector::Any.selects(&rule())); + // Whatever the `kind` field holds, a name of no kind included. + assert!(Selector::Any.matches("note", None)); + assert!(Selector::Any.matches("", None)); + for given in ["**", "*fence", "* *"] { + assert!(Selector::parse(given).is_err(), "{given:?}"); + } + } + + #[test] + fn a_fence_selector_may_name_one_language() { + assert_eq!( + Selector::parse("fence markdown"), + Ok(Selector::Fence("markdown".to_owned())) + ); + assert_eq!( + Selector::parse(" fence c++ "), + Ok(Selector::Fence("c++".to_owned())) + ); + assert_eq!( + Selector::parse("fence \"\""), + Ok(Selector::Fence(String::new())) + ); + } + + #[test] + fn a_first_word_that_is_no_kind_is_refused() { + for (given, word) in [ + ("fences", "fences"), + ("fences lua", "fences"), + ("Fence", "Fence"), + ("code", "code"), + ("", ""), + (" ", ""), + ] { + assert_eq!( + Selector::parse(given), + Err(format!( + "unknown kind \"{word}\"; kinds are frontmatter, section, prose, fence, list, \ + rule, and * selects every block" + )), + "{given:?}" + ); + } + } + + #[test] + fn a_language_on_any_other_kind_and_a_second_language_are_refused() { + assert_eq!( + Selector::parse("list lua"), + Err("only a fence selector takes a language, got \"list lua\"".to_owned()) + ); + assert_eq!( + Selector::parse("* lua"), + Err("only a fence selector takes a language, got \"* lua\"".to_owned()) + ); + assert_eq!( + Selector::parse("prose a b"), + Err("only a fence selector takes a language, got \"prose a b\"".to_owned()) + ); + assert_eq!( + Selector::parse("fence lua strict"), + Err("a fence selector takes one language, got \"fence lua strict\"".to_owned()) + ); + } + + #[test] + fn a_kind_matches_every_block_of_that_kind() { + let fences = Selector::Kind(Kind::Fence); + assert!(fences.selects(&fence("lua"))); + assert!(fences.selects(&fence(""))); + assert!(!fences.selects(&rule())); + assert!(Selector::Kind(Kind::Rule).selects(&rule())); + assert!(!Selector::Kind(Kind::Rule).matches("break", None)); + } + + #[test] + fn a_language_matches_exactly_and_the_empty_one_matches_a_fence_with_none() { + let lua = Selector::Fence("lua".to_owned()); + assert!(lua.selects(&fence("lua"))); + assert!(!lua.selects(&fence("Lua"))); + assert!(!lua.selects(&fence("luau"))); + assert!(!lua.selects(&fence(""))); + let unnamed = Selector::Fence(String::new()); + assert!(unnamed.selects(&fence(""))); + assert!(!unnamed.selects(&fence("lua"))); + // A block with no `lang` field has no language, not the empty one. + assert!(!unnamed.matches("fence", None)); + assert!(!lua.matches("prose", Some("lua"))); + } +} diff --git a/crates/runvil-internal/markdown/src/test_support.rs b/crates/runvil-internal/markdown/src/test_support.rs new file mode 100644 index 000000000..ab08b863b --- /dev/null +++ b/crates/runvil-internal/markdown/src/test_support.rs @@ -0,0 +1,372 @@ +//! Block constructors, sample texts, and seeded generated texts for this +//! crate's tests. + +use serde_json::Value as Json; + +use crate::Block; + +/// A frontmatter block on line 1, with no source. +pub(crate) fn frontmatter(data: Json) -> Block { + Block::Frontmatter { + line: 1, + source: String::new(), + data, + } +} + +/// A section block, with no source. +pub(crate) fn section(line: usize, level: u8, title: &str, blocks: Vec) -> Block { + Block::Section { + line, + source: String::new(), + level, + title: title.to_owned(), + blocks, + } +} + +/// A prose block, with no source. +pub(crate) fn prose(line: usize, text: &str) -> Block { + Block::Prose { + line, + source: String::new(), + text: text.to_owned(), + } +} + +/// A fence block whose language is the first word of `info`, with no +/// source. +pub(crate) fn fence(line: usize, info: &str, text: &str) -> Block { + Block::Fence { + line, + source: String::new(), + lang: info + .split_whitespace() + .next() + .unwrap_or_default() + .to_owned(), + info: info.to_owned(), + text: text.to_owned(), + } +} + +/// A bullet list block, with no source. +pub(crate) fn bullets(line: usize, items: &[&str]) -> Block { + Block::List { + line, + source: String::new(), + ordered: false, + items: items.iter().map(|item| (*item).to_owned()).collect(), + } +} + +/// A numbered list block, with no source. +pub(crate) fn numbered(line: usize, items: &[&str]) -> Block { + Block::List { + line, + source: String::new(), + ordered: true, + items: items.iter().map(|item| (*item).to_owned()).collect(), + } +} + +/// A rule block, with no source. +pub(crate) fn rule(line: usize) -> Block { + Block::Rule { + line, + source: String::new(), + } +} + +/// `block` with `source` as its source. +pub(crate) fn sourced(mut block: Block, source: &str) -> Block { + match &mut block { + Block::Frontmatter { source: own, .. } + | Block::Section { source: own, .. } + | Block::Prose { source: own, .. } + | Block::Fence { source: own, .. } + | Block::List { source: own, .. } + | Block::Rule { source: own, .. } => source.clone_into(own), + } + block +} + +/// `blocks` with every source emptied, for a test that states a parse by +/// its other fields, as the constructors above build them. +pub(crate) fn without_sources(blocks: &[Block]) -> Vec { + blocks + .iter() + .cloned() + .map(|block| match sourced(block, "") { + Block::Section { + line, + source, + level, + title, + blocks, + } => Block::Section { + line, + source, + level, + title, + blocks: without_sources(&blocks), + }, + leaf => leaf, + }) + .collect() +} + +/// Every block of `blocks` in file order, a section before the blocks +/// under it. +pub(crate) fn flat(blocks: &[Block]) -> Vec<&Block> { + let mut found = Vec::new(); + for block in blocks { + found.push(block); + if let Block::Section { blocks, .. } = block { + found.extend(flat(blocks)); + } + } + found +} + +/// The seed every generated text in this crate's tests starts from. A +/// failure names it. +pub(crate) const SEED: u64 = 0x5EED; + +/// A `SplitMix64` generator over a fixed seed, so a generated text is the +/// same on every run. +pub(crate) struct Seeded(u64); + +impl Seeded { + /// A generator that starts from `seed`. + pub(crate) fn new(seed: u64) -> Self { + Self(seed) + } + + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let z = (self.0 ^ (self.0 >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + let z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// A draw in `0..n`. + pub(crate) fn below(&mut self, n: usize) -> usize { + let draw = self.next_u64() % u64::try_from(n).expect("a usize fits in a u64"); + usize::try_from(draw).expect("a draw below a usize is a usize") + } + + /// A text of up to 24 lines drawn from [`LINES`], about one in three + /// of them blank. One text in eight opens with frontmatter, and one + /// in four has no final line ending. + pub(crate) fn text(&mut self) -> String { + let mut lines = Vec::new(); + if self.below(8) == 0 { + lines.extend(["---", "a: 1", "---"]); + } + for _ in 0..self.below(25) { + lines.push(if self.below(3) == 0 { + "" + } else { + LINES[self.below(LINES.len())] + }); + } + let mut text = lines.join("\n"); + if !lines.is_empty() && self.below(4) != 0 { + text.push('\n'); + } + text + } +} + +/// The lines a generated text is made of. Between them they open, +/// continue, and close every construct the parser tells apart, and many +/// read differently after different lines: `---` is a rule, a heading's +/// underline, or a frontmatter delimiter, and an indented line is code, +/// a paragraph's next line, or part of a list item. +const LINES: &[&str] = &[ + " ", + "\t", + "# One", + "## Two ##", + "### Three", + "###### Six", + "#", + " ## Indented", + "#NotHeading", + "## See [API](x) *now*", + "Title", + "=====", + "---", + "***", + "___", + " - - -", + "A paragraph line.", + "another line ", + "ends with a backslash\\", + "h\u{e9}llo \u{2014} w\u{f6}rld", + "\u{a0}", + "```", + "```lua", + "~~~", + "~~~~", + "````md", + " ``` py", + " indented code", + "- a", + "- b", + "* c", + "+ d", + "1. one", + "2. two", + "3) three", + "10. ten", + " - nested", + " continued", + "-", + "- [ ] todo", + "- # heading in an item", + "> quote", + "> # quoted heading", + "> - quoted item", + ">", + "
", + "
", + "", + "[ref]: http://example.com", + "| a | b |", + "|---|---|", + "a: b", + "key: [1, 2]", +]; + +/// A document with every kind of block: frontmatter, prose before the +/// first heading, three levels of section, both kinds of list, fences +/// with and without extra info words, and a rule. +pub(crate) const GUIDE: &str = r#"--- +title: Notes +tags: [a, b] +--- + +Preface paragraph. + +# Guide + +Intro text +on two lines. + +- alpha +- beta + +## Templates + +Use these. + +```lua +return 1 +``` + +```markdown +# Hello +``` + +Then the fallback: + +```json strict +{ "ok": true } +``` + +### Extras + +--- + +```lua +return 2 +``` + +## Usage + +1. first +2. second +"#; + +/// Texts that between them hold every construct the parser tells apart, +/// in the arrangements that are easy to get wrong. None has a `\r`, and +/// none opens with frontmatter. +pub(crate) const BODIES: &[&str] = &[ + "", + "\n\n \n", + "one paragraph", + "first\n\nsecond\n", + "Title\n=====\n\ntext\n\nSub\n---\n\nmore\n", + "Line one\nline two\n===\n\nbody\n", + "## The *big* `foo` [link](http://x) API ##\n\ntext\n", + "##\n\ntext\n\n# \n\nmore\n", + "> # Quoted\n\n- # Item heading\n- two\n\n# Real\n", + "## A\n\n#### B\n\nb\n\n### C\n\n# D\n\n###### E\n", + "### A\n\na\n\n# B\n\nb\n", + "text\n- a\n- b\n", + "text\n```\ncode\n```\nafter\n", + "- a\n\n- b\n\n- c\n", + "- first line\n second line\n\n second paragraph\n- next\n", + "- a\n - a1\n - a2\n- b\n 1. b1\n", + "1. step\n\n ```sh\n run\n ```\n2. done\n", + "-\n- b\n-\n later\n", + "5) five\n6) six\n", + "- a\n- b\n\n* c\n* d\n\n1. e\n\n1) f\n", + " - a\n - b\n", + "- a\n\tcontinued\n- b\n", + "- [ ] todo\n- [x] done\n", + " code line\n\ntext\n\n
\nraw\n
\n\n- x\n", + "```\na\n```\n\n[ref]: http://example.com\n\n---\n", + "> - a\n> - b\n>\n> ```\n> code\n> ```\n\nafter\n", + "~~~\nplain\n~~~\n\n```\nbare\n```\n\n``` rust ignore \nfn x() {}\n```\n", + "# A\n\n```lua\nreturn 1\n\n# not a heading\n", + " ```py\n x = 1\n y = 2\n ```\n", + "```json\n```\n\n```\n\n```\n", + "````md\n```lua\nx\n```\n````\n", + "```\na\n\n```\n", + "***\n\n___\n\n- - -\n\ntext\n", + "text\n---\n\nafter\n", + "# H\n```\ncode\n```\n", + "\n\ntext\n", + "+ a\n+ b\n", + "- a\n\n\n\n- b\n", + "| a | b |\n|---|---|\n| 1 | 2 |\n", + "- ---\n- x\n", + "text \nmore\\\nend\n", + "1. a\n\n b\n\n2. c\n", + "- a\n\n ```\n unclosed in item\n\ntext after\n", + "## A\n## B\n", + "#NotHeading\n\n#\tTab heading\n", + "```\n```\n```\n", + " indented code only\n", + "[ref]: http://x\n", + "- Title\n ===\n", + "***bold***\n\n* * *\n", + "1. a\n1. b\n1. c\n", + "- a\n- b\n\n\n- c\n", + "\\# not heading\n", + " # indented heading\n", + " # five spaces\n", + "```lua\nreturn 1\n```\ntrailing text\n", + "text\n\n---\n\n- a\n\n---\n", + "- a\n\n code in item\n- b\n", + "> quote\n\n- a\n\n> quote\n", + "1. one\n- bullet\n2. two\n", + "- a\n\n\n\ntext\n", + "## Title ##\n\n### C#\n\n#### A #\n", + "- [link]\n\n[link]: http://x\n", + "text\n```\nfence\n```\n- list\n***\n# H\n", + "- a\n - odd indent\n - b\n", + "10. ten\n11. eleven\n", + "-\n item after blank\n- \n", + "```\n\n\n```\n", + "~~~~\n~~~\n~~~~\n", + "
\n\n# inside html?\n\n
\n", + "~~~ a`b\nx\n~~~\n", + "1. a\n2. b\n3. c\n4. d\n5. e\n6. f\n7. g\n8. h\n9. i\n10. j\n more\n", + "# A\n## A\n### B\n## B\n", + "# Log\n\n## Entry\n\nfirst\n\n## Entry\n\nsecond\n", +]; diff --git a/crates/runvil-internal/prompt/src/body_text.rs b/crates/runvil-internal/prompt/src/body_text.rs index d9722c6f7..09257c105 100644 --- a/crates/runvil-internal/prompt/src/body_text.rs +++ b/crates/runvil-internal/prompt/src/body_text.rs @@ -1,7 +1,7 @@ //! Defines [`BodyText`], a stretch of prompt text that sections and blocks read in place. //! //! The parser makes one shared copy of the prompt body, and every section's -//! Markdown, list item, and prose block points into it. +//! Markdown and prose block points into it. use std::fmt; use std::ops::Range; @@ -21,6 +21,14 @@ impl BodyText { &self.text[self.range.clone()] } + /// The shared copy this stretch reads from, and the bytes of it the + /// stretch covers: what a holder keeps to read the text later without + /// copying it. + #[must_use] + pub fn shared(&self) -> (Arc, Range) { + (Arc::clone(&self.text), self.range.clone()) + } + /// Locates `part` inside `text` by its byte offset. /// /// Returns `None` when `part` is not a subslice of `text`. diff --git a/crates/runvil-internal/prompt/src/error-tests.rs b/crates/runvil-internal/prompt/src/error-tests.rs index 0952ec861..736029855 100644 --- a/crates/runvil-internal/prompt/src/error-tests.rs +++ b/crates/runvil-internal/prompt/src/error-tests.rs @@ -232,21 +232,6 @@ fn a_fence_violation_names_the_rule_it_breaks() { } } -#[test] -fn an_empty_list_item_is_list_empty_item_at_its_marker() { - let error = failure_of(&in_section("- alpha\n 1.\n- beta\n")); - let message = "empty bullet item in list section `S`"; - assert_eq!(error.code, ErrorCode::ListEmptyItem); - assert_eq!(error.message, message); - assert_eq!( - location(&error), - Some((FILE, 11, 3, 11, 5)), - "the marker itself, past its indent" - ); - assert_eq!(error.facts, BTreeMap::from([(ErrorFact::Name, "x".into())])); - assert_eq!(error.prose, prose_at(ErrorCode::ListEmptyItem, 11, message)); -} - #[test] fn a_lua_compile_failure_is_located_at_the_line_its_diagnostic_names() { // The block's source starts on file line 11, and the compiler reports diff --git a/crates/runvil-internal/prompt/src/lib.rs b/crates/runvil-internal/prompt/src/lib.rs index 744587346..894e2223f 100644 --- a/crates/runvil-internal/prompt/src/lib.rs +++ b/crates/runvil-internal/prompt/src/lib.rs @@ -32,7 +32,6 @@ mod error; mod fence; mod finder; mod frontmatter; -mod list; mod parse; mod position; mod prompt; diff --git a/crates/runvil-internal/prompt/src/list.rs b/crates/runvil-internal/prompt/src/list.rs deleted file mode 100644 index 394b2297a..000000000 --- a/crates/runvil-internal/prompt/src/list.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! Detects list-only sections and extracts their bullet items. - -use runvil_types::error::Error; - -use crate::Result; - -/// Returns the items of a list section's prose, or `None` when the prose is -/// not a list. Prose is a list when every nonblank line is a bullet marker -/// and at least one is, so one incidental bullet in ordinary prose leaves it -/// prose. -/// -/// # Errors -/// Returns the failure `make_empty_item_error` builds from the first marker with no -/// content, trimmed and sliced from `prose`, when the prose is a list. -pub(crate) fn bullet_items<'a>( - prose: &'a str, - make_empty_item_error: impl FnOnce(&'a str) -> Error, -) -> Result>> { - let mut items = Vec::new(); - let mut empty_marker = None; - for line in prose.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - match classify_list_line(trimmed) { - ListLine::Item(content) => items.push(content), - ListLine::EmptyMarker => empty_marker = empty_marker.or(Some(trimmed)), - ListLine::NotAMarker => return Ok(None), - } - } - match empty_marker { - Some(marker) => Err(make_empty_item_error(marker)), - None => Ok((!items.is_empty()).then_some(items)), - } -} - -/// The classification of a single nonblank prose line as a list marker. -enum ListLine<'a> { - /// A well-formed marker with nonblank content (the returned item text). - Item(&'a str), - /// A marker with no content: `-`, `*`, `1.`, `1. `, `1)`. - EmptyMarker, - /// Not a list marker at all (ordinary prose). - NotAMarker, -} - -/// Classifies one already-trimmed, nonblank line as a list marker. -fn classify_list_line(trimmed: &str) -> ListLine<'_> { - // Unordered: `- item` / `* item`, or a bare `-` / `*`. - if let Some(rest) = trimmed - .strip_prefix("- ") - .or_else(|| trimmed.strip_prefix("* ")) - { - return if rest.trim().is_empty() { - ListLine::EmptyMarker - } else { - ListLine::Item(rest) - }; - } - if trimmed == "-" || trimmed == "*" { - return ListLine::EmptyMarker; - } - - // Ordered: `N. item` / `N) item`, or a bare/empty `N.` / `N. ` / `N)`. - let digits = trimmed - .as_bytes() - .iter() - .take_while(|byte| byte.is_ascii_digit()) - .count(); - if digits == 0 { - return ListLine::NotAMarker; - } - let after_digits = &trimmed[digits..]; - let Some(after_punct) = after_digits - .strip_prefix('.') - .or_else(|| after_digits.strip_prefix(')')) - else { - return ListLine::NotAMarker; - }; - if after_punct.is_empty() { - // `1.` / `1)` - a bare marker with no separating space or content. - return ListLine::EmptyMarker; - } - // A valid ordered item requires a space after the marker punctuation, so - // `1.foo` is prose, not a list item. - if let Some(content) = after_punct.strip_prefix(' ') { - return if content.trim().is_empty() { - ListLine::EmptyMarker - } else { - ListLine::Item(content) - }; - } - ListLine::NotAMarker -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_and_bare_markers_are_classified() { - assert!(matches!( - classify_list_line("- item"), - ListLine::Item("item") - )); - assert!(matches!( - classify_list_line("1. item"), - ListLine::Item("item") - )); - assert!(matches!(classify_list_line("-"), ListLine::EmptyMarker)); - assert!(matches!(classify_list_line("1."), ListLine::EmptyMarker)); - assert!(matches!(classify_list_line("1. "), ListLine::EmptyMarker)); - assert!(matches!(classify_list_line("1)"), ListLine::EmptyMarker)); - assert!(matches!(classify_list_line("1.foo"), ListLine::NotAMarker)); - assert!(matches!( - classify_list_line("plain prose"), - ListLine::NotAMarker - )); - } - - #[test] - fn all_markers_predicate() { - fn items(prose: &str) -> Option> { - bullet_items(prose, |marker| panic!("no empty marker, got {marker:?}")) - .expect("a prose with no empty marker is never refused") - } - assert_eq!(items("- a\n1. b"), Some(vec!["a", "b"])); - assert_eq!(items("- a\nplain line"), None); - assert_eq!(items(" \n "), None); - } -} diff --git a/crates/runvil-internal/prompt/src/parse.rs b/crates/runvil-internal/prompt/src/parse.rs index 7df81da9f..448302fac 100644 --- a/crates/runvil-internal/prompt/src/parse.rs +++ b/crates/runvil-internal/prompt/src/parse.rs @@ -47,8 +47,6 @@ impl Prompt { /// fact: `label`, `opener`, `closer`, or `lookalike`. Lines inside the /// body of another fenced code block are never inspected, so a /// document can show a Lua fence as an example. - /// - `ListEmptyItem` when a section made up only of list items has an - /// empty item. /// - `LuaCompile` when a Lua block in the H1 or in a section fails to /// compile as Lua. /// - `Internal` when the parser fails through no fault of the prompt: diff --git a/crates/runvil-internal/prompt/src/prompt.rs b/crates/runvil-internal/prompt/src/prompt.rs index 67fba9756..0cc0cd698 100644 --- a/crates/runvil-internal/prompt/src/prompt.rs +++ b/crates/runvil-internal/prompt/src/prompt.rs @@ -43,11 +43,10 @@ pub struct Section { pub(crate) blocks: Vec, /// Child sections nested under this one (deeper heading levels). pub(crate) children: Vec
, - /// The list items of a list section, which has at least one; empty for - /// every other section. - pub(crate) items: Vec, /// The section's own raw Markdown, without its heading line or children. pub(crate) markdown: BodyText, + /// The 1-based line of the prompt file that `markdown` starts on. + pub(crate) markdown_line: NonZeroU32, } impl Section { @@ -75,12 +74,6 @@ impl Section { &self.children } - /// Returns the list items of a list section; empty for every other section. - #[must_use] - pub fn items(&self) -> &[BodyText] { - &self.items - } - /// The section's own raw Markdown: from just after its heading line to the next /// heading of any level, leading blank lines and trailing whitespace trimmed. #[must_use] @@ -88,6 +81,20 @@ impl Section { self.markdown.as_str() } + /// The section's own raw Markdown as a stretch of the shared prompt + /// text, which a reader takes without copying it. + #[must_use] + pub fn markdown_text(&self) -> &BodyText { + &self.markdown + } + + /// The 1-based line of the prompt file that the section's own raw + /// Markdown starts on. + #[must_use] + pub fn markdown_line(&self) -> NonZeroU32 { + self.markdown_line + } + /// The prologue: the first block, when it is Lua. #[cfg(any(test, feature = "test-support"))] #[must_use] @@ -122,12 +129,6 @@ impl Section { _ => None, } } - - /// Returns whether this is a list section, which has at least one item. - #[must_use] - pub fn is_list(&self) -> bool { - !self.items.is_empty() - } } /// A fully parsed prompt file. diff --git a/crates/runvil-internal/prompt/src/sections.rs b/crates/runvil-internal/prompt/src/sections.rs index 02ee0a588..6f7b8e2c5 100644 --- a/crates/runvil-internal/prompt/src/sections.rs +++ b/crates/runvil-internal/prompt/src/sections.rs @@ -8,7 +8,7 @@ use runvil_types::error::{Error, ErrorCode}; use crate::fence::{self, LuaRole, RawBlock}; use crate::position::{body_text, file_line_of, line_add, newlines_before, nz_source_line}; -use crate::{Block, BodyText, Result, Section, error, list}; +use crate::{Block, Result, Section, error}; use runvil_lua::LuaProgram; /// A heading with its title and the prose/Lua that follows it (before the next @@ -141,12 +141,12 @@ impl<'a> SectionWalker<'a> { /// /// Each section's checks run in a fixed order that decides which error a /// malformed prompt reports: its level, its name, its blocks, its children, - /// its list items, and last its name against earlier siblings. + /// and last its name against earlier siblings. /// /// # Errors /// Returns a `StructureInvalid` failure at the heading for an orphan /// heading, an empty heading, or duplicate sibling names, and the - /// failure for malformed section content. Propagates a Lua compile + /// failure for a malformed `lua` fence. Propagates a Lua compile /// failure, and an `Internal` failure from invalid or overflowing /// source-line calculations, from a fence split that disagrees with the /// Markdown parser, or from section text that is not a slice of @@ -170,20 +170,17 @@ impl<'a> SectionWalker<'a> { let blocks = self.build_heading_blocks(h, &name, &location)?; self.pos += 1; let children = self.build_sections(level)?; - let items = self.list_items(&blocks, &name)?; self.check_unique_sibling(&sibling_lines, &name, h, heading_abs_line)?; sibling_lines.push((name.clone(), heading_abs_line)); + let markdown = fence::trim_leading_blank_lines(h.content).trim_end(); result.push(Section { name, level, blocks, children, - items, - markdown: body_text( - self.shared, - fence::trim_leading_blank_lines(h.content).trim_end(), - )?, + markdown: body_text(self.shared, markdown)?, + markdown_line: file_line_of(self.shared, markdown, self.frontmatter_lines)?, }); } Ok(result) @@ -320,56 +317,6 @@ impl<'a> SectionWalker<'a> { Ok(blocks) } - /// Returns the section's list items, or none when it is not a list. - /// - /// A section is a list when it has no Lua and [`list::bullet_items`] returns - /// items. - /// - /// # Errors - /// Returns a `ListEmptyItem` failure at the first marker with no content. - fn list_items(&self, blocks: &[Block], name: &str) -> Result> { - let has_no_lua = blocks - .iter() - .all(|block| matches!(block, Block::Prose { .. })); - let prose_for_items = blocks.iter().find_map(|block| match block { - Block::Prose { text, .. } => Some(text.as_str()), - Block::Lua(_) => None, - }); - if has_no_lua - && let Some(prose) = prose_for_items - && let Some(items) = - list::bullet_items(prose, |marker| self.make_empty_item_error(marker, name))? - { - items - .into_iter() - .map(|item| body_text(self.shared, item)) - .collect() - } else { - Ok(Vec::new()) - } - } - - /// Builds the failure for a list item with no content at `marker`, its - /// marker sliced from `shared`, or with no location when `marker` is not - /// a slice of it. - fn make_empty_item_error(&self, marker: &str, name: &str) -> Error { - let message = format!("empty bullet item in list section `{name}`"); - match marker - .as_ptr() - .addr() - .checked_sub(self.shared.as_ptr().addr()) - { - Some(start) => error::make_failure_at_error( - ErrorCode::ListEmptyItem, - message, - self.shared, - start..start + marker.len(), - self.frontmatter_lines, - ), - None => error::make_failure_error(ErrorCode::ListEmptyItem, message), - } - } - /// Names a section in its Lua block locations. /// /// The name is the title alone, or the title and its heading line when diff --git a/crates/runvil-internal/prompt/src/test_support.rs b/crates/runvil-internal/prompt/src/test_support.rs index 7bd2e0135..057a9fe56 100644 --- a/crates/runvil-internal/prompt/src/test_support.rs +++ b/crates/runvil-internal/prompt/src/test_support.rs @@ -24,8 +24,8 @@ pub fn synthetic_section(name: &str, level: u8, blocks: Vec) -> Section { level, blocks, children: Vec::new(), - items: Vec::new(), markdown: BodyText::from_owned(String::new()), + markdown_line: NonZeroU32::MIN, } } diff --git a/crates/runvil-internal/prompt/src/tests/breaks.rs b/crates/runvil-internal/prompt/src/tests/breaks.rs index d4ce3ef5f..a1670bb4c 100644 --- a/crates/runvil-internal/prompt/src/tests/breaks.rs +++ b/crates/runvil-internal/prompt/src/tests/breaks.rs @@ -56,25 +56,6 @@ fn multiple_breaks_each_reset_the_pending_buffer() { assert_eq!(section.prose(), "Final prose."); } -#[test] -fn list_items_below_a_leading_break_parse() { - let body = "---\n\n- alpha\n- beta"; - let prompt = parse_ok(&PromptSource::new().prose("Items", body).build()); - let section = &prompt.sections[0]; - assert!(section.is_list()); - assert_eq!(section.items(), ["alpha", "beta"]); -} - -#[test] -fn a_break_resets_list_item_capture() { - // List items parse from the pending buffer: markers above a break are - // commentary, and only the markers below the last break parse. - let body = "- alpha\n\n---\n\n- beta"; - let prompt = parse_ok(&PromptSource::new().prose("Items", body).build()); - let section = &prompt.sections[0]; - assert_eq!(section.items(), ["beta"]); -} - #[test] fn h1_break_resets_prose_and_lua_fence_below_stays_live() { // The H1 follows the same reset rule: Markdown above the break is diff --git a/crates/runvil-internal/prompt/src/tests/headings.rs b/crates/runvil-internal/prompt/src/tests/headings.rs index 4cb1195dd..fdc263569 100644 --- a/crates/runvil-internal/prompt/src/tests/headings.rs +++ b/crates/runvil-internal/prompt/src/tests/headings.rs @@ -152,6 +152,12 @@ fn section_markdown_is_the_sections_own_content() { ); assert_eq!(section.children[0].markdown(), "child text"); assert_eq!(prompt.sections[1].markdown(), ""); + // The Markdown starts on a line of the file, after the frontmatter, + // the heading, and the blank lines under it. + assert_eq!(section.markdown_line().get(), 11); + assert_eq!(section.children[0].markdown_line().get(), 25); + let (shared, range) = section.markdown_text().shared(); + assert_eq!(&shared[range], own, "the stretch reads in place"); } #[test] diff --git a/crates/runvil-internal/prompt/src/tests/lists.rs b/crates/runvil-internal/prompt/src/tests/lists.rs index 873b60d04..ff5a1befa 100644 --- a/crates/runvil-internal/prompt/src/tests/lists.rs +++ b/crates/runvil-internal/prompt/src/tests/lists.rs @@ -1,118 +1,32 @@ -//! List section tests: list detection and the bullet parser. +//! A list in a section: the parser gives it no meaning of its own. use super::*; -use crate::list::bullet_items; #[test] -fn mixed_prose_with_one_bullet_is_not_a_list() { - // An incidental bullet line in ordinary prose must not force strict - // list parsing; the section stays prose. - let body = "Here is context.\n- one incidental bullet\nMore prose follows."; - let prompt = parse_ok(&PromptSource::new().prose("S", body).build()); - let section = &prompt.sections[0]; - assert!(!section.is_list(), "mixed prose is not a list"); - assert!(section.items().is_empty()); - assert!(section.prose().contains("incidental bullet")); -} - -#[test] -fn pure_list_section_parses_items() { +fn a_section_that_holds_only_a_list_is_an_ordinary_section() { let body = "- alpha\n- beta\n3. gamma"; let prompt = parse_ok(&PromptSource::new().prose("S", body).build()); let section = &prompt.sections[0]; - assert!(section.is_list()); - assert_eq!(section.items(), ["alpha", "beta", "gamma"]); -} - -#[test] -fn all_marker_list_with_empty_item_is_rejected() { - // Every nonblank line is a marker, so it is a list; the empty marker is - // then a hard error rather than a detector miss. - let body = "- alpha\n1.\n- beta"; - let error = parse_err(&PromptSource::new().prose("S", body).build()); - assert_eq!(error.code, ErrorCode::ListEmptyItem); + assert_eq!(section.blocks().len(), 1, "the list is one prose block"); + assert_eq!(section.prose(), body); + assert_eq!(section.markdown(), body); } #[test] -fn an_empty_marker_before_a_prose_line_stays_prose() { - let prompt = parse_ok(&PromptSource::new().prose("S", "- alpha\n-\nmore").build()); - let section = &prompt.sections[0]; - assert!(section.items().is_empty()); - assert_eq!(section.prose(), "- alpha\n-\nmore"); -} - -#[test] -fn list_error_kind_does_not_depend_on_the_section_name() { - for section in ["frontmatter", "fence"] { - let error = parse_err(&PromptSource::new().prose(section, "- alpha\n1.").build()); - assert_eq!(error.code, ErrorCode::ListEmptyItem); +fn a_bullet_with_no_content_parses() { + for body in ["- alpha\n1.\n- beta", "-", "- alpha\n-\n- gamma"] { + let prompt = parse_ok(&PromptSource::new().prose("S", body).build()); + assert_eq!(prompt.sections[0].markdown(), body); } } -/// The items of `prose`, which has no empty marker. -#[track_caller] -fn items(prose: &str) -> Option> { - bullet_items(prose, |marker| panic!("no empty marker, got {marker:?}")) - .expect("a prose with no empty marker is never refused") -} - -#[test] -fn bullet_parser_strips_unordered_markers() { - assert_eq!( - items("- alpha\n* beta\n- gamma"), - Some(vec!["alpha", "beta", "gamma"]) - ); -} - -#[test] -fn bullet_parser_strips_ordered_markers() { - assert_eq!( - items("1. first\n2. second\n3) third"), - Some(vec!["first", "second", "third"]) - ); -} - -#[test] -fn bullet_parser_ignores_blank_lines() { - assert_eq!( - items("- alpha\n\n- beta\n \n- gamma"), - Some(vec!["alpha", "beta", "gamma"]) - ); -} - -#[test] -fn bullet_parser_fails_with_the_failure_built_from_the_first_empty_marker() { - let prose = "- alpha\n - \n1.\n- gamma"; - let mut seen = None; - let error = bullet_items(prose, |marker| { - seen = Some(marker); - Error::new(ErrorCode::ListEmptyItem, "empty item", "empty item") - }) - .expect_err("an empty item must be refused"); - assert_eq!(error.code, ErrorCode::ListEmptyItem, "the caller's failure"); - assert_eq!(seen, Some("-")); - assert_eq!( - seen.map(str::as_ptr), - Some(prose[10..].as_ptr()), - "the trimmed marker of the first empty item, sliced from the prose" - ); -} - -#[test] -fn list_h3_parses_items_at_load_time() { - let src = PromptSource::new().prose("Parent", "p"); - let p = parse_ok(&src.prose("### Items", "- alpha\n- beta").build()); - let items_section = &p.sections[0].children[0]; - assert_eq!(items_section.name, "Items"); - assert_eq!(items_section.items, vec!["alpha", "beta"]); -} - #[test] -fn non_list_h3_has_empty_items() { - let worker = "```lua\nreturn item\n```\n\nDo work on {{ item }}."; +fn a_list_beside_a_lua_fence_stays_in_the_prose_before_it() { + let worker = "- one\n- two\n\n```lua\nreturn item\n```"; let src = PromptSource::new().prose("Parent", "p"); - let p = parse_ok(&src.prose("### Worker", worker).build()); - let worker = &p.sections[0].children[0]; + let prompt = parse_ok(&src.prose("### Worker", worker).build()); + let worker = &prompt.sections[0].children[0]; assert_eq!(worker.name, "Worker"); - assert!(worker.items.is_empty()); + assert_eq!(worker.blocks().len(), 2); + assert_eq!(worker.prose(), "- one\n- two"); } diff --git a/crates/runvil-internal/runtime/Cargo.toml b/crates/runvil-internal/runtime/Cargo.toml index e27630776..6799ef57a 100644 --- a/crates/runvil-internal/runtime/Cargo.toml +++ b/crates/runvil-internal/runtime/Cargo.toml @@ -9,6 +9,7 @@ publish = false [dependencies] runvil-lua.workspace = true +runvil-markdown.workspace = true runvil-model-client.workspace = true runvil-prompt.workspace = true runvil-types.workspace = true diff --git a/crates/runvil-internal/runtime/src/execute/run/tests-drops.rs b/crates/runvil-internal/runtime/src/execute/run/tests-drops.rs index 0e5029a36..1030ea779 100644 --- a/crates/runvil-internal/runtime/src/execute/run/tests-drops.rs +++ b/crates/runvil-internal/runtime/src/execute/run/tests-drops.rs @@ -232,7 +232,7 @@ fn timed_run(main: &str, cancel: &CancelHandle) -> Run { /// child untimed and reports both. fn caught_wait(wait: &str) -> String { format!( - "local t = tasks.spawn('## Child')\n\ + "local t = tasks.spawn('../Child')\n\ local ok, err = pcall({wait}, {{ t }}, {{ timeout = 30 }})\n\ local _t, _ok, result = tasks.join_any({{ t }})\n\ return tostring(ok) .. ':' .. err.kind .. '|' .. result" @@ -299,7 +299,7 @@ fn an_uncaught_dropped_timer_ends_the_run_canceled() { for wait in ["tasks.join_any", "tasks.join"] { let mut run = timed_run( &format!( - "local t = tasks.spawn('## Child')\n\ + "local t = tasks.spawn('../Child')\n\ {wait}({{ t }}, {{ timeout = 30 }})\n\ return 'waited'" ), diff --git a/crates/runvil-internal/runtime/src/execute/run/tests-inbox.rs b/crates/runvil-internal/runtime/src/execute/run/tests-inbox.rs index 3be84f894..35b8f50e1 100644 --- a/crates/runvil-internal/runtime/src/execute/run/tests-inbox.rs +++ b/crates/runvil-internal/runtime/src/execute/run/tests-inbox.rs @@ -202,7 +202,7 @@ fn waiting_for_input_is_true_exactly_while_the_main_chain_waits_on_its_inbox() { let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ store.exists('busy')\n\ local text = tasks.receive()\n\ tasks.cancel(t)\n\ @@ -300,7 +300,7 @@ fn a_run_that_cannot_start_hands_back_the_messages_delivered_before_it() { #[test] fn a_receive_in_a_call_child_reads_the_main_chains_inbox() { let mut run = run_of_sections(&[ - ("Main", "return 'main got ' .. call('## Reader')"), + ("Main", "return 'main got ' .. call('../Reader')"), ("Reader", "return tasks.receive()"), ]); let (effects, _, waiting) = pending(&mut run); @@ -377,8 +377,8 @@ fn a_task_waiting_on_its_inbox_reads_blocked_inbox_and_gives_back_its_slot() { ( "Main", "tasks.concurrency(1)\n\ - local waiter = tasks.spawn('## Waiter')\n\ - local worker = tasks.spawn('## Worker')\n\ + local waiter = tasks.spawn('../Waiter')\n\ + local worker = tasks.spawn('../Worker')\n\ local _, ok, result = tasks.join_any({ worker })\n\ local status = tasks.status(waiter)\n\ tasks.cancel(waiter)\n\ @@ -399,7 +399,7 @@ fn a_task_reads_its_own_inbox_and_leaves_the_main_chains_message_queued() { let mut run = run_of_sections(&[ ( "Main", - "local waiter = tasks.spawn('## Waiter')\n\ + "local waiter = tasks.spawn('../Waiter')\n\ store.exists('busy')\n\ local status = tasks.status(waiter)\n\ local text = tasks.receive()\n\ diff --git a/crates/runvil-internal/runtime/src/execute/run/tests-reply.rs b/crates/runvil-internal/runtime/src/execute/run/tests-reply.rs index c6ca6bdd1..6a7ff3d1e 100644 --- a/crates/runvil-internal/runtime/src/execute/run/tests-reply.rs +++ b/crates/runvil-internal/runtime/src/execute/run/tests-reply.rs @@ -64,10 +64,10 @@ fn a_wait_on_a_task_that_replied_returns_the_reply_and_the_task_parks_on_its_inb let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ local _, ok, reply = tasks.join_any({ t })\n\ tasks.cancel(t)\n\ - local u = tasks.spawn('## Worker')\n\ + local u = tasks.spawn('../Worker')\n\ local joined = tasks.join({ u })[1]\n\ tasks.cancel(u)\n\ return tostring(ok) .. ' ' .. reply .. ' ' .. tostring(joined.ok) .. ' ' .. joined.result", @@ -94,7 +94,7 @@ fn after_a_send_a_second_wait_returns_the_tasks_next_reply() { let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ local _, _, first = tasks.join_any({ t })\n\ tasks.send(t, 'shorten it')\n\ local _, _, second = tasks.join_any({ t })\n\ @@ -121,7 +121,7 @@ fn a_wait_on_an_idle_task_whose_reply_was_taken_with_no_message_since_raises_tas let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ tasks.join_any({ t })\n\ local state = tasks.status(t).state\n\ local ok, err = pcall(tasks.join_any, { t })\n\ @@ -143,7 +143,7 @@ fn a_reply_that_finds_a_message_queued_returns_it_at_once_and_the_next_wait_wait let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ tasks.send(t, 'early')\n\ local _, _, first = tasks.join_any({ t })\n\ local _, _, second = tasks.join_any({ t })\n\ @@ -167,7 +167,7 @@ fn an_idle_tasks_status_reads_running_blocked_on_its_inbox_with_no_ok_in_its_sec let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ tasks.join_any({ t })\n\ local status = tasks.status(t)\n\ tasks.cancel(t)\n\ @@ -189,8 +189,8 @@ fn an_idle_task_frees_its_slot_so_a_queued_spawn_starts() { ( "Main", "tasks.concurrency(1)\n\ - local replier = tasks.spawn('## Replier')\n\ - local worker = tasks.spawn('## Worker')\n\ + local replier = tasks.spawn('../Replier')\n\ + local worker = tasks.spawn('../Worker')\n\ local _, _, ready = tasks.join_any({ replier })\n\ local _, _, worked = tasks.join_any({ worker })\n\ tasks.send(replier, 'more')\n\ @@ -212,15 +212,15 @@ fn an_owner_that_ends_with_an_idle_task_keeps_its_own_result_and_abandons_the_ta let mut run = run_of_sections(&[ ( "Main", - "local owner = tasks.spawn('## Owner')\n\ + "local owner = tasks.spawn('../Owner')\n\ local _, ok, result = tasks.join_any({ owner })\n\ - local t = tasks.spawn('## Worker')\n\ + local t = tasks.spawn('../Worker')\n\ tasks.join_any({ t })\n\ return tostring(ok) .. ' ' .. result", ), ( "Owner", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ tasks.join_any({ t })\n\ return 'owner result'", ), @@ -256,8 +256,8 @@ fn an_owner_that_ends_with_a_running_task_beside_an_idle_one_names_only_the_runn let mut run = run_of_sections(&[ ( "Main", - "local idle = tasks.spawn('## Replier')\n\ - local running = tasks.spawn('## Receiver')\n\ + "local idle = tasks.spawn('../Replier')\n\ + local running = tasks.spawn('../Receiver')\n\ tasks.join_any({ idle })\n\ return 'leaked'", ), @@ -291,7 +291,7 @@ fn canceling_an_idle_task_ends_it_canceled() { let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ tasks.join_any({ t })\n\ tasks.cancel(t)\n\ local status = tasks.status(t)\n\ @@ -323,7 +323,7 @@ fn a_reply_on_the_main_chain_or_its_call_child_raises_a_lua_error() { ( "Main", "local ok, err = pcall(tasks.reply, 'x')\n\ - return tostring(ok) .. ' ' .. err.kind .. '|' .. call('## Helper') .. '|' .. tostring(err)", + return tostring(ok) .. ' ' .. err.kind .. '|' .. call('../Helper') .. '|' .. tostring(err)", ), ( "Helper", @@ -344,7 +344,7 @@ fn a_reply_in_a_call_child_of_a_task_replies_for_that_task() { let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ local _, _, reply = tasks.join_any({ t })\n\ local status = tasks.status(t)\n\ tasks.send(t, 'next')\n\ @@ -352,7 +352,7 @@ fn a_reply_in_a_call_child_of_a_task_replies_for_that_task() { return reply .. '|' .. status.state .. ' ' .. tostring(status.blocked) .. ' ' \ .. tostring(status.ok) .. ' ' .. tostring(status.section) .. '|' .. result", ), - ("Worker", "return 'worker returned ' .. call('## Helper')"), + ("Worker", "return 'worker returned ' .. call('../Helper')"), ( "Helper", "return 'helper got ' .. tasks.reply('from the helper')", @@ -377,9 +377,9 @@ fn past_the_idle_cap_a_reply_closes_the_task_idle_longest_as_done_with_its_reply &[ ( "Main", - "local first = tasks.spawn('## First')\n\ + "local first = tasks.spawn('../First')\n\ local _, _, one = tasks.join_any({ first })\n\ - local second = tasks.spawn('## Second')\n\ + local second = tasks.spawn('../Second')\n\ local _, _, two = tasks.join_any({ second })\n\ local status = tasks.status(first)\n\ local sent = tasks.send(first, 'too late')\n\ @@ -428,8 +428,8 @@ fn a_wait_on_a_task_the_cap_closed_delivers_its_undelivered_reply_once() { &[ ( "Main", - "local first = tasks.spawn('## First')\n\ - local second = tasks.spawn('## Second')\n\ + "local first = tasks.spawn('../First')\n\ + local second = tasks.spawn('../Second')\n\ local _, _, two = tasks.join_any({ second })\n\ local closed = tasks.status(first).state\n\ local ready = tasks.ready(first)\n\ @@ -476,11 +476,11 @@ fn past_the_idle_cap_a_reply_passes_over_the_task_idle_longest_when_it_owns_a_li &[ ( "Main", - "local owner = tasks.spawn('## Owner')\n\ + "local owner = tasks.spawn('../Owner')\n\ local _, _, one = tasks.join_any({ owner })\n\ - local idle = tasks.spawn('## Idle')\n\ + local idle = tasks.spawn('../Idle')\n\ local _, _, two = tasks.join_any({ idle })\n\ - local third = tasks.spawn('## Third')\n\ + local third = tasks.spawn('../Third')\n\ local _, _, three = tasks.join_any({ third })\n\ local states = tasks.status(owner).state .. ' ' .. tasks.status(idle).state \ .. ' ' .. tasks.status(third).state\n\ @@ -490,7 +490,7 @@ fn past_the_idle_cap_a_reply_passes_over_the_task_idle_longest_when_it_owns_a_li ), ( "Owner", - "tasks.spawn('## Worker')\ntasks.reply('a')\nreturn 'unreachable'", + "tasks.spawn('../Worker')\ntasks.reply('a')\nreturn 'unreachable'", ), ("Worker", "return tasks.receive()"), ("Idle", "tasks.reply('b')\nreturn 'unreachable'"), @@ -523,11 +523,11 @@ fn past_the_idle_cap_a_reply_passes_over_a_task_whose_call_child_owns_a_live_tas &[ ( "Main", - "local owner = tasks.spawn('## Owner')\n\ + "local owner = tasks.spawn('../Owner')\n\ local _, _, one = tasks.join_any({ owner })\n\ - local idle = tasks.spawn('## Idle')\n\ + local idle = tasks.spawn('../Idle')\n\ local _, _, two = tasks.join_any({ idle })\n\ - local third = tasks.spawn('## Third')\n\ + local third = tasks.spawn('../Third')\n\ local _, _, three = tasks.join_any({ third })\n\ local states = tasks.status(owner).state .. ' ' .. tasks.status(idle).state \ .. ' ' .. tasks.status(third).state\n\ @@ -535,10 +535,10 @@ fn past_the_idle_cap_a_reply_passes_over_a_task_whose_call_child_owns_a_live_tas tasks.cancel(third)\n\ return one .. two .. three .. ' ' .. states", ), - ("Owner", "return call('## Helper')"), + ("Owner", "return call('../Helper')"), ( "Helper", - "tasks.spawn('## Worker')\ntasks.reply('a')\nreturn 'unreachable'", + "tasks.spawn('../Worker')\ntasks.reply('a')\nreturn 'unreachable'", ), ("Worker", "return tasks.receive()"), ("Idle", "tasks.reply('b')\nreturn 'unreachable'"), @@ -571,7 +571,7 @@ fn past_the_idle_cap_closing_the_owner_of_the_replier_abandons_the_replier_as_ow &[ ( "Main", - "local a = tasks.spawn('## A')\n\ + "local a = tasks.spawn('../A')\n\ local _, _, reply = tasks.join_any({ a })\n\ store.exists('busy')\n\ local status = tasks.status(a)\n\ @@ -579,7 +579,7 @@ fn past_the_idle_cap_closing_the_owner_of_the_replier_abandons_the_replier_as_ow ), ( "A", - "tasks.spawn('## B')\ntasks.reply('a')\nreturn 'unreachable'", + "tasks.spawn('../B')\ntasks.reply('a')\nreturn 'unreachable'", ), ("B", "tasks.reply('b')\nreturn 'unreachable'"), ], @@ -609,10 +609,10 @@ fn past_the_idle_cap_closing_a_running_workers_owner_withdraws_its_effect_and_le &[ ( "Main", - "local owner = tasks.spawn('## Owner')\n\ + "local owner = tasks.spawn('../Owner')\n\ local _, _, one = tasks.join_any({ owner })\n\ store.exists('main')\n\ - local replier = tasks.spawn('## Replier')\n\ + local replier = tasks.spawn('../Replier')\n\ local _, _, two = tasks.join_any({ replier })\n\ local status = tasks.status(owner)\n\ tasks.cancel(replier)\n\ @@ -620,7 +620,7 @@ fn past_the_idle_cap_closing_a_running_workers_owner_withdraws_its_effect_and_le ), ( "Owner", - "tasks.spawn('## Worker')\ntasks.reply('a')\nreturn 'unreachable'", + "tasks.spawn('../Worker')\ntasks.reply('a')\nreturn 'unreachable'", ), ("Worker", "store.exists('worker')\nreturn 'never'"), ("Replier", "tasks.reply('b')\nreturn 'unreachable'"), @@ -670,18 +670,18 @@ fn past_the_idle_cap_closing_a_task_abandons_its_call_childs_worker_as_owner_ret &[ ( "Main", - "local owner = tasks.spawn('## Owner')\n\ + "local owner = tasks.spawn('../Owner')\n\ local _, _, one = tasks.join_any({ owner })\n\ - local replier = tasks.spawn('## Replier')\n\ + local replier = tasks.spawn('../Replier')\n\ local _, _, two = tasks.join_any({ replier })\n\ local status = tasks.status(owner)\n\ tasks.cancel(replier)\n\ return one .. ' ' .. two .. ' ' .. status.state .. ' ' .. tostring(status.ok)", ), - ("Owner", "return call('## Helper')"), + ("Owner", "return call('../Helper')"), ( "Helper", - "tasks.spawn('## Worker')\ntasks.reply('a')\nreturn 'unreachable'", + "tasks.spawn('../Worker')\ntasks.reply('a')\nreturn 'unreachable'", ), ("Worker", "return tasks.receive()"), ("Replier", "tasks.reply('b')\nreturn 'unreachable'"), @@ -710,7 +710,7 @@ fn ready_is_true_while_a_reply_waits_undelivered_and_false_once_a_wait_takes_it( let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ local before = tasks.ready(t)\n\ store.exists('busy')\n\ local waiting = tasks.ready(t)\n\ @@ -737,7 +737,7 @@ fn pending_lists_an_idle_task() { let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ tasks.join_any({ t })\n\ local listed = tasks.pending()\n\ tasks.cancel(t)\n\ @@ -754,7 +754,7 @@ fn a_reply_converts_its_value_as_a_section_return_does_with_nil_as_empty_text() let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ local replies = {}\n\ for _, word in ipairs({ 'a', 'b', 'c' }) do\n\ local _, _, reply = tasks.join_any({ t })\n\ diff --git a/crates/runvil-internal/runtime/src/execute/run/tests-send.rs b/crates/runvil-internal/runtime/src/execute/run/tests-send.rs index 3446a9c82..c2f279d06 100644 --- a/crates/runvil-internal/runtime/src/execute/run/tests-send.rs +++ b/crates/runvil-internal/runtime/src/execute/run/tests-send.rs @@ -72,7 +72,7 @@ fn an_owners_send_returns_true_resumes_its_tasks_receive_and_reports_under_the_t let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ store.exists('busy')\n\ local sent = tasks.send(t, 'also cover the tests')\n\ local _, ok, result = tasks.join_any({ t })\n\ @@ -107,8 +107,8 @@ fn a_non_owner_sending_by_id_string_raises_task_not_owned() { let mut run = run_of_sections(&[ ( "Main", - "local a = tasks.spawn('## A')\n\ - local b = tasks.spawn('## B', { input = a.task })\n\ + "local a = tasks.spawn('../A')\n\ + local b = tasks.spawn('../B', { input = a.task })\n\ local _, _, from_b = tasks.join_any({ b })\n\ local _, own = pcall(tasks.send, sys.taskid, 'x')\n\ local _, unknown = pcall(tasks.send, '0.9', 'x')\n\ @@ -135,7 +135,7 @@ fn a_send_to_a_task_that_returned_returns_false_and_queues_nothing() { let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ tasks.join({ t })\n\ return tostring(tasks.send(t, 'too late'))", ), @@ -156,7 +156,7 @@ fn a_send_to_a_task_waiting_for_admission_is_reported_right_after_its_start() { let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ tasks.send(t, 'one')\n\ tasks.send(t, 'two')\n\ local _, _, result = tasks.join_any({ t })\n\ @@ -198,7 +198,7 @@ fn a_task_canceled_before_admission_reports_neither_its_start_nor_a_message_sent let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ local sent = tasks.send(t, 'never read')\n\ tasks.cancel(t)\n\ return tostring(sent) .. ' ' .. tasks.status(t).state", @@ -229,8 +229,8 @@ fn a_task_that_stops_being_live_drops_the_messages_left_in_its_inbox() { let mut run = run_of_sections(&[ ( "Main", - "local a = tasks.spawn('## Returns')\n\ - local b = tasks.spawn('## Canceled')\n\ + "local a = tasks.spawn('../Returns')\n\ + local b = tasks.spawn('../Canceled')\n\ store.exists('main')\n\ tasks.send(a, 'unread by a')\n\ tasks.send(b, 'unread by b')\n\ @@ -285,7 +285,7 @@ fn a_send_whose_text_is_not_a_string_raises_a_lua_error() { let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ local ok, err = pcall(tasks.send, t, 42)\n\ tasks.cancel(t)\n\ return tostring(ok) .. '|' .. err.kind .. '|' .. tostring(err)", @@ -307,7 +307,7 @@ fn a_task_waiting_on_its_inbox_reads_blocked_inbox_and_reports_its_wait_under_it let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Waiter')\n\ + "local t = tasks.spawn('../Waiter')\n\ store.exists('busy')\n\ local blocked = tasks.status(t).blocked\n\ tasks.send(t, 'go')\n\ @@ -341,8 +341,8 @@ fn a_task_waiting_on_its_inbox_frees_its_slot_so_a_queued_spawn_starts() { ( "Main", "tasks.concurrency(1)\n\ - local waiter = tasks.spawn('## Waiter')\n\ - local worker = tasks.spawn('## Worker')\n\ + local waiter = tasks.spawn('../Waiter')\n\ + local worker = tasks.spawn('../Worker')\n\ local _, _, worked = tasks.join_any({ worker })\n\ tasks.send(waiter, 'woken')\n\ local _, _, woken = tasks.join_any({ waiter })\n\ @@ -363,7 +363,7 @@ fn a_main_chain_joining_a_task_that_waits_on_its_inbox_with_nothing_else_to_run_ let mut run = run_of_sections(&[ ( "Main", - "local t = tasks.spawn('## Worker')\n\ + "local t = tasks.spawn('../Worker')\n\ tasks.join({ t })\n\ return 'unreachable'", ), diff --git a/crates/runvil-internal/runtime/src/execute/run/tests-steer.rs b/crates/runvil-internal/runtime/src/execute/run/tests-steer.rs index 18b8c2c2c..941316e69 100644 --- a/crates/runvil-internal/runtime/src/execute/run/tests-steer.rs +++ b/crates/runvil-internal/runtime/src/execute/run/tests-steer.rs @@ -508,7 +508,7 @@ const STEERED_TASK: &[(&str, &str)] = &[ ( "Main", "models.use('default')\n\ - local t = tasks.spawn('## Worker')\n\ + local t = tasks.spawn('../Worker')\n\ store.exists('busy')\n\ tasks.send(t, 'shorter please')\n\ local _, _, result = tasks.join_any({ t })\n\ @@ -566,8 +566,8 @@ fn a_message_to_one_task_does_not_withdraw_another_chains_request() { ( "Main", "models.use('default')\n\ - local a = tasks.spawn('## A')\n\ - local b = tasks.spawn('## B')\n\ + local a = tasks.spawn('../A')\n\ + local b = tasks.spawn('../B')\n\ store.exists('busy')\n\ tasks.send(a, 'for a')\n\ local results = tasks.join({ a, b })\n\ diff --git a/crates/runvil-internal/runtime/src/execute/scheduler.rs b/crates/runvil-internal/runtime/src/execute/scheduler.rs index bd62c398e..dcd4ee8da 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler.rs @@ -32,7 +32,7 @@ //! merged: `ChainState` is cloned into callbacks, while the scheduler must //! stay unreachable from the callback layer. The prompt tree is shared //! through the context's `Arc`, so a chain names its position in it by -//! path ([`SectionPos`](crate::heading_address::SectionPos)) rather than by +//! path ([`SectionPos`](crate::section_pos::SectionPos)) rather than by //! borrow, and the scheduler owns itself outright. //! //! This file holds the scheduler core: the run's lifecycle and the ready diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/chain.rs b/crates/runvil-internal/runtime/src/execute/scheduler/chain.rs index ab63234ec..2d6bf7c84 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/chain.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/chain.rs @@ -14,9 +14,9 @@ use runvil_vfs::Access; use crate::execute::context::ChainState; use crate::execute::run::EffectId; use crate::execute::section_context::TaskSeed; -use crate::heading_address::SectionPos; use crate::lua::Answer; use crate::model::ModelBinding; +use crate::section_pos::SectionPos; use crate::{Result, error}; use super::chain_record::{CallDepth, ChainKind, Walk}; diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/chain_record.rs b/crates/runvil-internal/runtime/src/execute/scheduler/chain_record.rs index 9d3e90fec..25b0a9d25 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/chain_record.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/chain_record.rs @@ -16,10 +16,10 @@ use runvil_vfs::Access; use crate::execute::context::ChainState; use crate::execute::section_context::{SectionContext, TaskSeed}; use crate::execute::walk_target::JumpTarget; -use crate::heading_address::SectionPos; use crate::lua::{Answer, Blocked, UsageAnchor}; use crate::model::{Message, ModelId}; use crate::prompt::{Block, BodyText, Prompt, Section}; +use crate::section_pos::SectionPos; use crate::{Result, error}; /// A chain's local id counters: the indices its next child chain and its @@ -656,7 +656,7 @@ impl Chain { concurrency: std::num::NonZeroUsize, ) -> Chain { use crate::execute::RunParams; - use crate::heading_address::SectionIndex; + use crate::section_pos::SectionIndex; use crate::test_support::scenario::{TEST_SEED, TEST_STARTED_AT}; let source = concat!( @@ -867,9 +867,9 @@ mod tests { use super::{Chain, ChainArena, ChainIndex, ChainKind, ChatAnchor, Walk}; use crate::execute::walk_target::JumpTarget; - use crate::heading_address::{SectionIndex, SectionPos}; use crate::lua::UsageAnchor; use crate::model::{Message, ModelId}; + use crate::section_pos::{SectionIndex, SectionPos}; fn usage(prompt: u32, completion: u32) -> Usage { Usage::new(prompt, completion, prompt + completion) diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/dispatch.rs b/crates/runvil-internal/runtime/src/execute/scheduler/dispatch.rs index 3c83b0c0e..0b1f7494c 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/dispatch.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/dispatch.rs @@ -23,7 +23,7 @@ use runvil_types::event::ReplyOrigin; use crate::execute::run::Effect; use crate::execute::section_context::TaskSeed; -use crate::lua::{Answer, Request, VfsOp}; +use crate::lua::{Answer, LibraryKey, Request, VfsOp}; use crate::model::{Message, ModelBinding}; use crate::{Result, error}; @@ -82,16 +82,27 @@ impl Scheduler { Request::Infer { prompt, binding } => { self.with_chain(id, |ops| ops.dispatch_infer(&prompt, binding)) } - Request::Call { target, input, var } => { - self.dispatch_call(id, &target, input.as_deref(), &var) - } + Request::Call { + target, + input, + var, + from, + } => self.dispatch_call(id, &target, input.as_deref(), &var, from.as_ref()), Request::Spawn { target, input, item, index, var, - } => self.dispatch_spawn(id, &target, input.as_deref(), TaskSeed { item, index }, var), + from, + } => self.dispatch_spawn( + id, + &target, + input.as_deref(), + TaskSeed { item, index }, + var, + from.as_ref(), + ), Request::Timer { duration } => self.dispatch_timer(id, duration), Request::JoinAny { tasks } => self.dispatch_join_any(id, tasks), Request::Ready { task } => self.dispatch_ready(id, &task), @@ -128,8 +139,9 @@ impl Scheduler { target: &str, input: Option<&str>, var: &serde_json::Value, + from: Option<&LibraryKey>, ) -> Result<()> { - match self.prepare_call(id, target, input, var) { + match self.prepare_call(id, target, input, var, from) { Ok(child) => { self.ready.push_back(child); Ok(()) @@ -148,6 +160,7 @@ impl Scheduler { target: &str, input: Option<&str>, var: &serde_json::Value, + from: Option<&LibraryKey>, ) -> Result { let chain = &self.chains[id]; let depth = chain.call_depth.deeper()?; @@ -171,7 +184,7 @@ impl Scheduler { let model = chain.current_model()?; // `chain`'s arena borrow ends here; the resolution names the // target's slice by path, so nothing borrows the arena across it. - let resolved = self.with_chain(id, |ops| ops.resolve_chain_target(target))?; + let resolved = self.with_chain(id, |ops| ops.resolve_chain_target(target, from))?; // The child's id is the caller's next child id. let lineage = self.chains[id].next_child_id()?; self.start_chain(ChainStart { diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/h1.rs b/crates/runvil-internal/runtime/src/execute/scheduler/h1.rs index f3481e01c..63757429c 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/h1.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/h1.rs @@ -22,7 +22,7 @@ use std::sync::Arc; use runvil_types::error::place; -use crate::heading_address::{SectionIndex, SectionPos}; +use crate::section_pos::{SectionIndex, SectionPos}; use crate::{Result, error}; use super::chain::ChainStep; @@ -145,25 +145,24 @@ impl Scheduler { Ok(ChainStep::Continue) } - /// Ends the H1 pass on a jump out: the heading resolves against the - /// pass's top-level set through + /// Ends the H1 pass on a jump out: the path resolves from the H1, + /// whose children are the top-level sections, through /// [`resolve_chain_target`](super::ChainOps::resolve_chain_target), /// then the pass ends and the root walk starts at the target. /// /// # Errors - /// Returns a `LuaRuntime` failure when the heading is malformed, matches no - /// top-level section, or matches more than one, placed on `line`, the - /// `jump` call's; the pass's own ending can fail as - /// [`end_h1`](Self::end_h1) documents. + /// Returns a `LuaRuntime` failure when the path is refused or names no + /// top-level section, placed on `line`, the `jump` call's; the pass's + /// own ending can fail as [`end_h1`](Self::end_h1) documents. pub(super) fn end_h1_at_jump( &mut self, id: ChainIndex, - heading: &str, + path: &str, line: Option, ) -> Result { let prompt = self.prompt(); let target = self - .with_chain(id, |ops| ops.resolve_chain_target(heading)) + .with_chain(id, |ops| ops.resolve_chain_target(path, None)) .map_err(|error| place(error, prompt.file(), line))?; self.end_h1(id, target.pos) } diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/step.rs b/crates/runvil-internal/runtime/src/execute/scheduler/step.rs index 28fba94df..a91d66e3b 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/step.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/step.rs @@ -11,9 +11,9 @@ use runvil_types::error::{ErrorCode, place}; use runvil_types::event_lifecycle; use crate::execute::requirements::NOTICE_HEADER; -use crate::heading_address::{SectionIndex, SectionPos}; use crate::lua::{Answer, CoroStep, LuaBlockResult, Request, YieldParse}; use crate::prompt::Block; +use crate::section_pos::{SectionIndex, SectionPos}; use crate::{Result, error}; use super::chain::ChainStep; @@ -29,8 +29,8 @@ enum Settled { Dispatch(Request), /// The block suspended on a call answered on the spot. Answer(Result), - /// The block jumped to the heading, from the `jump` call on the - /// prompt line, when known. + /// The block jumped to the section path, from the `jump` call on + /// the prompt line, when known. Jump(String, Option), /// The block returned a scalar. Returned(String), @@ -103,7 +103,7 @@ fn settle_block( // resolved target. emitter.report(name, event_lifecycle::LUA_CHUNK_SUCCEEDED); match done { - LuaBlockResult::Jump(heading, line) => Ok(Settled::Jump(heading, line)), + LuaBlockResult::Jump(path, line) => Ok(Settled::Jump(path, line)), LuaBlockResult::Returned(Some(value)) => { // The H1 pass's final `var` read-back runs here // exactly as the walk reads it on every exit, so a @@ -245,11 +245,11 @@ impl Scheduler { Settled::Answer(answer) => self.answer_inline(id, answer)?, // A jump out of H1 ends the pass and starts the walk at the // target. - Settled::Jump(heading, line) if is_h1 => { - return self.end_h1_at_jump(id, &heading, line); + Settled::Jump(path, line) if is_h1 => { + return self.end_h1_at_jump(id, &path, line); } - Settled::Jump(heading, line) => { - self.with_chain(id, |ops| ops.apply_jump(&heading, line))?; + Settled::Jump(path, line) => { + self.with_chain(id, |ops| ops.apply_jump(&path, line))?; self.ready.push_back(id); } Settled::Returned(value) if is_h1 => { diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/tasks.rs b/crates/runvil-internal/runtime/src/execute/scheduler/tasks.rs index f7ab7fad7..2d9573bb6 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/tasks.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/tasks.rs @@ -38,7 +38,7 @@ use runvil_vfs::ExecId; use crate::execute::run::EffectId; use crate::execute::section_context::TaskSeed; -use crate::lua::Answer; +use crate::lua::{Answer, LibraryKey}; use crate::{Result, error}; use super::admission::SpawnRecord; @@ -558,8 +558,9 @@ impl Scheduler { input: Option<&str>, seed: TaskSeed, var: serde_json::Value, + from: Option<&LibraryKey>, ) -> Result<()> { - match self.prepare_spawn(id, target, input, seed, var) { + match self.prepare_spawn(id, target, input, seed, var, from) { Ok((task, child, record)) => { // The child waits for admission, queued behind the // spawner; the drain admits it when a slot frees up at @@ -573,8 +574,7 @@ impl Scheduler { /// The fallible half of spawn dispatch: `call`'s depth cap against the /// spawner's call-depth field, `call`'s target resolution over the - /// spawner's visible set, the worker-template check (a list section is - /// not a target), then the task chain one level deeper under the + /// spawner's visible set, then the task chain one level deeper under the /// spawn's `args` and `var` snapshot, with its own access capability (a /// concurrent thread of execution, forked from the spawner's so the /// spawn is the happens-before edge; every delivery of the task joins @@ -591,6 +591,7 @@ impl Scheduler { input: Option<&str>, seed: TaskSeed, var: serde_json::Value, + from: Option<&LibraryKey>, ) -> Result<(TaskId, ChainIndex, SpawnRecord)> { let chain = &self.chains[id]; let depth = chain.call_depth.deeper()?; @@ -618,19 +619,13 @@ impl Scheduler { // `chain`'s arena borrow ends here; the resolution names the // target's slice by path, resolved against the shared tree. let prompt = self.prompt(); - let resolved = self.with_chain(id, |ops| ops.resolve_chain_target(target))?; + let resolved = self.with_chain(id, |ops| ops.resolve_chain_target(target, from))?; let worker = resolved .pos .section(&prompt) .ok_or(error::make_internal_error( "a resolved worker is absent from its slice", ))?; - if worker.is_list() { - return Err(error::make_lua_runtime_error(format!( - "section `{}` is a list section, not a worker template", - worker.name() - ))); - } // The access spawns before the chain exists: a store refusal here // is the last fallible step that can leave nothing behind, so it // runs ahead of the id allocation and the arena push rather than diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/test_hooks.rs b/crates/runvil-internal/runtime/src/execute/scheduler/test_hooks.rs index 7ebe6f21d..5fb4e2d28 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/test_hooks.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/test_hooks.rs @@ -14,8 +14,8 @@ use runvil_vfs::{MemoryBackend, Origin, VfsRef}; use crate::Result; use crate::execute::run::EffectId; -use crate::heading_address::{SectionIndex, SectionPos}; use crate::lua::{Answer, Request, ToolSet, VfsOp}; +use crate::section_pos::{SectionIndex, SectionPos}; use super::chain::ChainStart; use super::chain_record::{CallDepth, ChainKind}; diff --git a/crates/runvil-internal/runtime/src/execute/scheduler/walk.rs b/crates/runvil-internal/runtime/src/execute/scheduler/walk.rs index 338185484..9dea8ac45 100644 --- a/crates/runvil-internal/runtime/src/execute/scheduler/walk.rs +++ b/crates/runvil-internal/runtime/src/execute/scheduler/walk.rs @@ -16,11 +16,11 @@ use std::sync::Arc; use runvil_types::error::place; use crate::execute::section_context::SectionContext; -use crate::execute::walk_target::{ - JumpTarget, h1_visible_sections, resolve_jump_target, section_position, -}; -use crate::heading_address::{self, SectionIndex, SectionPos, Visible}; +use crate::execute::walk_target::{JumpTarget, resolve_jump_target}; +use crate::lua::LibraryKey; use crate::prompt::Block; +use crate::section_path::Origin; +use crate::section_pos::{SectionIndex, SectionPos}; use crate::{Result, error}; use super::chain::ChainStep; @@ -196,19 +196,18 @@ impl ChainOps<'_> { /// Applies a jump's control transfer: closes the jumper's frame as /// completed (the final `var` /// rolled forward; the armed drop firing `SECTION_FINISHED`, a jump - /// being a completion), resolves the heading against the jumper's - /// visible set, and moves the walk. A sibling target replaces the - /// walk's current position; a child target suspends the jumper on the - /// chain's walk stack and descends into the jumper's child slice from - /// the target. + /// being a completion), resolves the path from the jumper, and moves + /// the walk. A sibling target replaces the walk's current position; a + /// child target suspends the jumper on the chain's walk stack and + /// descends into the jumper's child slice from the target. /// /// # Errors /// Returns a `LuaRuntime` failure when the `var` read-back fails (the /// frame drops unarmed), placed on the jumping block's line, or when - /// the heading matches no visible section or more than one, placed on - /// `line`, the `jump` call's - the jumper's frame has already closed - /// as completed. - pub(super) fn apply_jump(&mut self, heading: &str, line: Option) -> Result<()> { + /// the path is refused or names no section, placed on `line`, the + /// `jump` call's - the jumper's frame has already closed as + /// completed. + pub(super) fn apply_jump(&mut self, path: &str, line: Option) -> Result<()> { let prompt = self.prompt(); { let chain = &mut *self.chain; @@ -225,37 +224,40 @@ impl ChainOps<'_> { frame.mark_completed(); drop(frame); } + // A jump moves the running walk, so its path starts at the running + // section, in a library's code too. let target = self - .resolve_chain_target(heading) + .resolve_chain_target(path, None) .map_err(|error| place(error, prompt.file(), line))?; self.chain.walk.jump(target); Ok(()) } - /// Resolves `heading` against the chain's current section's visible - /// set into the position the walk or a contained chain continues from: - /// a direct child's place in the jumper's child slice, or a sibling's - /// in the jumper's own. The H1 pass's set is the whole top-level - /// slice, so its targets never descend. + /// Resolves the relative `path` into the position the walk or a + /// contained chain continues from: a direct child's place in the + /// starting section's child slice, or a sibling's in its own. The + /// path starts at the library `from` names, when a library's code + /// made the request, and otherwise at the chain's current section. + /// The H1 pass's children are the top-level sections, so its targets + /// never descend. /// /// # Errors - /// Returns a `LuaRuntime` failure when the heading is malformed, matches no - /// visible section, or matches more than one (see - /// [`heading_address::resolve_visible`]). - pub(super) fn resolve_chain_target(&self, heading: &str) -> Result { + /// Returns a `LuaRuntime` failure when the path is malformed, is not + /// one of the two relative forms, or names no section, and an + /// `Internal` failure for a key that names no section position (see + /// [`resolve_jump_target`]). + pub(super) fn resolve_chain_target( + &self, + path: &str, + from: Option<&LibraryKey>, + ) -> Result { let prompt = self.prompt(); let chain = &*self.chain; - if chain.kind.is_h1() { - let sections = h1_visible_sections(&prompt); - let target = heading_address::resolve_visible(heading, Visible::all(sections))?; - let index = section_position(sections, target).ok_or(error::make_internal_error( - "a resolved H1 target is absent from the top-level slice", - ))?; - return Ok(JumpTarget { - pos: SectionPos::top(index), - descends: false, - }); - } - resolve_jump_target(heading, &prompt, chain.walk.current()) + let origin = if chain.kind.is_h1() { + Origin::H1 + } else { + Origin::Section(chain.walk.current()) + }; + resolve_jump_target(path, &prompt, origin, from) } } diff --git a/crates/runvil-internal/runtime/src/execute/section_context.rs b/crates/runvil-internal/runtime/src/execute/section_context.rs index 8b741643c..73ce6fe9a 100644 --- a/crates/runvil-internal/runtime/src/execute/section_context.rs +++ b/crates/runvil-internal/runtime/src/execute/section_context.rs @@ -24,17 +24,18 @@ use runvil_types::ids::TaskId; use runvil_types::tools::ToolId; use runvil_vfs::{Access, ScopeHandle}; -use crate::heading_address::{SectionPos, Visible}; use crate::lua::{ Library, LibraryKey, ProseState, ScriptReport, SectionVm, ToolCallCounts, Uninjected, }; use crate::model::ModelBinding; use crate::prompt::Prompt; +use crate::section_path::Origin; +use crate::section_pos::SectionPos; use crate::{Result, error, subst}; use super::context::{ChainState, ReportingHandles}; use super::section_vm::{self, VmSeed}; -use super::walk_target::{self, RequireOrigin}; +use super::walk_target; /// The seeds a spawned task chain's first section entry receives beyond the /// shared Engine values: `tasks.spawn`'s `opts.item` (installed as the @@ -153,14 +154,14 @@ impl SectionContext { /// Constructs the frame for one walked section and runs its setup /// preamble: the section's `sys`, the section-started observation, VM /// construction and limits, and the shared setup half - /// ([`section_vm::setup_section_vm`]), with the `prompt` reads resolved - /// over the section's visible set. + /// ([`section_vm::setup_section_vm`]), with the `prompt` reads of the + /// section's own code resolved from the section. /// /// `pos` is the section's place in the caller's walk. Each `prompt` - /// read resolves its sibling slice from the shared prompt and reads - /// the section's visible set (its siblings minus itself, plus its - /// direct children) in place, and `require` resolves from the same - /// position over its wider reach. `section_id` + /// read resolves its path against the shared prompt: a relative path + /// from `pos`, which names a sibling or a direct child, and a path + /// from the top from the top-level sections. `require` takes only a + /// path from the top, so it needs no position. `section_id` /// is the section's `sys.id`: the entering chain's hierarchical id extended /// by its local entry counter, allocated by the scheduler; `task_id` /// is the entering chain's `sys.taskid`. `var` is the walk's current @@ -199,18 +200,16 @@ impl SectionContext { ctx.emitter() .report(section.name(), event_lifecycle::SECTION_STARTED); let vm = limited_vm(ctx, section.name())?; - let resolve = library_resolver( - Arc::clone(ctx.prompt()), - RequireOrigin::Section(pos.clone()), - ); + let resolve = library_resolver(Arc::clone(ctx.prompt())); let render = prose_renderer(ctx, seed.item.clone()); - // The `prompt` reads resolve over the section's visible set; the - // suspending calls (`call`, `tasks.fanout`, `models.infer`) are the - // yield shims the setup half installs. + // The `prompt` reads resolve from the section, or from the library + // whose code makes the read; the suspending calls (`call`, + // `tasks.fanout`, `models.infer`) are the yield shims the setup + // half installs. let prompt = Arc::clone(ctx.prompt()); let pos = pos.clone(); - let read = move |heading: String| { - walk_target::read_visible(&heading, Visible::of(pos.siblings(&prompt), pos.index)) + let read = move |path: String, from: Option| { + walk_target::read_section(&path, &prompt, Origin::Section(&pos), from.as_ref()) }; // The shared setup half ([`section_vm::setup_section_vm`]) follows; // only the seed, the `sys` extras, and the callbacks' position are @@ -246,10 +245,9 @@ impl SectionContext { /// observation, and no `SECTION_FINISHED` since the scheduler never arms /// the pass's completion (the pass is not a walked section), an empty /// `var` seed (it runs first and is never re-entered), `argv` installed - /// writable so the repair pattern can assign it, - /// [`walk_target::h1_visible_sections`] as the visible set behind the - /// `prompt` reads, and [`RequireOrigin::H1`], which reaches every - /// top-level section, behind `require`. + /// writable so the repair pattern can assign it, and [`Origin::H1`] + /// behind the `prompt` reads, so a relative path names a top-level + /// section. /// /// # Errors /// Returns the failure of whichever step failed (see [`section_vm`] @@ -265,14 +263,11 @@ impl SectionContext { let title = ctx.prompt().title(); let sys = ctx.section_sys(section_id, task_id, title); let vm = limited_vm(ctx, title)?; - let resolve = library_resolver(Arc::clone(ctx.prompt()), RequireOrigin::H1); + let resolve = library_resolver(Arc::clone(ctx.prompt())); let render = prose_renderer(ctx, None); let prompt = Arc::clone(ctx.prompt()); - let read = move |heading: String| { - walk_target::read_visible( - &heading, - Visible::all(walk_target::h1_visible_sections(&prompt)), - ) + let read = move |path: String, from: Option| { + walk_target::read_section(&path, &prompt, Origin::H1, from.as_ref()) }; // `argv` installs writable, the H1 delta `SectionVmSetup::argv` // describes; the executor reads the value back at the freeze (see @@ -445,16 +440,12 @@ fn live_vm(vm: Option<&SectionVm>) -> &SectionVm { vm.expect("a live frame holds its VM until drop") } -/// The resolver behind a frame's `require`: each heading resolves in -/// `prompt` from the position a loading library's key names, or from -/// `origin` when no library is loading. +/// The resolver behind a frame's `require`: each path resolves from the +/// top of `prompt`, so every frame's resolver is the same. fn library_resolver( prompt: Arc, - origin: RequireOrigin, -) -> impl Fn(String, Option) -> Result + Send + Sync + 'static { - move |heading: String, key: Option| { - walk_target::resolve_library(&prompt, &origin, &heading, key.as_ref()) - } +) -> impl Fn(String) -> Result + Send + Sync + 'static { + move |path: String| walk_target::resolve_library(&prompt, &path) } /// Builds the section VM for `name` over the run's shared sets, with the diff --git a/crates/runvil-internal/runtime/src/execute/section_vm.rs b/crates/runvil-internal/runtime/src/execute/section_vm.rs index 3c1dbe1f8..77b7f024a 100644 --- a/crates/runvil-internal/runtime/src/execute/section_vm.rs +++ b/crates/runvil-internal/runtime/src/execute/section_vm.rs @@ -6,9 +6,9 @@ //! (both take the chain's next entry `id`; a task's first entry adds its //! `index`), picks the [`VmSeed`] (the chain's `var`, plus the item seeded //! into this section on a task's first entry), and supplies the `read` -//! callback behind the `prompt` reads, resolved over its own visible set, -//! the library resolver behind `require`, resolved over its own reach, and -//! the renderer for library prose templates. +//! callback behind the `prompt` reads, resolved from its own section, the +//! library resolver behind `require`, and the renderer for library prose +//! templates. //! //! VM construction and the Lua limits install stay with the frame //! constructor. A construction or limits failure propagates as a bare @@ -24,7 +24,7 @@ use runvil_types::emitter::Emitter; use runvil_types::packs::Prelude; use runvil_vfs::{Access, ScopeHandle}; -use crate::lua::{Library, LibraryKey, ProseState, SectionText, SectionVm, Uninjected}; +use crate::lua::{Library, LibraryKey, ProseState, SectionRead, SectionVm, Uninjected}; use crate::model::ModelBinding; use crate::{Error, Result, error}; @@ -133,11 +133,11 @@ pub(crate) fn setup_section_vm( render: P, ) -> Result where - R: Fn(String) -> std::result::Result, Error> + Send + Sync + 'static, - L: Fn(String, Option) -> std::result::Result + R: Fn(String, Option) -> std::result::Result + Send + Sync + 'static, + L: Fn(String) -> std::result::Result + Send + Sync + 'static, P: Fn(&str, Option, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, { vm.set_prompt_file(setup.file); @@ -174,11 +174,11 @@ fn install_after_injection( render: P, ) -> Result<()> where - R: Fn(String) -> std::result::Result, Error> + Send + Sync + 'static, - L: Fn(String, Option) -> std::result::Result + R: Fn(String, Option) -> std::result::Result + Send + Sync + 'static, + L: Fn(String) -> std::result::Result + Send + Sync + 'static, P: Fn(&str, Option, ProseState<'_>) -> mlua::Result + Send + Sync + 'static, { vm.install_engine_globals(setup.emitter, setup.section_name, setup.scope)?; diff --git a/crates/runvil-internal/runtime/src/execute/tests/adhoc_tools.rs b/crates/runvil-internal/runtime/src/execute/tests/adhoc_tools.rs index 413207221..166284214 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/adhoc_tools.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/adhoc_tools.rs @@ -177,7 +177,7 @@ fn a_handler_that_calls_jump_fails_the_run() { text("unreachable"), ]); let only = "tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args) - jump('## Other') + jump('../Other') end)) messages:user('Use the tool.') models.loop() @@ -199,7 +199,7 @@ const JUMP_REFUSAL: &str = "jump is unavailable inside an adhoc tool handler"; /// block saved before defining it. const SAVED_JUMP_GRAB: &str = "local j = jump\n\ tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args)\n\ - j('## Other')\n\ + j('../Other')\n\ end))\n"; #[test] @@ -255,13 +255,13 @@ fn jump_stays_refused_in_an_outer_handler_after_an_inner_one_returns() { "tools.offer(tools.adhoc({{ name = 'inner', description = 'Inner' }}, function() return 'inner' end)) tools.offer(tools.adhoc({{ name = 'outer', description = 'Outer' }}, function() local inner = tools.call('adhoc/inner', {{}}) - local ok, err = pcall(jump, '## Other') + local ok, err = pcall(jump, '../Other') assert(not ok, 'jump is still refused in the outer handler') assert(tostring(err):find('{JUMP_REFUSAL}', 1, true), tostring(err)) return inner end)) assert(tools.call('adhoc/outer', {{}}) == 'inner', 'the outer handler returns') - jump('## Other')" + jump('../Other')" ); let trace = loop_scenario(prompt("", &[("Only", &only), OTHER])).run(); assert_eq!(trace.text(), "jumped"); @@ -278,7 +278,7 @@ fn jump_works_in_the_same_block_after_the_loop_returns() { end)) messages:user('Use the tool.') models.loop() - jump('## Other')"; + jump('../Other')"; let trace = loop_scenario(prompt("", &[("Only", only), OTHER])) .chat(&gateway) .run(); diff --git a/crates/runvil-internal/runtime/src/execute/tests/batch_turn.rs b/crates/runvil-internal/runtime/src/execute/tests/batch_turn.rs index d2ec4e70d..198f20590 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/batch_turn.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/batch_turn.rs @@ -269,7 +269,7 @@ fn a_later_result_keeps_the_batch_turn_after_a_handler_calls_an_inferring_sectio let source = prompt( "", &[ - ("Only", &grab_block(OFFER_ECHO, "return call('## Helper')")), + ("Only", &grab_block(OFFER_ECHO, "return call('../Helper')")), ("Helper", "return models.infer('helper prompt')"), ], ); diff --git a/crates/runvil-internal/runtime/src/execute/tests/context.rs b/crates/runvil-internal/runtime/src/execute/tests/context.rs index 870aa329f..8347c1a9c 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/context.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/context.rs @@ -14,7 +14,7 @@ pub(super) fn arm_fanout() -> PromptSource { PromptSource::new().section( "Parent", " - local r = tasks.fanout('### Worker', {'alpha'}) + local r = tasks.fanout('Worker', {'alpha'}) return r[1].text", ) } diff --git a/crates/runvil-internal/runtime/src/execute/tests/effects.rs b/crates/runvil-internal/runtime/src/execute/tests/effects.rs index e3718e665..e6cb4cfac 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/effects.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/effects.rs @@ -226,7 +226,7 @@ fn a_timed_wait_issues_exactly_one_timer_effect() { # Timer\n\n\ ## Main\n\n\ ```lua\n\ - local t = tasks.spawn('## Child')\n\ + local t = tasks.spawn('../Child')\n\ local first, ok, result = tasks.join_any({ t }, { timeout = 30 })\n\ return result\n\ ```\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/exec_flow.rs b/crates/runvil-internal/runtime/src/execute/tests/exec_flow.rs index fb55667ac..cc184811c 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/exec_flow.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/exec_flow.rs @@ -1,6 +1,6 @@ //! Tests for section walk control flow: `call`, `jump`, `fanout`, the `prompt` reads, and `var`. -use runvil_prompt::test_support::{PromptSource, synthetic_section}; +use runvil_prompt::test_support::PromptSource; use super::context::arm_fanout; use super::*; @@ -52,7 +52,7 @@ fn call_runs_named_section_as_subroutine() { .section( "Main", " - local by_name = call('## Research') + local by_name = call('../Research') assert(by_name == 'research-reply') assert(store.read('evidence.md') == 'research-reply') return by_name", @@ -86,11 +86,11 @@ fn nested_call_without_input_inherits_the_chains_args() { .section( "Main", " - local r = call('## Sub', 'chain-args') + local r = call('../Sub', 'chain-args') assert(r == 'inner-reply') return r", ) - .section("Sub", "return call('## Inner')") + .section("Sub", "return call('../Inner')") .prose( "Inner", " @@ -120,25 +120,6 @@ fn nested_call_without_input_inherits_the_chains_args() { ); } -/// Two top-level sections sharing one name error loudly as ambiguous rather -/// than silently resolving to the first. The parser refuses such a prompt, -/// so the jumper's visible set is built by hand. -#[test] -fn duplicate_top_level_section_names_error_loudly() { - let sections = vec![ - synthetic_section("Main", 2, Vec::new()), - synthetic_section("Dup", 2, Vec::new()), - synthetic_section("Dup", 2, Vec::new()), - ]; - let error = crate::heading_address::resolve_visible( - "## Dup", - crate::heading_address::Visible::of(§ions, crate::heading_address::SectionIndex::FIRST), - ) - .expect_err("two visible sections with one name must be ambiguous"); - let rendered = error.to_string(); - assert!(rendered.contains("ambiguous"), "error was: {rendered}"); -} - /// [`arm_fanout`] with a `### Worker` whose Lua is `worker`. fn default_arm(worker: &str) -> String { arm_fanout().section("### Worker", worker).build() diff --git a/crates/runvil-internal/runtime/src/execute/tests/fanout_acceptance.rs b/crates/runvil-internal/runtime/src/execute/tests/fanout_acceptance.rs index 488517a15..a6e9347c9 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/fanout_acceptance.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/fanout_acceptance.rs @@ -7,10 +7,9 @@ //! outer arm's chain, hierarchical ids are identical across two runs //! whose arms finish in different orders, and three arms each running //! `models.loop` hold three model rounds in flight at once. The exhausted -//! stub, empty collection, list-section worker, claims violation across -//! arms, and the fanout-inside-a-`call`-child id nesting are pinned in -//! `scheduler`. An arm parks on a model round the rig holds until the -//! test releases it. +//! stub, empty collection, claims violation across arms, and the +//! fanout-inside-a-`call`-child id nesting are pinned in `scheduler`. An +//! arm parks on a model round the rig holds until the test releases it. use std::collections::BTreeMap; @@ -43,7 +42,7 @@ fn a_queued_arm_is_admitted_when_any_arm_frees_its_slot() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'a', 'b', 'c'})\n\ + local r = tasks.fanout('Worker', {'a', 'b', 'c'})\n\ return r[1].text .. '|' .. r[2].text .. '|' .. r[3].text\n\ ```\n\n\ ### Worker\n\n\ @@ -96,7 +95,7 @@ fn a_fatal_arm_gives_every_started_arm_exactly_one_terminal() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Fanout\n\n\ ## Parent\n\n\ - ```lua\ntasks.fanout('### Worker', {'boom', 'slow', 'queued'})\n```\n\n\ + ```lua\ntasks.fanout('Worker', {'boom', 'slow', 'queued'})\n```\n\n\ ### Worker\n\n\ ```lua\n\ local a = models.infer(item .. ':1')\n\ @@ -143,7 +142,7 @@ fn an_arm_that_replies_hands_the_call_its_reply_and_ends_canceled() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'reply', 'return'})\n\ + local r = tasks.fanout('Worker', {'reply', 'return'})\n\ return r[1].text .. '|' .. tostring(r[1].ok) .. '|' .. r[2].text .. '|' .. #tasks.pending()\n\ ```\n\n\ ### Worker\n\n\ @@ -180,12 +179,12 @@ fn a_nested_fanout_nests_its_arm_ids_under_the_outer_arm() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Outer', {'a', 'b'})\n\ + local r = tasks.fanout('Outer', {'a', 'b'})\n\ return r[1].text .. '|' .. r[2].text\n\ ```\n\n\ ### Outer\n\n\ ```lua\n\ - local r = tasks.fanout('### Inner', {'x', 'y'})\n\ + local r = tasks.fanout('../Inner', {'x', 'y'})\n\ return sys.id .. '(' .. r[1].text .. ',' .. r[2].text .. ')'\n\ ```\n\n\ ### Inner\n\n\ @@ -221,8 +220,8 @@ const IDENTITY: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Identity\n\n\ ## Main\n\n\ ```lua\n\ - local r = tasks.fanout('## Worker', {'a', 'b'})\n\ - local sub = call('## Sub')\n\ + local r = tasks.fanout('../Worker', {'a', 'b'})\n\ + local sub = call('../Sub')\n\ return r[1].text .. '|' .. r[2].text .. '|' .. sub .. '|' .. sys.id\n\ ```\n\n\ ## Worker\n\n\ @@ -315,7 +314,7 @@ fn three_arms_running_models_loop_hold_three_model_rounds_in_flight_at_once() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'a', 'b', 'c'})\n\ + local r = tasks.fanout('Worker', {'a', 'b', 'c'})\n\ return r[1].text .. '|' .. r[2].text .. '|' .. r[3].text\n\ ```\n\n\ ### Worker\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/happens_before-concurrency.rs b/crates/runvil-internal/runtime/src/execute/tests/happens_before-concurrency.rs index 9c5a058ea..9284c913c 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/happens_before-concurrency.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/happens_before-concurrency.rs @@ -16,15 +16,15 @@ const TRACE_6: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Nested\n\n\ ## Main\n\n\ ```lua\n\ - local arm = tasks.spawn('### Arm')\n\ + local arm = tasks.spawn('Arm')\n\ local _, ok, result = tasks.join_any({ arm })\n\ assert(ok and result == 'arm done', tostring(result))\n\ return store.read('sub-one.md') .. '/' .. store.read('sub-two.md')\n\ ```\n\n\ ### Arm\n\n\ ```lua\n\ - local a = tasks.spawn('#### Sub', { item = 'one' })\n\ - local b = tasks.spawn('#### Sub', { item = 'two' })\n\ + local a = tasks.spawn('Sub', { item = 'one' })\n\ + local b = tasks.spawn('Sub', { item = 'two' })\n\ tasks.join({ a, b })\n\ return 'arm done'\n\ ```\n\n\ @@ -45,7 +45,7 @@ const TRACE_12: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Concurrency\n\n\ ## Main\n\n\ ```lua\n\ - local arm = tasks.spawn('### Arm')\n\ + local arm = tasks.spawn('Arm')\n\ local _, ok, result = tasks.join_any({ arm })\n\ assert(ok, tostring(result))\n\ return result\n\ @@ -56,7 +56,7 @@ const TRACE_12: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ assert(n == 2, 'the effective limit is 2, got ' .. n)\n\ local items = {}\n\ for i = 1, 10 do items[i] = tostring(i) end\n\ - local r = tasks.fanout('#### Sub', items)\n\ + local r = tasks.fanout('Sub', items)\n\ return table.concat(r, '|')\n\ ```\n\n\ #### Sub\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/happens_before.rs b/crates/runvil-internal/runtime/src/execute/tests/happens_before.rs index 211231976..074396793 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/happens_before.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/happens_before.rs @@ -34,7 +34,7 @@ const SEVEN_ARM_FANOUT: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n ## Parent\n\n\ ```lua\n\ local topics = { 'a', 'b', 'c', 'd', 'e', 'f', 'g' }\n\ - local results = tasks.fanout('### Worker', topics)\n\ + local results = tasks.fanout('Worker', topics)\n\ local parts = {}\n\ for i = 1, #results do parts[i] = store.read('research/' .. i .. '.md') end\n\ store.write('research.md', table.concat(parts, '\\n\\n'))\n\ @@ -60,7 +60,7 @@ const FREE_FORM: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ local topics = { 'a', 'b', 'c', 'd', 'e', 'f', 'g' }\n\ local set = {}\n\ for i, topic in ipairs(topics) do\n\ - set[i] = tasks.spawn('### Worker', { item = topic, index = i })\n\ + set[i] = tasks.spawn('Worker', { item = topic, index = i })\n\ end\n\ tasks.join(set)\n\ local parts = {}\n\ @@ -81,9 +81,9 @@ const PIPELINE: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Pipeline\n\n\ ## Parent\n\n\ ```lua\n\ - local g = tasks.spawn('### Gather')\n\ + local g = tasks.spawn('Gather')\n\ tasks.join({ g })\n\ - local s = tasks.spawn('### Summarize')\n\ + local s = tasks.spawn('Summarize')\n\ tasks.join({ s })\n\ return store.read('summary.md')\n\ ```\n\n\ @@ -106,8 +106,8 @@ const TRACE_5_PASS: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Waits\n\n\ ## Main\n\n\ ```lua\n\ - local a = tasks.spawn('## Arm', { item = 'a' })\n\ - local b = tasks.spawn('## Arm', { item = 'b' })\n\ + local a = tasks.spawn('../Arm', { item = 'a' })\n\ + local b = tasks.spawn('../Arm', { item = 'b' })\n\ local first, ok, result = tasks.join_any({ a, b })\n\ assert(ok, tostring(result))\n\ if first.task == a.task then return store.read('a.txt') end\n\ @@ -127,8 +127,8 @@ const TRACE_5_FAIL: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Waits\n\n\ ## Main\n\n\ ```lua\n\ - local a = tasks.spawn('## Arm', { item = 'a' })\n\ - local b = tasks.spawn('## Arm', { item = 'b' })\n\ + local a = tasks.spawn('../Arm', { item = 'a' })\n\ + local b = tasks.spawn('../Arm', { item = 'b' })\n\ local first, ok, result = tasks.join_any({ a, b })\n\ assert(ok, tostring(result))\n\ if first.task == a.task then return store.read('b.txt') end\n\ @@ -148,7 +148,7 @@ const TRACE_7: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Trace 7\n\n\ ```lua\n\ store.write('config.md', 'configured')\n\ - var.t = tasks.spawn('## Task')\n\ + var.t = tasks.spawn('Task')\n\ ```\n\n\ ## Result\n\n\ ```lua\n\ @@ -172,11 +172,11 @@ const TRACE_10_PASS: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Timed\n\n\ ## Main\n\n\ ```lua\n\ - local b = tasks.spawn('## Quick')\n\ + local b = tasks.spawn('../Quick')\n\ local _, ok, result = tasks.join_any({ b })\n\ assert(ok and result == 'quick arm', tostring(result))\n\ local btext = store.read('b.txt')\n\ - local a = tasks.spawn('## Slow')\n\ + local a = tasks.spawn('../Slow')\n\ tasks.join({ a }, { timeout = 0.05 })\n\ local ok2 = pcall(tasks.join_any, { a })\n\ local atext = store.read('a.txt')\n\ @@ -201,7 +201,7 @@ const TRACE_10_FAIL: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Timed\n\n\ ## Main\n\n\ ```lua\n\ - local a = tasks.spawn('## Slow')\n\ + local a = tasks.spawn('../Slow')\n\ tasks.join({ a }, { timeout = 0.05 })\n\ return store.read('a.txt')\n\ ```\n\n\ @@ -217,7 +217,7 @@ const TRACE_11: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Cancel\n\n\ ## Main\n\n\ ```lua\n\ - local a = tasks.spawn('## Task')\n\ + local a = tasks.spawn('../Task')\n\ store.exists('p1')\n\ store.exists('p2')\n\ tasks.cancel(a)\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/live_infer.rs b/crates/runvil-internal/runtime/src/execute/tests/live_infer.rs index 1c6e82a27..39c6fad92 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/live_infer.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/live_infer.rs @@ -72,7 +72,7 @@ fn unread_h1_prose_stays_inert_and_explicit_infer_requires_a_model() { #[test] fn a_library_function_resolves_engine_globals_when_called() { let source = PromptSource::new() - .section("Result", "require '## helpers'\nreturn read_args()") + .section("Result", "require '/helpers'\nreturn read_args()") .section("helpers", "function read_args() return args end"); let trace = Scenario::new(source.build()) .args("later canned value") @@ -90,7 +90,7 @@ fn a_library_calls_engine_globals_at_load_time() { let source = PromptSource::new() .section( "Result", - "require '## loader'\nreturn store.read('loaded.txt')", + "require '/loader'\nreturn store.read('loaded.txt')", ) .section( "loader", @@ -120,18 +120,18 @@ fn tool_objects_and_roles_reach_section_call_and_fanout_vms() { .section( "Parent", " - require '## bindings' + require '/bindings' local direct = binding_names() - local called = call('## Called') - local arms = tasks.fanout('### Worker', prompt.list('### Items')) + local called = call('../Called') + local arms = tasks.fanout('Worker', prompt.section('Items', 'list').items) return direct .. '|' .. called .. '|' .. table.concat(arms, ',')", ) .section( "### Worker", - "require '## bindings'\nreturn binding_names() .. ':' .. item", + "require '/bindings'\nreturn binding_names() .. ':' .. item", ) .prose("### Items", "- one\n- two") - .section("Called", "require '## bindings'\nreturn binding_names()") + .section("Called", "require '/bindings'\nreturn binding_names()") .section( "bindings", "function binding_names() return tools.get('tools/echo').id .. ':' .. models.get('default').name end", diff --git a/crates/runvil-internal/runtime/src/execute/tests/model_and_reply-globals.rs b/crates/runvil-internal/runtime/src/execute/tests/model_and_reply-globals.rs index c516b1006..f0bd78c5c 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/model_and_reply-globals.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/model_and_reply-globals.rs @@ -26,7 +26,7 @@ fn a_library_function_sees_sys_model_unknown_before_scope_close() { "Only", " ```lua - require '## sys_reader' + require '/sys_reader' return read_sys_model() ``` @@ -102,7 +102,7 @@ fn fanout_arm_sees_the_model_name_in_sys_model_after_the_scope_install() { .section( "Parent", " - local r = tasks.fanout('### Worker', prompt.list('### Items')) + local r = tasks.fanout('Worker', prompt.section('Items', 'list').items) return table.concat(r, ',')", ) .prose( @@ -129,7 +129,7 @@ fn fanout_item_substitution_renders_a_table_member_as_compact_json() { .section( "Parent", " - local r = tasks.fanout('### Worker', {{7, 'x'}}) + local r = tasks.fanout('Worker', {{7, 'x'}}) return r[1].text", ) .prose( diff --git a/crates/runvil-internal/runtime/src/execute/tests/model_and_reply.rs b/crates/runvil-internal/runtime/src/execute/tests/model_and_reply.rs index acb0a706b..994672c79 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/model_and_reply.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/model_and_reply.rs @@ -230,7 +230,7 @@ fn a_required_helper_survives_prologue_model_and_epilog() { "Only", " ```lua - require '## helpers' + require '/helpers' var.question = decorate(args) ``` @@ -259,11 +259,11 @@ fn a_required_helper_survives_prologue_model_and_epilog() { ("Only".to_owned(), detail::LUA_CHUNK_STARTED.to_string()), ( "Only".to_owned(), - "Lua library load started: ## helpers".to_owned(), + "Lua library load started: /helpers".to_owned(), ), ( "Only".to_owned(), - "Lua library load succeeded: ## helpers".to_owned(), + "Lua library load succeeded: /helpers".to_owned(), ), ("Only".to_owned(), detail::LUA_CHUNK_SUCCEEDED.to_string(),), ("Only".to_owned(), detail::LUA_CHUNK_STARTED.to_string()), diff --git a/crates/runvil-internal/runtime/src/execute/tests/offering-packs.rs b/crates/runvil-internal/runtime/src/execute/tests/offering-packs.rs index 4f5dffdf8..c8bb9334f 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/offering-packs.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/offering-packs.rs @@ -30,11 +30,11 @@ fn an_offered_pack_reaches_only_the_rounds_of_the_section_entry_that_offered_it( ## A\n\n\ ```lua\n\ tools.offer('kit/a')\n\ - require '## offers'\n\ + require '/offers'\n\ tools.offer('kit')\n\ {a}\ - call('## Called')\n\ - tasks.join({{ tasks.spawn('## Worker') }})\n\ + call('../Called')\n\ + tasks.join({{ tasks.spawn('../Worker') }})\n\ ```\n\n\ ## B\n\n\ ```lua\n{b}return 'ok'\n```\n\n\ @@ -43,7 +43,7 @@ fn an_offered_pack_reaches_only_the_rounds_of_the_section_entry_that_offered_it( ## Called\n\n\ ```lua\n{called}return 'called'\n```\n\n\ ## Worker\n\n\ - ```lua\nrequire '## offers'\n{worker}return 'worker'\n```\n", + ```lua\nrequire '/offers'\n{worker}return 'worker'\n```\n", h1 = round("Packs"), a = round("A"), b = round("B"), diff --git a/crates/runvil-internal/runtime/src/execute/tests/parallel_tool_calls-withdrawn.rs b/crates/runvil-internal/runtime/src/execute/tests/parallel_tool_calls-withdrawn.rs index 9adcd64b7..25f911bcd 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/parallel_tool_calls-withdrawn.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/parallel_tool_calls-withdrawn.rs @@ -31,7 +31,7 @@ fn withdrawals(step: Step) -> (Vec<(EffectId, Effect)>, Vec) { /// tool-call batch, then cancels Child and lingers on a second round. const CANCELED_CHILD: &str = "---\nname: loop\ndescription: d\nrunvil: 0\n---\n\n# Loop\n\n\ ## Main\n\n```lua\n\ - local t = tasks.spawn('## Child')\n\ + local t = tasks.spawn('../Child')\n\ models.infer('wait')\n\ tasks.cancel(t)\n\ models.infer('linger')\n\ @@ -226,8 +226,8 @@ fn the_step_that_decides_the_run_withdraws_every_outstanding_effect() { /// model round, then cancels B before A and lingers on a second round. const CANCELED_PAIR: &str = "---\nname: loop\ndescription: d\nrunvil: 0\n---\n\n# Loop\n\n\ ## Main\n\n```lua\n\ - local a = tasks.spawn('## A')\n\ - local b = tasks.spawn('## B')\n\ + local a = tasks.spawn('../A')\n\ + local b = tasks.spawn('../B')\n\ models.infer('wait')\n\ tasks.cancel(b)\n\ tasks.cancel(a)\n\ @@ -298,9 +298,9 @@ fn one_step_lists_every_tasks_withdrawn_calls_in_effect_id_order() { /// step. const DECIDED_IN_ONE_STEP: &str = "---\nname: loop\ndescription: d\nrunvil: 0\n---\n\n# Loop\n\n\ ## Main\n\n```lua\n\ - local stored = tasks.spawn('## Store')\n\ - local called = tasks.spawn('## Call')\n\ - local fast = tasks.spawn('## Fast')\n\ + local stored = tasks.spawn('../Store')\n\ + local called = tasks.spawn('../Call')\n\ + local fast = tasks.spawn('../Fast')\n\ local _t, _ok, result = tasks.join_any({ fast }, { timeout = 30 })\n\ tasks.cancel(stored)\n\ tasks.cancel(called)\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/parallel_tool_calls.rs b/crates/runvil-internal/runtime/src/execute/tests/parallel_tool_calls.rs index 7fd589804..f2acae46d 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/parallel_tool_calls.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/parallel_tool_calls.rs @@ -235,7 +235,7 @@ fn adhoc_calls_run_alone_between_their_neighbors() { fn canceling_a_task_parked_on_a_batch_orphans_every_call() { let md = "---\nname: loop\ndescription: d\nrunvil: 0\n---\n\n# Loop\n\n\ ## Main\n\n```lua\n\ - local t = tasks.spawn('## Child')\n\ + local t = tasks.spawn('../Child')\n\ models.infer('wait')\n\ tasks.cancel(t)\n\ models.infer('linger')\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/preludes.rs b/crates/runvil-internal/runtime/src/execute/tests/preludes.rs index eb4b114be..1b718a1cc 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/preludes.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/preludes.rs @@ -156,9 +156,9 @@ fn a_library_a_fanout_arm_and_a_spawned_task_all_see_a_prelude_global() { # Preludes\n\n\ ## Main\n\n\ ```lua\n\ - require '## greetings'\n\ - local arms = tasks.fanout('### Arm', { 'arm' })\n\ - local t = tasks.spawn('## Child')\n\ + require '/greetings'\n\ + local arms = tasks.fanout('Arm', { 'arm' })\n\ + local t = tasks.spawn('../Child')\n\ local _, ok, child = tasks.join_any({ t })\n\ assert(ok, tostring(child))\n\ return library_greeting() .. '|' .. arms[1].text .. '|' .. child\n\ @@ -186,7 +186,7 @@ fn a_prelude_write_across_two_arms_ends_the_run_with_determinism_caught_or_not() # Preludes\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', { 'alpha', 'beta' })\n\ + local r = tasks.fanout('Worker', { 'alpha', 'beta' })\n\ return r[1].text\n\ ```\n\n\ ### Worker\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/provenance.rs b/crates/runvil-internal/runtime/src/execute/tests/provenance.rs index 1c2e561e9..4d621a5e7 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/provenance.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/provenance.rs @@ -57,7 +57,7 @@ fn provenance_seq_is_strictly_increasing_within_one_task_across_a_fanout() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'a', 'b', 'c'})\n\ + local r = tasks.fanout('Worker', {'a', 'b', 'c'})\n\ return r[1].text .. r[2].text .. r[3].text\n\ ```\n\n\ ### Worker\n\n\ @@ -128,7 +128,7 @@ fn a_call_child_reports_under_its_callers_task() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Call\n\n\ ## Outer\n\n\ - ```lua\nreturn call('## Inner')\n```\n\n\ + ```lua\nreturn call('../Inner')\n```\n\n\ ## Inner\n\n\ ```lua\nlog('inner ran')\nreturn 'hello'\n```\n"; let trace = scheduler_scenario(md).run(); diff --git a/crates/runvil-internal/runtime/src/execute/tests/rendered_tools.rs b/crates/runvil-internal/runtime/src/execute/tests/rendered_tools.rs index 1f61d23f0..b38077fe5 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/rendered_tools.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/rendered_tools.rs @@ -133,7 +133,7 @@ fn after_the_render_every_offer_raises_even_an_identical_one_while_defining_stil function() tools.offer('tools') end, function() tools.offer(tools.adhoc({ name = 'late', description = 'Late' }, function() return '' end)) end, function() tools.offer_dynamic('tools') end, - function() require '## offers' end, + function() require '/offers' end, }) do local _, err = pcall(offer) out[#out + 1] = err.kind .. ':' .. tostring(err) diff --git a/crates/runvil-internal/runtime/src/execute/tests/run_termination.rs b/crates/runvil-internal/runtime/src/execute/tests/run_termination.rs index d9f76f976..dcc1d30aa 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/run_termination.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/run_termination.rs @@ -23,7 +23,7 @@ const CHILD_PARKED_ON_ROUND: &str = "---\nname: t\ndescription: d\nrunvil: 0\n-- # Termination\n\n\ ## Spawner\n\n\ ```lua\n\ - local t = tasks.spawn('## Child')\n\ + local t = tasks.spawn('../Child')\n\ tasks.join_any({ t })\n\ return 'unreachable'\n\ ```\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/chain_model.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/chain_model.rs index f4342b4d3..a8c4abce3 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/chain_model.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/chain_model.rs @@ -54,8 +54,8 @@ fn a_call_child_and_a_spawned_task_inherit_the_parents_switch() { ( "## Main", "models.use('strong')\n\ - local viaCall = call('## Child')\n\ - local t = tasks.spawn('## Child')\n\ + local viaCall = call('../Child')\n\ + local t = tasks.spawn('../Child')\n\ local _, ok, viaTask = tasks.join_any({ t })\n\ assert(ok)\n\ return viaCall .. '|' .. viaTask", @@ -72,10 +72,10 @@ fn a_childs_switch_never_reaches_its_parent_or_a_sibling() { ( "## Main", "models.use('strong')\n\ - local viaCall = call('## Switcher')\n\ + local viaCall = call('../Switcher')\n\ local after = models.current().label\n\ - local a = tasks.spawn('## Switcher')\n\ - local b = tasks.spawn('## Reader')\n\ + local a = tasks.spawn('../Switcher')\n\ + local b = tasks.spawn('../Reader')\n\ local _, okA, labelA = tasks.join_any({ a })\n\ local _, okB, labelB = tasks.join_any({ b })\n\ assert(okA and okB)\n\ @@ -104,7 +104,7 @@ fn a_switch_in_h1_is_the_starting_model_of_the_root_walk() { #[test] fn a_switch_in_h1_reaches_the_walk_a_jump_starts() { let md = sections(&[ - ("", "models.use('strong')\njump('## Target')"), + ("", "models.use('strong')\njump('Target')"), ("## Skipped", "error('the jump must skip this section')"), ("## Target", "return models.current().label"), ]); @@ -123,8 +123,8 @@ fn a_queued_task_keeps_the_model_its_parent_had_when_it_spawned_it() { "## Main", "tasks.concurrency(1)\n\ models.use('strong')\n\ - local a = tasks.spawn('## Child')\n\ - local b = tasks.spawn('## Child')\n\ + local a = tasks.spawn('../Child')\n\ + local b = tasks.spawn('../Child')\n\ models.use('fast')\n\ local _, okA, first = tasks.join_any({ a })\n\ local _, okB, second = tasks.join_any({ b })\n\ @@ -148,7 +148,7 @@ fn all_arms_of_one_fanout_start_on_the_same_model() { "## Main", "tasks.concurrency(1)\n\ models.use('strong')\n\ - local r = tasks.fanout('### Arm', {'a', 'b', 'c'})\n\ + local r = tasks.fanout('Arm', {'a', 'b', 'c'})\n\ return table.concat(r, ',')", ), ("### Arm", "return models.current().label"), diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/concurrency-stepped.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/concurrency-stepped.rs index f5a1a8042..cb573d58d 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/concurrency-stepped.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/concurrency-stepped.rs @@ -18,14 +18,14 @@ fn two_tasks_whose_call_children_finish_in_any_order_both_complete() { ## Main\n\n\ ```lua\n\ var.who = 'a'\n\ - local a = tasks.spawn('### Caller')\n\ + local a = tasks.spawn('Caller')\n\ var.who = 'b'\n\ - local b = tasks.spawn('### Caller')\n\ + local b = tasks.spawn('Caller')\n\ local results = tasks.join({ a, b })\n\ return results[1].result .. '|' .. results[2].result\n\ ```\n\n\ ### Caller\n\n\ - ```lua\nreturn call('#### Leaf')\n```\n\n\ + ```lua\nreturn call('Leaf')\n```\n\n\ #### Leaf\n\n\ ```lua\nreturn models.infer(var.who)\n```\n"; for batching in [ @@ -58,7 +58,7 @@ fn a_queued_task_that_can_never_be_admitted_is_reported_as_a_stall() { ## Main\n\n\ ```lua\n\ store.write('park', 'x')\n\ - local t = tasks.spawn('## Child')\n\ + local t = tasks.spawn('../Child')\n\ tasks.join_any({ t })\n\ return 'never'\n\ ```\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/concurrency.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/concurrency.rs index 1bfe00d93..17130c8a2 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/concurrency.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/concurrency.rs @@ -95,8 +95,8 @@ fn a_queued_task_reads_blocked_queued_and_its_start_event_fires_at_admission() { # Limits\n\n\ ## Main\n\n\ ```lua\n\ - local a = tasks.spawn('## Child')\n\ - local b = tasks.spawn('## Child')\n\ + local a = tasks.spawn('../Child')\n\ + local b = tasks.spawn('../Child')\n\ local s = tasks.status(b)\n\ assert(s.state == 'running', s.state)\n\ assert(s.blocked == 'queued', tostring(s.blocked))\n\ @@ -151,8 +151,8 @@ fn a_start_event_reports_the_spawning_section_not_the_admission_time_one() { # Sections\n\n\ ## Main\n\n\ ```lua\n\ - var.t = tasks.spawn('### Worker')\n\ - jump('## Next')\n\ + var.t = tasks.spawn('Worker')\n\ + jump('../Next')\n\ ```\n\n\ ### Worker\n\n\ ```lua\nreturn 'work'\n```\n\n\ @@ -200,7 +200,7 @@ fn a_resumed_task_is_admitted_ahead_of_fresh_starts() { # Resume\n\n\ ## Main\n\n\ ```lua\n\ - local w = tasks.spawn('### Walker')\n\ + local w = tasks.spawn('Walker')\n\ local _, ok = tasks.join_any({ w })\n\ assert(ok)\n\ return 'done'\n\ @@ -208,7 +208,7 @@ fn a_resumed_task_is_admitted_ahead_of_fresh_starts() { ### Walker\n\n\ ```lua\n\ local set = {}\n\ - for i = 1, 3 do set[i] = tasks.spawn('#### Sub', { index = i }) end\n\ + for i = 1, 3 do set[i] = tasks.spawn('Sub', { index = i }) end\n\ for i = 1, 3 do\n\ tasks.join_any({ set[i] })\n\ models.infer('W' .. i)\n\ @@ -254,7 +254,7 @@ fn an_arm_that_lowers_its_limit_runs_its_fanout_two_arms_at_a_time() { # Limits\n\n\ ## Main\n\n\ ```lua\n\ - local arm = tasks.spawn('### Arm')\n\ + local arm = tasks.spawn('Arm')\n\ local _, ok = tasks.join_any({ arm })\n\ assert(ok)\n\ return 'done'\n\ @@ -263,7 +263,7 @@ fn an_arm_that_lowers_its_limit_runs_its_fanout_two_arms_at_a_time() { ```lua\n\ local n = tasks.concurrency(2)\n\ assert(n == 2, 'the effective limit is 2, got ' .. n)\n\ - local r = tasks.fanout('#### Sub', {'x', 'y', 'z', 'w'})\n\ + local r = tasks.fanout('Sub', {'x', 'y', 'z', 'w'})\n\ return table.concat(r, ',')\n\ ```\n\n\ #### Sub\n\n\ @@ -297,12 +297,12 @@ fn a_nested_fanout_does_not_deadlock_under_a_ceiling_of_one() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Arm', {'a', 'b', 'c'})\n\ + local r = tasks.fanout('Arm', {'a', 'b', 'c'})\n\ return table.concat(r, ',')\n\ ```\n\n\ ### Arm\n\n\ ```lua\n\ - local inner = tasks.fanout('#### Sub', {'x', 'y', 'z'})\n\ + local inner = tasks.fanout('Sub', {'x', 'y', 'z'})\n\ local parts = {}\n\ for i = 1, #inner do parts[i] = inner[i].text end\n\ return item .. ':' .. table.concat(parts, '+')\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/failures.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/failures.rs index 24f43a45a..2db1433e9 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/failures.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/failures.rs @@ -18,7 +18,7 @@ fn two_arms_appending_one_path_boom_without_any_other_suspension() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'alpha', 'beta'})\n\ + local r = tasks.fanout('Worker', {'alpha', 'beta'})\n\ return table.concat(r, ',')\n\ ```\n\n\ ### Worker\n\n\ @@ -52,7 +52,7 @@ fn an_arm_rewriting_its_own_path_succeeds() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'only'})\n\ + local r = tasks.fanout('Worker', {'only'})\n\ return r[1].text\n\ ```\n\n\ ### Worker\n\n\ @@ -75,8 +75,8 @@ fn sequential_fanouts_may_write_one_path() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local a = tasks.fanout('### Worker', {'one'})\n\ - local b = tasks.fanout('### Worker', {'two'})\n\ + local a = tasks.fanout('Worker', {'one'})\n\ + local b = tasks.fanout('Worker', {'two'})\n\ return b[1].text\n\ ```\n\n\ ### Worker\n\n\ @@ -100,7 +100,7 @@ fn fatal_arm_aborts_queued_siblings() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Fanout\n\n\ ## Parent\n\n\ - ```lua\ntasks.fanout('### Worker', {'boom', 'beta', 'gamma'})\n```\n\n\ + ```lua\ntasks.fanout('Worker', {'boom', 'beta', 'gamma'})\n```\n\n\ ### Worker\n\n\ ```lua\n\ store.append('log.txt', item .. '\\n')\n\ @@ -151,7 +151,7 @@ fn fatal_arm_aborts_an_in_flight_sibling() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Fanout\n\n\ ## Parent\n\n\ - ```lua\ntasks.fanout('### Worker', {'boom', 'slow'})\n```\n\n\ + ```lua\ntasks.fanout('Worker', {'boom', 'slow'})\n```\n\n\ ### Worker\n\n\ ```lua\n\ local a = models.infer(item .. ':1')\n\ @@ -221,7 +221,7 @@ fn a_caught_fanout_failure_lets_the_caller_continue() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local ok, err = pcall(tasks.fanout, '### Worker', {'boom', 'slow'})\n\ + local ok, err = pcall(tasks.fanout, 'Worker', {'boom', 'slow'})\n\ assert(not ok, 'the fatal arm error reaches the caller')\n\ local a = models.infer('after')\n\ return 'caught:' .. a\n\ @@ -254,7 +254,7 @@ fn cancellation_while_suspended_in_a_fanout_arm_cancels_the_run() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'one', 'two'})\n\ + local r = tasks.fanout('Worker', {'one', 'two'})\n\ return r[1].text\n\ ```\n\n\ ### Worker\n\n\ @@ -319,7 +319,7 @@ fn a_spawn_failure_mid_fanout_cancels_the_queued_arms() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local ok, err = pcall(tasks.fanout, '### Worker', {'one', 'two', 'three'})\n\ + local ok, err = pcall(tasks.fanout, 'Worker', {'one', 'two', 'three'})\n\ assert(not ok, 'the spawn failure reaches the caller')\n\ return 'caught:' .. models.infer('after')\n\ ```\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout/arms.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout/arms.rs index ec3f09106..116401d1d 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout/arms.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout/arms.rs @@ -13,7 +13,7 @@ fn pre_canceled_fanout_returns_canceled() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'alpha', 'beta'})\n\ + local r = tasks.fanout('Worker', {'alpha', 'beta'})\n\ return r[1].text\n\ ```\n\n\ ### Worker\n\n\ @@ -38,7 +38,7 @@ fn model_required_when_arm_infer_has_no_binding() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - tasks.fanout('### Worker', {'alpha'})\n\ + tasks.fanout('Worker', {'alpha'})\n\ ```\n\n\ ### Worker\n\n\ Ask the model about {{ item }}.\n\n\ @@ -66,12 +66,12 @@ fn a_library_required_in_an_arm_sees_the_arm_item() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'alpha'})\n\ + local r = tasks.fanout('Worker', {'alpha'})\n\ return r[1].text\n\ ```\n\n\ ### Worker\n\n\ ```lua\n\ - require '## capture'\n\ + require '/capture'\n\ return tostring(captured_by_library) .. '|' .. tostring(item)\n\ ```\n\n\ ## capture\n\n\ @@ -100,12 +100,12 @@ fn a_jump_inside_a_fanout_arm_drives_a_child_walk() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'alpha'})\n\ + local r = tasks.fanout('Worker', {'alpha'})\n\ return r[1].text\n\ ```\n\n\ ### Worker\n\n\ ```lua\n\ - jump('### Target')\n\ + jump('../Target')\n\ error('the arm remaining blocks are skipped')\n\ ```\n\n\ ### Target\n\n\ @@ -138,12 +138,12 @@ fn a_jump_from_an_arm_to_a_worker_child_walks_the_child_slice() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'alpha'})\n\ + local r = tasks.fanout('Worker', {'alpha'})\n\ return r[1].text\n\ ```\n\n\ ### Worker\n\n\ ```lua\n\ - jump('#### Child')\n\ + jump('Child')\n\ error('the arm remaining blocks are skipped')\n\ ```\n\n\ #### Child\n\n\ @@ -178,7 +178,7 @@ fn fanout_hash_collection_iterates_in_sorted_key_order() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', { zeta = 1, alpha = 2, mid = 3 })\n\ + local r = tasks.fanout('Worker', { zeta = 1, alpha = 2, mid = 3 })\n\ assert(r[1].item.key == 'alpha' and r[3].item.key == 'zeta', 'results land by sorted key')\n\ return table.concat(r, ',')\n\ ```\n\n\ @@ -203,7 +203,7 @@ fn fanout_results_are_sealed_against_writes_and_metatable_replacement() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'alpha', 'beta'})\n\ + local r = tasks.fanout('Worker', {'alpha', 'beta'})\n\ local ok, err = pcall(function() r[1].text = 'forged' end)\n\ assert(not ok and tostring(err):find('tasks.fanout results are read-only', 1, true), 'a write raises: ' .. tostring(err))\n\ ok, err = pcall(setmetatable, r[1], nil)\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout/failure_semantics.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout/failure_semantics.rs index 85f291338..ef3d535b2 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout/failure_semantics.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout/failure_semantics.rs @@ -15,7 +15,7 @@ fn fanout_empty_collection_errors_before_any_scheduling() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Fanout\n\n\ ## Parent\n\n\ - ```lua\ntasks.fanout('### Worker', {})\n```\n\n\ + ```lua\ntasks.fanout('Worker', {})\n```\n\n\ ### Worker\n\n\ ```lua\nstore.write('ran.txt', 'yes')\n```\n"; let trace = scheduler_scenario(md).run(); @@ -38,28 +38,6 @@ fn fanout_empty_collection_errors_before_any_scheduling() { ); } -#[test] -fn fanout_worker_that_is_a_list_section_errors() { - // Pin of the worker-template guard: a resolved list section is not a - // worker template. - let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ - # Fanout\n\n\ - ## Parent\n\n\ - ```lua\ntasks.fanout('### Items', {'x'})\n```\n\n\ - ### Items\n\n\ - - a\n\ - - b\n"; - let trace = scheduler_scenario(md).run(); - - let error = trace.error(); - assert!( - error - .to_string() - .contains("is a list section, not a worker template"), - "error was: {error}" - ); -} - /// Alpha and Beta ping-pong calls through nested call chains, and the /// chain that lands at depth 8 calls fanout, on prompt line 13, where each /// arm would run one level deeper than the cap allows. @@ -68,15 +46,15 @@ const DEPTH_FANOUT: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ ## Alpha\n\n\ ```lua\n\ var.n = (var.n or 0) + 1\n\ - if var.n >= 9 then return tasks.fanout('### Worker', {'x'}) end\n\ - return call('## Beta')\n\ + if var.n >= 9 then return tasks.fanout('Worker', {'x'}) end\n\ + return call('../Beta')\n\ ```\n\n\ ### Worker\n\n\ ```lua\nreturn item\n```\n\n\ ## Beta\n\n\ ```lua\n\ var.n = var.n + 1\n\ - return call('## Alpha')\n\ + return call('../Alpha')\n\ ```\n"; #[test] @@ -140,7 +118,7 @@ fn an_exhausted_arm_becomes_the_incomplete_stub_and_its_sibling_still_lands() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {{'loop', 'plain'}})\n\ + local r = tasks.fanout('Worker', {{'loop', 'plain'}})\n\ assert(#r == 2, 'both slots are filled')\n\ assert(r[1].ok == false and r[1].exhausted == true, 'the exhausted arm is flagged')\n\ assert(r[1].item == 'loop', 'the stub keeps its item')\n\ @@ -220,7 +198,7 @@ fn two_arms_writing_one_path_terminate_the_run_with_a_determinism_violation() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'alpha', 'beta'})\n\ + local r = tasks.fanout('Worker', {'alpha', 'beta'})\n\ return r[1].text\n\ ```\n\n\ ### Worker\n\n\ @@ -285,7 +263,7 @@ fn two_live_arms_appending_one_path_terminate_with_a_determinism_violation() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'alpha', 'beta'})\n\ + local r = tasks.fanout('Worker', {'alpha', 'beta'})\n\ return r[1].text\n\ ```\n\n\ ### Worker\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout/scheduling.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout/scheduling.rs index bade128ee..f7196f352 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout/scheduling.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout/scheduling.rs @@ -15,7 +15,7 @@ fn fanout_results_follow_collection_order_not_finish_order() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'one', 'two'})\n\ + local r = tasks.fanout('Worker', {'one', 'two'})\n\ return r[1].text .. '|' .. r[2].text\n\ ```\n\n\ ### Worker\n\n\ @@ -48,7 +48,7 @@ fn fanout_arms_interleave_at_io_points_on_one_thread() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'one', 'two'})\n\ + local r = tasks.fanout('Worker', {'one', 'two'})\n\ return r[1].text .. '|' .. r[2].text\n\ ```\n\n\ ### Worker\n\n\ @@ -88,7 +88,7 @@ fn the_admission_limit_gates_the_arms_a_fanout_runs_at_once() { # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'x', 'y', 'z'})\n\ + local r = tasks.fanout('Worker', {'x', 'y', 'z'})\n\ return r[1].text .. '|' .. r[2].text .. '|' .. r[3].text\n\ ```\n\n\ ### Worker\n\n\ @@ -124,7 +124,7 @@ fn fanout_arms_take_child_ids_in_collection_order_per_fanout_index_and_structure # Fanout\n\n\ ## Parent\n\n\ ```lua\n\ - local r = tasks.fanout('### Worker', {'a', 'b'})\n\ + local r = tasks.fanout('Worker', {'a', 'b'})\n\ assert(r[1].ok and r[2].ok, 'both arms succeed')\n\ assert(r[2].item == 'b', 'the result object holds the item')\n\ store.append('ids.txt', 'parent:' .. sys.id .. '\\n')\n\ @@ -173,7 +173,7 @@ fn fanout_over_a_large_collection_admits_arms_under_the_ceiling() { ```lua\n\ local items = {}\n\ for i = 1, 1025 do items[i] = tostring(i) end\n\ - local r = tasks.fanout('### Worker', items)\n\ + local r = tasks.fanout('Worker', items)\n\ return #r .. ':' .. r[1].text .. ':' .. r[1025].text\n\ ```\n\n\ ### Worker\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout_model.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout_model.rs index 7c06f490f..1235bb5c5 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout_model.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/fanout_model.rs @@ -11,7 +11,7 @@ fn a_three_argument_fanout_runs_every_arm_on_the_given_model() { let md = sections(&[ ( "## Main", - "local r = tasks.fanout(models.get('fast'), '### Arm', {'a', 'b', 'c'})\n\ + "local r = tasks.fanout(models.get('fast'), 'Arm', {'a', 'b', 'c'})\n\ return table.concat(r, ',')", ), ("### Arm", "return models.current().model_id"), @@ -26,7 +26,7 @@ fn the_parents_model_after_a_three_argument_fanout_is_what_it_was_before() { ( "## Main", "models.use('strong')\n\ - local r = tasks.fanout(models.get('fast'), '### Arm', {'a', 'b'})\n\ + local r = tasks.fanout(models.get('fast'), 'Arm', {'a', 'b'})\n\ return table.concat(r, ',') .. '|' .. models.current().label", ), ("### Arm", "return models.current().label"), @@ -47,14 +47,14 @@ fn a_spawn_failure_part_way_still_restores_the_parents_model() { if var.n == 1 then models.use('strong') end\n\ if var.n >= 9 then\n\ local before = models.current().label\n\ - local ok, err = pcall(tasks.fanout, models.get('fast'), '### Worker', {'x'})\n\ + local ok, err = pcall(tasks.fanout, models.get('fast'), 'Worker', {'x'})\n\ local cap = tostring(err):match('recursion exceeded cap of %d+')\n\ return before .. '|' .. tostring(ok) .. '|' .. tostring(cap) .. '|' .. models.current().label\n\ end\n\ - return call('## Beta')", + return call('../Beta')", ), ("### Worker", "return item"), - ("## Beta", "var.n = var.n + 1\nreturn call('## Alpha')"), + ("## Beta", "var.n = var.n + 1\nreturn call('../Alpha')"), ]); let trace = chain_scenario(&md).run(); assert_eq!( @@ -68,7 +68,7 @@ fn a_raw_id_handle_fans_out_with_no_model_supplied() { let md = sections(&[ ( "## Main", - "local r = tasks.fanout(models.get('vendor/raw-model'), '### Arm', {'a', 'b'})\n\ + "local r = tasks.fanout(models.get('vendor/raw-model'), 'Arm', {'a', 'b'})\n\ return table.concat(r, ',')", ), ("### Arm", "return models.current().model_id"), @@ -91,7 +91,7 @@ fn a_failing_arm_still_leaves_the_parents_model_as_it_was_before_the_call() { "## Main", "models.use('strong')\n\ local before = models.current().label\n\ - local ok = pcall(tasks.fanout, models.get('fast'), '### Arm', {'a'})\n\ + local ok = pcall(tasks.fanout, models.get('fast'), 'Arm', {'a'})\n\ return before .. '|' .. tostring(ok) .. '|' .. models.current().label", ), ("### Arm", "error('boom')"), @@ -107,11 +107,11 @@ fn a_first_argument_that_is_not_a_model_handle_is_refused() { "## Main", "local out = {}\n\ for _, first in ipairs({ 'fast', 5 }) do\n\ - local ok, err = pcall(tasks.fanout, first, '### Arm', {'a'})\n\ + local ok, err = pcall(tasks.fanout, first, 'Arm', {'a'})\n\ assert(not ok, 'a non-handle first argument must be refused')\n\ out[#out + 1] = tostring(err)\n\ end\n\ - local ok, err = pcall(tasks.fanout, nil, '### Arm', {'a'})\n\ + local ok, err = pcall(tasks.fanout, nil, 'Arm', {'a'})\n\ assert(not ok, 'a nil first argument must be refused')\n\ out[#out + 1] = tostring(err)\n\ return table.concat(out, '\\n')", @@ -135,8 +135,8 @@ fn an_argument_count_other_than_two_or_three_is_refused() { let md = sections(&[( "## Main", "local ok0, err0 = pcall(tasks.fanout)\n\ - local ok1, err1 = pcall(tasks.fanout, '### Arm')\n\ - local ok4, err4 = pcall(tasks.fanout, models.get('fast'), '### Arm', {'a'}, 4)\n\ + local ok1, err1 = pcall(tasks.fanout, 'Arm')\n\ + local ok4, err4 = pcall(tasks.fanout, models.get('fast'), 'Arm', {'a'}, 4)\n\ assert(not ok0 and not ok1 and not ok4, 'a wrong argument count must be refused')\n\ return tostring(err0) .. '\\n' .. tostring(err1) .. '\\n' .. tostring(err4)", )]); @@ -155,7 +155,7 @@ fn an_arms_own_switch_stays_inside_that_arm() { let md = sections(&[ ( "## Main", - "local r = tasks.fanout(models.get('fast'), '### Arm', {'a', 'b'})\n\ + "local r = tasks.fanout(models.get('fast'), 'Arm', {'a', 'b'})\n\ return table.concat(r, ',') .. '|' .. models.current().label", ), ( @@ -172,7 +172,7 @@ fn models_use_default_inside_an_arm_selects_the_default_role() { let md = sections(&[ ( "## Main", - "local r = tasks.fanout(models.get('fast'), '### Arm', {'a', 'b'})\n\ + "local r = tasks.fanout(models.get('fast'), 'Arm', {'a', 'b'})\n\ return table.concat(r, ',')", ), ( @@ -190,7 +190,7 @@ fn a_two_argument_fanout_gives_every_arm_the_parents_current_model() { ( "## Main", "models.use('strong')\n\ - local r = tasks.fanout('### Arm', {'a', 'b'})\n\ + local r = tasks.fanout('Arm', {'a', 'b'})\n\ return table.concat(r, ',')", ), ("### Arm", "return models.current().label"), diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/h1/control_flow.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/h1/control_flow.rs index 100e7061e..ca8e0383a 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/h1/control_flow.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/h1/control_flow.rs @@ -10,7 +10,7 @@ fn call_from_h1_runs_the_target_as_a_contained_chain() { // exactly as in any section. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Test prompt\n\n\ - ```lua\nvar.answer = call('## Answer')\n```\n\n\ + ```lua\nvar.answer = call('Answer')\n```\n\n\ ## Result\n\n\ ```lua\nreturn var.answer\n```\n\n\ ## Answer\n\n\ @@ -30,11 +30,11 @@ fn a_call_from_h1_and_a_call_from_the_first_walked_section_take_consecutive_chil // counter continues too: its first section is still `0.1`. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Test prompt\n\n\ - ```lua\nvar.first = call('## Answer')\n```\n\n\ + ```lua\nvar.first = call('Answer')\n```\n\n\ ## Result\n\n\ ```lua\n\ assert(sys.id == '0.1', 'the first walked section takes root entry 1')\n\ - return var.first .. ',' .. call('## Answer')\n\ + return var.first .. ',' .. call('../Answer')\n\ ```\n\n\ ## Answer\n\n\ ```lua\nreturn sys.id\n```\n"; @@ -54,13 +54,14 @@ fn call_from_h1_to_an_unknown_section_is_a_catchable_error() { // uncaught, it ends the run as the H1 gate failure. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Test prompt\n\n\ - ```lua\nlocal ok, err = pcall(call, '## Nope'); return tostring(ok) .. ':' .. tostring(err)\n```\n"; + ```lua\nlocal ok, err = pcall(call, 'Nope'); return tostring(ok) .. ':' .. tostring(err)\n```\n"; let trace = h1_scenario(md).run(); let out = trace.text(); assert!( - out.starts_with("false:") && out.contains("## Nope"), - "the caught error names the missing section: {out}" + out.starts_with("false:") + && out.contains("section `Nope` not found: the top level has no section `Nope`"), + "the caught error says where it looked: {out}" ); } @@ -70,7 +71,7 @@ fn jump_from_h1_starts_the_walk_at_the_target() { // top-level target, skipping the sections before it. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Test prompt\n\n\ - ```lua\njump('## Target')\n```\n\n\ + ```lua\njump('Target')\n```\n\n\ ## Skipped\n\n\ ```lua\nerror('the jump target must skip this section')\n```\n\n\ ## Target\n\n\ @@ -84,25 +85,27 @@ fn jump_from_h1_starts_the_walk_at_the_target() { fn jump_from_h1_to_an_unknown_section_fails_the_run() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Test prompt\n\n\ - ```lua\njump('## Nope')\n```\n"; + ```lua\njump('Nope')\n```\n"; let trace = h1_scenario(md).run(); let error = trace.error(); assert!( - error.to_string().contains("## Nope"), - "the failure names the missing section: {error}" + error + .to_string() + .contains("section `Nope` not found: the top level has no section `Nope`"), + "the failure says where it looked: {error}" ); } #[test] -fn a_jump_from_h1_to_a_missing_heading_fails_on_the_jump_line() { +fn a_jump_from_h1_to_a_missing_section_fails_on_the_jump_line() { // Jump-target resolution is the machinery around the chunk, not the // failed gate, so it keeps its own kind on the `jump` call's line. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Test prompt\n\n\ ```lua\n\ var.before = true\n\ - jump('## Nope')\n\ + jump('Nope')\n\ ```\n\n\ ## Result\n\n\ ```lua\nreturn 'unexpected'\n```\n"; @@ -124,7 +127,7 @@ fn fanout_from_h1_runs_the_worker_over_the_collection() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Test prompt\n\n\ ```lua\n\ - local r = tasks.fanout('## Worker', {'a', 'b'})\n\ + local r = tasks.fanout('Worker', {'a', 'b'})\n\ var.answer = r[1].text .. '|' .. r[2].text\n\ ```\n\n\ ## Result\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/h1/pass.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/h1/pass.rs index b19909506..12ede30c3 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/h1/pass.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/h1/pass.rs @@ -247,7 +247,7 @@ fn a_failing_library_required_from_h1_ends_the_run_as_requirements_unmet() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Gate\n\n\ ```lua\n\ - require '## broken'\n\ + require '/broken'\n\ var.ok = true\n\ ```\n\n\ ## broken\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/chains.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/chains.rs index 064598741..6eda614cb 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/chains.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/chains.rs @@ -13,7 +13,7 @@ fn a_return_inside_a_child_walk_ends_the_whole_chain() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Return\n\n\ ## A\n\n\ - ```lua\njump('### X')\n```\n\n\ + ```lua\njump('X')\n```\n\n\ ### X\n\n\ ```lua\nreturn 'x-value'\n```\n\n\ ## B\n\n\ @@ -37,11 +37,11 @@ fn jump_inside_call_is_contained_in_the_chain() { # Contained\n\n\ ## Main\n\n\ ```lua\n\ - local r = call('## Sub')\n\ + local r = call('../Sub')\n\ return 'main:' .. r\n\ ```\n\n\ ## Sub\n\n\ - ```lua\njump('## Peer')\n```\n\n\ + ```lua\njump('../Peer')\n```\n\n\ ## Skipped\n\n\ ```lua\nerror('the chain jump must move past me')\n```\n\n\ ## Peer\n\n\ @@ -64,11 +64,11 @@ fn jump_inside_a_call_chain_moves_within_the_chain() { # Move\n\n\ ## A\n\n\ ```lua\n\ - local r = call('## Sub')\n\ + local r = call('../Sub')\n\ return 'A:' .. r\n\ ```\n\n\ ## Sub\n\n\ - ```lua\njump('## Peer')\n```\n\n\ + ```lua\njump('../Peer')\n```\n\n\ ## Peer\n\n\ ```lua\nstore.append('order.txt', 'Peer\\n')\n```\n\n\ ## Tail\n\n\ @@ -100,7 +100,7 @@ fn call_chain_jumps_to_a_child_and_returns_the_chain_result() { ## A\n\n\ ```lua\n\ store.append('order.txt', 'A1\\n')\n\ - local r = call('## Sub')\n\ + local r = call('../Sub')\n\ assert(r == 's2-result', 'the chain final text returns to A')\n\ store.append('order.txt', 'A2\\n')\n\ ```\n\n\ @@ -112,7 +112,7 @@ fn call_chain_jumps_to_a_child_and_returns_the_chain_result() { ## Sub\n\n\ ```lua\n\ store.append('order.txt', 'Sub\\n')\n\ - jump('### S1')\n\ + jump('S1')\n\ ```\n\n\ ### S1\n\n\ ```lua\nstore.append('order.txt', 'S1\\n')\n```\n\n\ @@ -139,7 +139,7 @@ fn the_outer_walk_never_moves_during_a_contained_chain() { # Outer\n\n\ ## A\n\n\ ```lua\n\ - call('## Sub')\n\ + call('../Sub')\n\ store.append('order.txt', 'A-done\\n')\n\ ```\n\n\ ## B\n\n\ @@ -148,7 +148,7 @@ fn the_outer_walk_never_moves_during_a_contained_chain() { return store.read('order.txt')\n\ ```\n\n\ ## Sub\n\n\ - ```lua\njump('## Peer')\n```\n\n\ + ```lua\njump('../Peer')\n```\n\n\ ## Peer\n\n\ ```lua\n\ store.append('order.txt', 'Peer\\n')\n\ @@ -172,7 +172,7 @@ fn a_return_inside_a_chain_ends_the_chain_not_the_run() { # Scoped\n\n\ ## A\n\n\ ```lua\n\ - local r = call('## Sub')\n\ + local r = call('../Sub')\n\ store.append('order.txt', 'A:' .. r .. '\\n')\n\ ```\n\n\ ## B\n\n\ @@ -202,7 +202,7 @@ fn call_to_a_child_starts_a_contained_chain() { # ChildExecute\n\n\ ## Main\n\n\ ```lua\n\ - local r = call('### Sub')\n\ + local r = call('Sub')\n\ return 'got:' .. r\n\ ```\n\n\ ### Sub\n\n\ @@ -236,16 +236,16 @@ fn a_jump_descent_does_not_consume_call_depth() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Depth\n\n\ ## Main\n\n\ - ```lua\njump('### X')\n```\n\n\ + ```lua\njump('X')\n```\n\n\ ### X\n\n\ ```lua\n\ store.append('depth.txt', 'x\\n')\n\ - return call('### Y')\n\ + return call('../Y')\n\ ```\n\n\ ### Y\n\n\ ```lua\n\ store.append('depth.txt', 'y\\n')\n\ - return call('### X')\n\ + return call('../X')\n\ ```\n"; let rig = Rig::run(scheduler_scenario(md)); diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/child_levels.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/child_levels.rs index 1cd4eb6a9..d4c331d2b 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/child_levels.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/child_levels.rs @@ -19,7 +19,7 @@ fn jump_to_a_child_starts_the_child_level_walk() { ## A\n\n\ ```lua\n\ store.append('order.txt', 'A\\n')\n\ - jump('### X')\n\ + jump('X')\n\ ```\n\n\ ### X\n\n\ ```lua\nstore.append('order.txt', 'X\\n')\n```\n\n\ @@ -45,12 +45,12 @@ fn child_walk_recurses_to_h4() { ## A\n\n\ ```lua\n\ store.append('order.txt', 'A\\n')\n\ - jump('### X')\n\ + jump('X')\n\ ```\n\n\ ### X\n\n\ ```lua\n\ store.append('order.txt', 'X\\n')\n\ - jump('#### P')\n\ + jump('P')\n\ ```\n\n\ #### P\n\n\ ```lua\nstore.append('order.txt', 'P\\n')\n```\n\n\ @@ -76,11 +76,11 @@ fn a_child_level_that_runs_out_inside_a_call_chain_resumes_after_the_jumper() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # CallDescend\n\n\ ## Main\n\n\ - ```lua\nreturn 'main:' .. call('## Sub')\n```\n\n\ + ```lua\nreturn 'main:' .. call('../Sub')\n```\n\n\ ## Sub\n\n\ ```lua\n\ var.order = 'Sub'\n\ - jump('### S1')\n\ + jump('S1')\n\ ```\n\n\ ### S1\n\n\ ```lua\nvar.order = var.order .. ',S1'\n```\n\n\ @@ -102,13 +102,13 @@ fn a_child_level_that_runs_out_inside_a_task_chain_resumes_after_the_jumper() { # TaskDescend\n\n\ ## Main\n\n\ ```lua\n\ - local results = tasks.join({ tasks.spawn('## Worker') })\n\ + local results = tasks.join({ tasks.spawn('../Worker') })\n\ return 'main:' .. results[1].result\n\ ```\n\n\ ## Worker\n\n\ ```lua\n\ var.order = 'Worker'\n\ - jump('### W1')\n\ + jump('W1')\n\ ```\n\n\ ### W1\n\n\ ```lua\nvar.order = var.order .. ',W1'\n```\n\n\ @@ -129,14 +129,14 @@ fn a_child_level_whose_jumper_was_last_in_its_slice_unwinds_through_a_second_pop ## A\n\n\ ```lua\n\ var.order = 'A'\n\ - jump('### X')\n\ + jump('X')\n\ ```\n\n\ ### X\n\n\ ```lua\nvar.order = var.order .. ',X'\n```\n\n\ ### Y\n\n\ ```lua\n\ var.order = var.order .. ',Y'\n\ - jump('#### P')\n\ + jump('P')\n\ ```\n\n\ #### P\n\n\ ```lua\nvar.order = var.order .. ',P'\n```\n\n\ @@ -159,7 +159,7 @@ fn a_child_level_whose_jumper_was_last_at_the_root_ends_the_chain() { ## Last\n\n\ ```lua\n\ store.append('order.txt', 'Last\\n')\n\ - jump('### C')\n\ + jump('C')\n\ ```\n\n\ ### C\n\n\ ```lua\nstore.append('order.txt', 'C\\n')\n```\n"; @@ -179,7 +179,7 @@ fn the_chain_reports_the_section_it_resumes_in_after_a_pop() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Names\n\n\ ## A\n\n\ - ```lua\njump('### X')\n```\n\n\ + ```lua\njump('X')\n```\n\n\ ### X\n\n\ ```lua\nvar.x = 1\n```\n\n\ ## B\n\n\ @@ -203,7 +203,7 @@ fn jump_to_an_off_walk_child_runs_it() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # OffChild\n\n\ ## A\n\n\ - ```lua\njump('### Off')\n```\n\n\ + ```lua\njump('Off')\n```\n\n\ ### X\n\n\ ```lua\nstore.append('order.txt', 'X\\n')\n```\n\n\ ### Off\n\n\ @@ -225,12 +225,12 @@ fn running_child_addresses_its_own_siblings_and_children() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Visible\n\n\ ## A\n\n\ - ```lua\njump('### X')\n```\n\n\ + ```lua\njump('X')\n```\n\n\ ### X\n\n\ ```lua\n\ - local r = call('#### Grand')\n\ + local r = call('Grand')\n\ store.append('order.txt', 'X:' .. r .. '\\n')\n\ - jump('### Y')\n\ + jump('../Y')\n\ ```\n\n\ #### Grand\n\n\ ```lua\nreturn 'grand-ran'\n```\n\n\ @@ -244,22 +244,22 @@ fn running_child_addresses_its_own_siblings_and_children() { } #[test] -fn running_child_cannot_address_a_top_level_section() { - // A running child cannot address a top-level section - the parent level - // is not in its visible set, so the jump resolves as not-found. +fn a_running_child_cannot_jump_to_its_parents_sibling() { + // A running child cannot jump to a top-level section - its parent's + // sibling is not its own sibling, so the jump resolves as not-found. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Escape\n\n\ ## A\n\n\ - ```lua\njump('### X')\n```\n\n\ + ```lua\njump('X')\n```\n\n\ ### X\n\n\ - ```lua\njump('## B')\n```\n\n\ + ```lua\njump('../B')\n```\n\n\ ## B\n\n\ ```lua\nreturn 'b-ran'\n```\n"; let trace = scheduler_scenario(md).run(); let rendered = trace.error().to_string(); assert!( - rendered.contains("not found"), + rendered.contains("section `../B` not found: `/A/X` has no sibling `B`"), "a top-level section is not in a child's visible set: {rendered}" ); } @@ -271,7 +271,7 @@ fn jump_to_a_niece_errors() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Niece\n\n\ ## A\n\n\ - ```lua\njump('### Niece')\n```\n\n\ + ```lua\njump('Niece')\n```\n\n\ ## B\n\n\ ### Niece\n\n\ ```lua\nreturn 'niece-ran'\n```\n"; @@ -294,7 +294,7 @@ fn sys_id_counts_the_sections_one_chain_enters_across_a_jump_into_a_child_level( ## A\n\n\ ```lua\n\ store.append('ids.txt', tostring(sys.id) .. '\\n')\n\ - jump('### X')\n\ + jump('X')\n\ ```\n\n\ ### X\n\n\ ```lua\nstore.append('ids.txt', tostring(sys.id) .. '\\n')\n```\n\n\ @@ -322,14 +322,14 @@ fn a_call_child_takes_ids_nested_under_its_own_chain_distinct_from_the_parent() ## Main\n\n\ ```lua\n\ store.append('ids.txt', 'main:' .. sys.id .. '\\n')\n\ - call('## Sub')\n\ + call('../Sub')\n\ store.append('ids.txt', 'after:' .. sys.id .. '\\n')\n\ return store.read('ids.txt')\n\ ```\n\n\ ## Sub\n\n\ ```lua\n\ store.append('ids.txt', 'sub:' .. sys.id .. '\\n')\n\ - local r = tasks.fanout('## Worker', {'a', 'b'})\n\ + local r = tasks.fanout('../Worker', {'a', 'b'})\n\ store.append('ids.txt', r[1].text .. '\\n' .. r[2].text .. '\\n')\n\ return 'sub-done'\n\ ```\n\n\ @@ -357,10 +357,10 @@ fn two_runs_of_the_same_prompt_produce_identical_ids() { ## Main\n\n\ ```lua\n\ local ids = { sys.id }\n\ - ids[#ids + 1] = call('## Sub')\n\ - local r = tasks.fanout('## Worker', {'x', 'y', 'z'})\n\ + ids[#ids + 1] = call('../Sub')\n\ + local r = tasks.fanout('../Worker', {'x', 'y', 'z'})\n\ for i = 1, #r do ids[#ids + 1] = r[i].text end\n\ - ids[#ids + 1] = call('## Sub')\n\ + ids[#ids + 1] = call('../Sub')\n\ var.ids = table.concat(ids, ',')\n\ ```\n\n\ ## Last\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/core_rules.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/core_rules.rs index 71fce59f2..1c1e05f84 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/core_rules.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/core_rules.rs @@ -94,7 +94,7 @@ fn call_chain_over_off_walk_siblings_returns_to_the_caller() { # Siblings\n\n\ ## A\n\n\ ```lua\n\ - local r = call('## S1')\n\ + local r = call('../S1')\n\ store.append('order.txt', 'A:' .. r .. '\\n')\n\ ```\n\n\ ## B\n\n\ @@ -143,7 +143,7 @@ fn call_clones_var_in_and_discards_child_writes() { ## Main\n\n\ ```lua\n\ var.shared = 'caller'\n\ - local r = call('## Sub')\n\ + local r = call('../Sub')\n\ assert(r == 'sub saw caller', 'the child reads the cloned var')\n\ assert(var.child_write == nil, 'child writes must not reach the caller')\n\ return 'ok'\n\ @@ -168,7 +168,7 @@ fn a_call_chain_counts_its_own_entries_and_the_outer_walk_resumes_its_own_sequen ## Main\n\n\ ```lua\n\ assert(sys.id == '0.1', 'the first walked section takes entry 1 of the root chain')\n\ - local r = call('## Sub')\n\ + local r = call('../Sub')\n\ store.append('order.txt', r .. '\\n')\n\ ```\n\n\ ## B\n\n\ @@ -199,8 +199,8 @@ fn entering_the_same_section_twice_takes_two_ids() { # Twice\n\n\ ## Main\n\n\ ```lua\n\ - local a = call('## Sub')\n\ - local b = call('## Sub')\n\ + local a = call('../Sub')\n\ + local b = call('../Sub')\n\ return a .. ',' .. b\n\ ```\n\n\ ## Sub\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/driving.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/driving.rs index 30157e3ac..257e01fac 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/driving.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/driving.rs @@ -14,7 +14,7 @@ fn nested_call_and_inference_run_end_to_end_on_a_current_thread_runtime() { # Gate\n\n\ ## Outer\n\n\ ```lua\n\ - local inner = call('## Inner')\n\ + local inner = call('../Inner')\n\ return models.infer('outer saw: ' .. inner)\n\ ```\n\n\ ## Inner\n\n\ @@ -83,9 +83,9 @@ fn call_depth_cap_reads_the_chain_field() { let md = "---\nname: depth\ndescription: d\nrunvil: 0\n---\n\n\ # Depth\n\n\ ## Alpha\n\n\ - ```lua\nreturn call('## Beta')\n```\n\n\ + ```lua\nreturn call('../Beta')\n```\n\n\ ## Beta\n\n\ - ```lua\nreturn call('## Alpha')\n```\n"; + ```lua\nreturn call('../Alpha')\n```\n"; let trace = scheduler_scenario(md).run(); let error = trace.error(); @@ -135,7 +135,7 @@ fn a_dispatch_failure_resumes_through_the_envelope_into_pcall() { # Catch\n\n\ ## Only\n\n\ ```lua\n\ - local ok, err = pcall(call, '## Missing')\n\ + local ok, err = pcall(call, '../Missing')\n\ if ok then return 'uncaught' end\n\ return 'caught: ' .. tostring(err)\n\ ```\n"; diff --git a/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/jumps.rs b/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/jumps.rs index f87bfd978..d2d193393 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/jumps.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/scheduler/walk/jumps.rs @@ -16,7 +16,7 @@ fn jump_transfer_skips_the_jumpers_remaining_blocks() { ## Check\n\n\ ```lua\n\ store.write('seen.txt', 'check')\n\ - jump('## Help')\n\ + jump('../Help')\n\ store.write('seen.txt', 'should-not-run')\n\ ```\n\n\ ## Accept\n\n\ @@ -33,12 +33,12 @@ fn jump_transfer_skips_the_jumpers_remaining_blocks() { #[test] fn section_cannot_jump_to_itself() { - // The caller is outside its own visible set, so naming its own heading - // to `jump` resolves as not-found. + // The caller is outside its own visible set, so naming itself as a + // sibling to `jump` resolves as not-found. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Self\n\n\ ## Self\n\n\ - ```lua\njump('## Self')\n```\n"; + ```lua\njump('../Self')\n```\n"; let trace = scheduler_scenario(md).run(); let rendered = trace.error().to_string(); @@ -54,7 +54,7 @@ fn jump_to_off_walk_section_runs_it() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Addressed\n\n\ ## A\n\n\ - ```lua\njump('## B')\n```\n\n\ + ```lua\njump('../B')\n```\n\n\ ## B\n\n\ ---\n\n\ ```lua\nreturn 'b-ran'\n```\n\n\ @@ -74,7 +74,7 @@ fn var_persists_across_a_jump() { ## A\n\n\ ```lua\n\ var.from_a = 'a'\n\ - jump('## C')\n\ + jump('../C')\n\ ```\n\n\ ## B\n\n\ ```lua\nerror('the jump must skip B')\n```\n\n\ @@ -102,7 +102,7 @@ fn a_jump_fires_section_finished_for_the_jumper_before_the_target_starts() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Boundaries\n\n\ ## A\n\n\ - ```lua\njump('## B')\n```\n\n\ + ```lua\njump('../B')\n```\n\n\ ## B\n\n\ ```lua\nreturn 'b-ran'\n```\n"; let trace = scheduler_scenario(md).run(); @@ -156,12 +156,12 @@ fn an_erroring_section_reports_started_but_not_finished() { #[test] fn a_failed_jump_resolution_still_finishes_the_jumper() { // The error half of the jump observation boundary: the jumper's frame - // closes as completed before the heading resolves, so SECTION_FINISHED + // closes as completed before the path resolves, so SECTION_FINISHED // fires for the jumper even when the target does not resolve. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Unresolved\n\n\ ## A\n\n\ - ```lua\njump('## Missing')\n```\n"; + ```lua\njump('../Missing')\n```\n"; let trace = scheduler_scenario(md).run(); let error = trace.error(); @@ -177,13 +177,13 @@ fn a_failed_jump_resolution_still_finishes_the_jumper() { } #[test] -fn a_jump_to_a_missing_heading_fails_on_the_jump_line() { +fn a_jump_to_a_missing_section_fails_on_the_jump_line() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Unresolved\n\n\ ## A\n\n\ ```lua\n\ var.before = true\n\ - jump('## Missing')\n\ + jump('../Missing')\n\ ```\n"; let trace = scheduler_scenario(md).run(); diff --git a/crates/runvil-internal/runtime/src/execute/tests/section_messages.rs b/crates/runvil-internal/runtime/src/execute/tests/section_messages.rs index b09daa1bc..843c77967 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/section_messages.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/section_messages.rs @@ -95,13 +95,13 @@ fn a_jump_target_a_task_a_fanout_arm_and_a_called_section_each_start_empty() { .section( "Main", "messages:user('main') - local called = call('## Called') - local _, _, task = tasks.join_any({ tasks.spawn('## Task') }) - local arms = tasks.fanout('### Arm', { 'a', 'b' }) + local called = call('../Called') + local _, _, task = tasks.join_any({ tasks.spawn('../Task') }) + local arms = tasks.fanout('Arm', { 'a', 'b' }) var.seen = table.concat({ called, task, arms[1].text, arms[2].text, #messages .. ':' .. messages[1].content, }, '|') - jump('## Next')", + jump('../Next')", ) .section( "### Arm", @@ -123,7 +123,7 @@ fn a_called_sections_loop_sends_only_its_own_list_and_leaves_the_callers() { .section( "Main", "messages:user('caller') - local called = call('## Called') + local called = call('../Called') models.loop() return called .. '|' .. #messages .. '|' .. messages[#messages].content", ) @@ -160,7 +160,7 @@ fn a_required_library_reads_and_extends_the_requiring_sections_list() { .section( "Main", "messages:user('main') - local seen = require '## lib' + local seen = require '/lib' return seen .. '|' .. #messages .. '|' .. messages[2].content", ) .section( diff --git a/crates/runvil-internal/runtime/src/execute/tests/serial_driver.rs b/crates/runvil-internal/runtime/src/execute/tests/serial_driver.rs index 80cd15ea9..ba6e8e2cc 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/serial_driver.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/serial_driver.rs @@ -30,7 +30,7 @@ const FANOUT: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ ## Parent\n\n\ ```lua\n\ store.write('seed.txt', 'planted')\n\ - local r = tasks.fanout('### Worker', {'a', 'b', 'c'})\n\ + local r = tasks.fanout('Worker', {'a', 'b', 'c'})\n\ return sys.id .. '|' .. r[1].text .. '|' .. r[2].text .. '|' .. r[3].text\n\ ```\n\n\ ### Worker\n\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite.rs b/crates/runvil-internal/runtime/src/execute/tests/suite.rs index c1008f6af..d7a870702 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite.rs @@ -2,8 +2,8 @@ //! author writes them and drives their execution on the serial test driver //! without a live gateway. Split by domain - parsing contracts, section //! execution, fanout, control flow, the args/argv and lazy-prose surfaces, -//! an author's own `_G` metatable, `require`, and the role map and start -//! checks - over the run-level +//! an author's own `_G` metatable, `require`, the `markdown` global, and +//! the role map and start checks - over the run-level //! [`Scenario`](crate::test_support::scenario::Scenario). Its //! cases reach engine-only items (the test drivers and recorders, the store //! facade, the loader's Lua programs), so they run here rather than against @@ -15,6 +15,7 @@ mod execution; mod fanout; mod global_metatable; mod lazy_prose; +mod markdown; mod parsing; mod prepare; mod require; diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface.rs index e288b1f55..cbdf4de9b 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/args_surface.rs @@ -296,7 +296,7 @@ fn a_call_chain_derives_argv_from_its_own_args_frozen() { let source = prompt( QUERY, &[ - ("Main", "return call('## Sub', '{\"query\":\"chain\"}')"), + ("Main", "return call('../Sub', '{\"query\":\"chain\"}')"), ( "Sub", " @@ -317,7 +317,7 @@ fn a_no_input_call_inherits_the_repaired_argv() { let source = PromptSource::new() .front(QUERY) .h1("argv = { query = 'repaired' }") - .section("Main", "return call('## Sub')") + .section("Main", "return call('../Sub')") .section( "Sub", " diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow.rs index 49148233e..660f2f7bd 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow.rs @@ -13,6 +13,7 @@ mod fanout_arms; mod fanout_collections; mod globals; mod h1_control; +mod prompt_blocks; mod prompt_reads; mod run_setup; mod store_failures; diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/chains.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/chains.rs index b048197a3..76ea5b016 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/chains.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/chains.rs @@ -16,7 +16,7 @@ fn jump_transfer_skips_the_jumpers_remaining_blocks() { "Check", " store.write('seen.txt', 'check') - jump('## Help') + jump('../Help') store.write('seen.txt', 'should-not-run')", ), ("Accept", "return 'accepted'"), @@ -40,10 +40,10 @@ fn jump_inside_call_is_contained_in_the_chain() { ( "Main", " - local r = call('## Sub') + local r = call('../Sub') return 'main:' .. r", ), - ("Sub", "jump('## Peer')"), + ("Sub", "jump('../Peer')"), ("Skipped", "error('the chain jump must move past me')"), ("Peer", "return 'peer-ran'"), ], @@ -64,7 +64,7 @@ fn call_chain_jumps_to_a_child_and_returns_the_chain_result() { "A", " store.append('order.txt', 'A1\\n') - local r = call('## Sub') + local r = call('../Sub') assert(r == 's2-result', 'the chain final text returns to A') store.append('order.txt', 'A2\\n')", ), @@ -78,7 +78,7 @@ fn call_chain_jumps_to_a_child_and_returns_the_chain_result() { "Sub", " store.append('order.txt', 'Sub\\n') - jump('### S1')", + jump('S1')", ), ("### S1", "store.append('order.txt', 'S1\\n')"), ( @@ -102,7 +102,7 @@ fn call_chain_over_off_walk_siblings_returns_to_the_caller() { .section( "A", " - local r = call('## S1') + local r = call('../S1') store.append('order.txt', 'A:' .. r .. '\\n')", ) .section( @@ -141,10 +141,10 @@ fn jump_inside_a_call_chain_moves_within_the_chain() { ( "A", " - local r = call('## Sub') + local r = call('../Sub') return 'A:' .. r", ), - ("Sub", "jump('## Peer')"), + ("Sub", "jump('../Peer')"), ("Peer", "store.append('order.txt', 'Peer\\n')"), ( "Tail", @@ -169,7 +169,7 @@ fn the_outer_walk_never_moves_during_a_contained_chain() { ( "A", " - call('## Sub') + call('../Sub') store.append('order.txt', 'A-done\\n')", ), ( @@ -178,7 +178,7 @@ fn the_outer_walk_never_moves_during_a_contained_chain() { store.append('order.txt', 'B\\n') return store.read('order.txt')", ), - ("Sub", "jump('## Peer')"), + ("Sub", "jump('../Peer')"), ( "Peer", " @@ -202,7 +202,7 @@ fn a_return_inside_a_chain_ends_the_chain_not_the_run() { ( "A", " - local r = call('## Sub') + local r = call('../Sub') store.append('order.txt', 'A:' .. r .. '\\n')", ), ( @@ -234,7 +234,7 @@ fn a_call_chain_counts_its_own_entries_and_the_outer_walk_resumes_its_own_sequen "Main", " assert(sys.id == '0.1', 'the first walked section takes entry 1 of the root chain') - local r = call('## Sub') + local r = call('../Sub') store.append('order.txt', r .. '\\n')", ), ( @@ -269,8 +269,8 @@ fn entering_the_same_section_twice_takes_two_ids() { ( "Main", " - local a = call('## Sub') - local b = call('## Sub') + local a = call('../Sub') + local b = call('../Sub') return a .. ',' .. b", ), ("Sub", "return tostring(sys.id)"), @@ -291,7 +291,7 @@ fn fanout_arms_take_child_ids_in_collection_order_and_a_per_fanout_index() { ( "Parent", " - local r = tasks.fanout('### Worker', {'a', 'b'}) + local r = tasks.fanout('Worker', {'a', 'b'}) local function parts(s) return string.match(s, '^(%d+):([%d%.]+)$') end local i1, id1 = parts(r[1].text) local i2, id2 = parts(r[2].text) @@ -334,7 +334,7 @@ fn sys_taskid_inside_a_fanout_arm_is_the_arms_own_task() { "Parent", " assert(sys.taskid == '0', 'the main walk is task 0, got ' .. sys.taskid) - local results = tasks.fanout('### Worker', {'a'}) + local results = tasks.fanout('Worker', {'a'}) return results[1].text", ), ("### Worker", "return sys.taskid"), @@ -351,7 +351,7 @@ fn sys_taskid_inside_a_fanout_arm_is_the_arms_own_task() { fn call_recursion_is_capped() { let source = prompt( "", - &[("A", "return call('## B')"), ("B", "return call('## A')")], + &[("A", "return call('../B')"), ("B", "return call('../A')")], ); let trace = Scenario::new(source).run(); let error = trace.error(); @@ -367,11 +367,11 @@ fn call_recursion_is_capped() { ); } -/// The caller is outside its own visible set (decision 3): naming its own -/// heading to `call` resolves as not-found. +/// The caller is outside its own visible set (decision 3): naming itself +/// as a sibling to `call` resolves as not-found. #[test] fn section_cannot_call_itself() { - let trace = Scenario::new(prompt("", &[("Self", "return call('## Self')")])).run(); + let trace = Scenario::new(prompt("", &[("Self", "return call('../Self')")])).run(); let rendered = trace.error().to_string(); assert!( rendered.contains("not found"), @@ -379,11 +379,11 @@ fn section_cannot_call_itself() { ); } -/// The caller is outside its own visible set (decision 3): naming its own -/// heading to `jump` resolves as not-found. +/// The caller is outside its own visible set (decision 3): naming itself +/// as a sibling to `jump` resolves as not-found. #[test] fn section_cannot_jump_to_itself() { - let trace = Scenario::new(prompt("", &[("Self", "jump('## Self')")])).run(); + let trace = Scenario::new(prompt("", &[("Self", "jump('../Self')")])).run(); let rendered = trace.error().to_string(); assert!( rendered.contains("not found"), diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/child_walks.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/child_walks.rs index b89463190..bad27acf9 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/child_walks.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/child_walks.rs @@ -7,7 +7,7 @@ use super::*; #[test] fn jump_to_off_walk_section_runs_it() { let source = PromptSource::new() - .section("A", "jump('## B')") + .section("A", "jump('../B')") .prose("B", "---\n\n```lua\nreturn 'b-ran'\n```") .section("C", "return 'c-ran'"); assert_eq!(Scenario::new(source.build()).run().text(), "b-ran"); @@ -26,7 +26,7 @@ fn jump_to_a_child_starts_the_child_level_walk() { "A", " store.append('order.txt', 'A\\n') - jump('### X')", + jump('X')", ), ("### X", "store.append('order.txt', 'X\\n')"), ("### Y", "store.append('order.txt', 'Y\\n')"), @@ -53,13 +53,13 @@ fn child_walk_recurses_to_h4() { "A", " store.append('order.txt', 'A\\n') - jump('### X')", + jump('X')", ), ( "### X", " store.append('order.txt', 'X\\n') - jump('#### P')", + jump('P')", ), ("#### P", "store.append('order.txt', 'P\\n')"), ("#### Q", "store.append('order.txt', 'Q\\n')"), @@ -80,7 +80,7 @@ fn child_walk_recurses_to_h4() { #[test] fn jump_to_an_off_walk_child_runs_it() { let source = PromptSource::new() - .section("A", "jump('### Off')") + .section("A", "jump('Off')") .section("### X", "store.append('order.txt', 'X\\n')") .prose( "### Off", @@ -102,7 +102,7 @@ fn call_to_a_child_starts_a_contained_chain() { ( "Main", " - local r = call('### Sub') + local r = call('Sub') return 'got:' .. r", ), ("### Sub", "store.append('order.txt', 'Sub\\n')"), @@ -146,13 +146,13 @@ fn running_child_addresses_its_own_siblings_and_children() { let source = prompt( "", &[ - ("A", "jump('### X')"), + ("A", "jump('X')"), ( "### X", " - local r = call('#### Grand') + local r = call('Grand') store.append('order.txt', 'X:' .. r .. '\\n') - jump('### Y')", + jump('../Y')", ), ("#### Grand", "return 'grand-ran'"), ("### Y", "store.append('order.txt', 'Y\\n')"), @@ -162,24 +162,31 @@ fn running_child_addresses_its_own_siblings_and_children() { assert_eq!(Scenario::new(source).run().text(), "X:grand-ran\nY\n"); } -/// A running child cannot address a top-level section: the parent level is -/// not in its visible set, so the jump resolves as not-found. +/// A running child cannot jump to a top-level section: its parent's +/// sibling is not its own sibling, so the jump resolves as not-found, and +/// `jump` takes no path from the top. #[test] -fn running_child_cannot_address_a_top_level_section() { - let source = prompt( - "", - &[ - ("A", "jump('### X')"), - ("### X", "jump('## B')"), - ("B", "return 'b-ran'"), - ], - ); - let trace = Scenario::new(source).run(); - let rendered = trace.error().to_string(); +fn a_running_child_cannot_jump_to_its_parents_sibling() { + let failure = |jump: &str| { + let source = prompt( + "", + &[("A", "jump('X')"), ("### X", jump), ("B", "return 'b-ran'")], + ); + Scenario::new(source).run().error().to_string() + }; + let rendered = failure("jump('../B')"); assert!( - rendered.contains("not found"), + rendered.contains("section `../B` not found: `/A/X` has no sibling `B`"), "a top-level section is not in a child's visible set: {rendered}" ); + let rendered = failure("jump('/B')"); + assert!( + rendered.contains( + "section path \"/B\" starts with \"/\"; only require, prompt.section, and \ + prompt.section_or take a path from the top" + ), + "a jump takes a relative path: {rendered}" + ); } /// A sibling's child (a niece or nephew) is not in the visible set: the jump @@ -187,7 +194,7 @@ fn running_child_cannot_address_a_top_level_section() { #[test] fn jump_to_a_niece_errors() { let source = PromptSource::new() - .section("A", "jump('### Niece')") + .section("A", "jump('Niece')") .prose("B", "") .section("### Niece", "return 'niece-ran'"); let trace = Scenario::new(source.build()).run(); @@ -210,7 +217,7 @@ fn sys_id_counts_the_sections_one_chain_enters_across_a_jump_into_a_child_level( "A", " store.append('ids.txt', tostring(sys.id) .. '\\n') - jump('### X')", + jump('X')", ), ( "### X", @@ -231,7 +238,7 @@ fn sys_id_counts_the_sections_one_chain_enters_across_a_jump_into_a_child_level( assert_eq!(Scenario::new(source).run().text(), "0.1\n0.2\n0.3\n0.4\n"); } -/// `call()` on a child heading resolves the target's index within the +/// `call()` on a child resolves the target's index within the /// caller's CHILD slice, not the sibling slice: earlier children do not run, /// and the chain falls through to the target's following child siblings. #[test] @@ -242,7 +249,7 @@ fn call_to_a_later_child_runs_the_child_slice_from_that_index() { ( "Main", " - local r = call('### Sub2') + local r = call('Sub2') return 'got:' .. r", ), ("### Sub1", "error('an earlier child must not run')"), diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/fanout_arms.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/fanout_arms.rs index 32351be30..be2799e5e 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/fanout_arms.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/fanout_arms.rs @@ -1,4 +1,4 @@ -//! Control flow inside a fanout arm: `prompt.list`, `call`, nested +//! Control flow inside a fanout arm: the `prompt` reads, `call`, nested //! `fanout`, and `jump` over the worker's visible set, the depth cap //! across the arm boundary, and an arm's text when it replies nothing. @@ -8,16 +8,16 @@ use super::*; use crate::execute::tests::context::arm_fanout; use crate::test_support::at_line; -/// `prompt.list` inside a fanout arm reads a list section's items: the arm -/// resolves like any section (the worker's siblings minus the worker, plus -/// its children). +/// A `prompt` read inside a fanout arm reads the items of a sibling's +/// list: the arm resolves like any section (the worker's siblings minus +/// the worker, plus its children). #[test] -fn prompt_list_inside_a_fanout_arm_reads_items() { +fn a_prompt_read_inside_a_fanout_arm_reads_a_siblings_list() { let source = arm_fanout() .section( "### Worker", " - local items = prompt.list('### Items') + local items = prompt.section('../Items', 'list').items return item .. ':' .. table.concat(items, ',')", ) .prose("### Items", "- x\n- y"); @@ -42,14 +42,14 @@ fn call_inside_a_fanout_arm_runs_a_contained_chain() { "Parent", " tasks.concurrency(1) - local r = tasks.fanout('### Worker', {'alpha'}) + local r = tasks.fanout('Worker', {'alpha'}) return r[1].text", ), ( "### Worker", " assert(sys.section_count == 1, 'the arm sees the run top-level section count') - local got = call('### Sub') + local got = call('../Sub') return 'worker:' .. got .. ':' .. item", ), ( @@ -58,7 +58,7 @@ fn call_inside_a_fanout_arm_runs_a_contained_chain() { assert(sys.id == '0.0.0.0', 'a contained chain is the arm chain child and starts at entry 0') assert(item == nil, 'a contained chain runs as a plain section') assert(sys.section_count == 1, 'a contained chain sees the run section count') - local leaves = tasks.fanout('#### Leaf', {'x', 'y'}) + local leaves = tasks.fanout('Leaf', {'x', 'y'}) var.leaves = leaves[1].text .. leaves[2].text", ), ("#### Leaf", "return item .. '!'"), @@ -80,13 +80,13 @@ fn fanout_inside_a_fanout_arm_maps_over_a_collection() { ( "Parent", " - local r = tasks.fanout('### Outer', {'a', 'b'}) + local r = tasks.fanout('Outer', {'a', 'b'}) return table.concat({r[1].text, r[2].text}, ';')", ), ( "### Outer", " - local inner = tasks.fanout('### Inner', {item .. '1', item .. '2'}) + local inner = tasks.fanout('../Inner', {item .. '1', item .. '2'}) assert(inner[1].ok and inner[2].ok) return item .. ':' .. inner[1].text .. ',' .. inner[2].text", ), @@ -113,7 +113,7 @@ fn jump_inside_a_fanout_arm_drives_a_child_walk() { .section( "### Worker", " - jump('### Target') + jump('../Target') error('the arm remaining blocks are skipped')", ) .section( @@ -147,7 +147,7 @@ fn jump_inside_a_fanout_arm_to_a_worker_child_walks_the_child_slice() { .section( "### Worker", " - jump('#### Child') + jump('Child') error('the arm remaining blocks are skipped')", ) .section( @@ -177,7 +177,7 @@ fn jump_inside_a_fanout_arm_to_a_worker_child_walks_the_child_slice() { #[test] fn jump_inside_a_fanout_arm_to_a_silent_chain_returns_empty_text() { let source = arm_fanout() - .section("### Worker", "jump('### Target')") + .section("### Worker", "jump('../Target')") .section("### Target", "store.append('order.txt', 'Target\\n')"); let trace = Scenario::new(source.build()).run(); assert_eq!( @@ -188,13 +188,13 @@ fn jump_inside_a_fanout_arm_to_a_silent_chain_returns_empty_text() { assert_eq!(trace.read("order.txt").expect("order log"), "Target\n"); } -/// A jump from an arm to a heading outside the worker's visible set (the -/// top-level parent) resolves as not-found, and the error lists only the -/// worker's visible set. +/// A relative path in an arm starts at the worker, so the worker's parent +/// is no neighbor: a jump that names it as a sibling is not found, and +/// the error names the worker as the section it searched. #[test] -fn jump_inside_a_fanout_arm_to_a_non_visible_heading_errors() { +fn jump_inside_a_fanout_arm_to_the_workers_parent_errors() { let source = arm_fanout() - .section("### Worker", "jump('## Parent')") + .section("### Worker", "jump('../Parent')") .prose("### Items", "- x"); let trace = Scenario::new(source.build()).run(); let error = trace.error(); @@ -205,28 +205,34 @@ fn jump_inside_a_fanout_arm_to_a_non_visible_heading_errors() { ); let rendered = error.to_string(); assert!( - rendered.contains("not found"), - "a non-visible heading must not resolve: {rendered}" + rendered + .contains("section `../Parent` not found: `/Parent/Worker` has no sibling `Parent`"), + "the error says where it looked: {rendered}" ); assert!( - rendered.contains("### Items"), - "the error lists the worker's visible set: {rendered}" + !rendered.contains("Items"), + "the error lists no other section: {rendered}" ); } -/// `call` and `prompt.list` inside an arm naming a section outside the -/// worker's visible set both reject it: `call` errors not-found, which the -/// arm catches with `pcall` and asserts on, and `prompt.list` reads `nil`. +/// `call` and a `prompt` read inside an arm naming the worker's parent as +/// a neighbor both miss: `call` errors not-found, which the arm catches +/// with `pcall` and asserts on, and `prompt.section_or` reads `nil`. A +/// path from the top reads the parent. #[test] -fn call_raises_and_list_reads_nil_for_non_visible_sections_in_a_fanout_arm() { +fn call_raises_and_a_read_misses_for_the_workers_parent_in_a_fanout_arm() { let source = arm_fanout() .section( "### Worker", " - local ok_call, err_call = pcall(call, '## Parent') - assert(not ok_call, 'call of a non-visible section must fail') - assert(tostring(err_call):find('not found', 1, true), 'call error must be not-found: ' .. tostring(err_call)) - assert(prompt.list('## Parent') == nil, 'prompt.list of a non-visible section must read nil') + for _, path in ipairs({ '../Parent', 'Parent' }) do + local ok_call, err_call = pcall(call, path) + assert(not ok_call, 'call of the parent must fail') + assert(tostring(err_call):find('not found', 1, true), 'call error must be not-found: ' .. tostring(err_call)) + assert(prompt.section_or(path, 'list') == nil, 'a read of the parent must read nil') + end + assert(prompt.section('/Parent') ~= nil, 'a path from the top reads the parent') + assert(prompt.section('../Items', 'list').items[1] == 'x', 'a sibling reads') return item .. ':rejected'", ) .prose("### Items", "- x"); @@ -251,11 +257,11 @@ fn fanout_recursion_across_the_boundary_trips_the_depth_cap() { local n = tonumber(ok and v or '0') + 1 store.write('n.txt', tostring(n)) if n >= 5 then - return tasks.fanout('## W', {'x'}) + return tasks.fanout('../W', {'x'}) end - return call('## B')", + return call('../B')", ) - .section("B", "return call('## A')") + .section("B", "return call('../A')") .prose("W", "---\n\n```lua\nreturn item\n```"); let trace = Scenario::new(source.build()).run(); let error = trace.error(); @@ -286,7 +292,7 @@ fn call_inside_an_arm_spawned_near_the_cap_trips_the_depth_cap() { // at depth 8, and the arm's own `call` would need depth 9 - past // MAX_CALL_DEPTH (8). let source = PromptSource::new() - .section("A", "return call('## B')") + .section("A", "return call('../B')") .section( "B", " @@ -294,12 +300,12 @@ fn call_inside_an_arm_spawned_near_the_cap_trips_the_depth_cap() { local n = tonumber(ok and v or '0') + 1 store.write('n.txt', tostring(n)) if n >= 4 then - local r = tasks.fanout('## W', {'x'}) + local r = tasks.fanout('../W', {'x'}) return r[1].text end - return call('## A')", + return call('../A')", ) - .prose("W", "---\n\n```lua\nreturn call('## C')\n```") + .prose("W", "---\n\n```lua\nreturn call('../C')\n```") .prose("C", "---\n\n```lua\nreturn 'c-reply'\n```"); let trace = Scenario::new(source.build()).run(); let error = trace.error(); @@ -330,7 +336,7 @@ fn fanout_arm_without_output_and_no_incoming_reply_yields_empty_text() { ( "Parent", " - local r = tasks.fanout('### Worker', {'alpha'}) + local r = tasks.fanout('Worker', {'alpha'}) assert(r[1].ok, 'the arm succeeded') assert(r[1].text == '', 'a no-reply arm yields empty text') return 'ok'", diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/fanout_collections.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/fanout_collections.rs index 9928ba982..aed7cccdd 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/fanout_collections.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/fanout_collections.rs @@ -16,7 +16,7 @@ fn fanout_returns_structured_results() { .section( "Parent", " - local r = tasks.fanout('### Worker', prompt.list('### Items')) + local r = tasks.fanout('Worker', prompt.section('Items', 'list').items) assert(r[1].text == 'alpha-1') assert(r[1].ok == true) assert(r[1].item == 'alpha') @@ -49,7 +49,7 @@ fn off_walk_worker_runs_as_a_fanout_arm() { .section( "Parent", " - local r = tasks.fanout('### Worker', prompt.list('### Items')) + local r = tasks.fanout('Worker', prompt.section('Items', 'list').items) return r[1].text .. ',' .. r[2].text", ) .prose("### Worker", "---\n\n```lua\nreturn item .. '-done'\n```") @@ -69,7 +69,7 @@ fn fanout_accepts_a_prompt_built_collection_over_the_old_default_cap() { " local members = {} for i = 1, 1025 do members[i] = 'm' .. i end - local r = tasks.fanout('### Worker', members) + local r = tasks.fanout('Worker', members) assert(#r == 1025, 'every member ran') return r[1025].text", ); @@ -86,7 +86,7 @@ fn fanout_collection_array_members_arrive_as_themselves_in_order() { ( "Parent", " - local r = tasks.fanout('### Worker', {'b', 2, true, {nested='x'}}) + local r = tasks.fanout('Worker', {'b', 2, true, {nested='x'}}) assert(#r == 4) assert(r[1].text == 'string:b') assert(r[2].text == 'number:2') @@ -115,7 +115,7 @@ fn fanout_collection_hash_members_arrive_as_pair_tables() { ( "Parent", " - local r = tasks.fanout('### Worker', {alpha=1, beta='two'}) + local r = tasks.fanout('Worker', {alpha=1, beta='two'}) assert(#r == 2) local seen = {} for i = 1, #r do @@ -135,12 +135,12 @@ fn fanout_collection_hash_members_arrive_as_pair_tables() { assert_eq!(Scenario::new(source).run().text(), "ok"); } -/// An empty collection is an error: no work is likely a bug (a list section -/// that parsed empty, a wrong variable), so the fanout is rejected before -/// any scheduling rather than returning zero results. +/// An empty collection is an error: no work is likely a bug, such as a +/// wrong variable, so the fanout is rejected before any scheduling rather +/// than returning zero results. #[test] fn fanout_collection_empty_errors() { - let trace = Scenario::new(item_worker("tasks.fanout('### Worker', {})")).run(); + let trace = Scenario::new(item_worker("tasks.fanout('Worker', {})")).run(); let rendered = trace.error().to_string(); assert!( rendered.contains("empty collection"), @@ -157,7 +157,7 @@ fn fanout_collection_item_round_trips_members() { ( "Parent", " - local r = tasks.fanout('### Worker', {1, 'two', {n=3}}) + local r = tasks.fanout('Worker', {1, 'two', {n=3}}) assert(r[1].item == 1) assert(r[2].item == 'two') assert(type(r[3].item) == 'table' and r[3].item.n == 3) @@ -179,7 +179,7 @@ fn fanout_over_a_json_null_member_runs_one_arm_whose_item_is_json_null() { ( "Parent", " - local r = tasks.fanout('### Worker', { json.null }) + local r = tasks.fanout('Worker', { json.null }) assert(#r == 1, 'one arm ran') return r[1].text", ), @@ -203,7 +203,7 @@ fn fanout_worker_resolves_as_a_sibling() { .section( "Main", " - local r = tasks.fanout('## Worker', {'x'}) + local r = tasks.fanout('../Worker', {'x'}) return r[1].text", ) .prose("Worker", "---\n\n```lua\nreturn item .. '-done'\n```"); @@ -217,13 +217,13 @@ fn fanout_worker_shared_by_two_sibling_callers() { .section( "A", " - local r = tasks.fanout('## Worker', {'a'}) + local r = tasks.fanout('../Worker', {'a'}) store.write('a.txt', r[1].text)", ) .section( "B", " - local r = tasks.fanout('## Worker', {'b'}) + local r = tasks.fanout('../Worker', {'b'}) return store.read('a.txt') .. ',' .. r[1].text", ) .prose("Worker", "---\n\n```lua\nreturn item .. '-done'\n```"); @@ -237,7 +237,7 @@ fn fanout_niece_worker_is_not_visible() { let source = prompt( "", &[ - ("Main", "tasks.fanout('### Niece', {'x'})"), + ("Main", "tasks.fanout('Niece', {'x'})"), ("Other", "local x = 1"), ("### Niece", "return item"), ], @@ -250,23 +250,23 @@ fn fanout_niece_worker_is_not_visible() { ); } -/// The two-string form errors at the boundary, pointing at `prompt.list`. +/// The two-string form errors at the boundary, and the error says how to +/// read the items of a list in a section. #[test] -fn fanout_string_second_parameter_errors_pointing_at_prompt_list() { +fn fanout_string_second_parameter_errors_naming_the_read_that_gives_a_lists_items() { let source = PromptSource::new() - .section("Parent", "tasks.fanout('### Worker', '### Items')") + .section("Parent", "tasks.fanout('Worker', 'Items')") .section("### Worker", "return item") .prose("### Items", "- alpha"); let trace = Scenario::new(source.build()).run(); let rendered = trace.error().to_string(); assert!( - rendered.contains("tasks.fanout's second parameter is a collection"), + rendered.contains( + "tasks.fanout's second parameter is a collection, got string; for the items of a \ + list in a section, pass prompt.section(path, \"list\").items" + ), "error was: {rendered}" ); - assert!( - rendered.contains("prompt.list(heading)"), - "the error must point at prompt.list: {rendered}" - ); } /// A number or boolean second parameter is not a collection. @@ -274,9 +274,9 @@ fn fanout_string_second_parameter_errors_pointing_at_prompt_list() { fn fanout_number_and_boolean_second_parameters_error() { let source = item_worker( " - local ok_n, err_n = pcall(tasks.fanout, '### Worker', 5) + local ok_n, err_n = pcall(tasks.fanout, 'Worker', 5) assert(not ok_n and tostring(err_n):find('collection'), tostring(err_n)) - local ok_b, err_b = pcall(tasks.fanout, '### Worker', true) + local ok_b, err_b = pcall(tasks.fanout, 'Worker', true) assert(not ok_b and tostring(err_b):find('collection'), tostring(err_b)) return 'ok'", ); @@ -286,7 +286,7 @@ fn fanout_number_and_boolean_second_parameters_error() { /// A function member cannot cross into an arm; the error names its index. #[test] fn fanout_function_member_errors_naming_the_index() { - let source = item_worker("tasks.fanout('### Worker', {'a', function() end})"); + let source = item_worker("tasks.fanout('Worker', {'a', function() end})"); let trace = Scenario::new(source).run(); let rendered = trace.error().to_string(); assert!(rendered.contains("index 2"), "error was: {rendered}"); @@ -300,7 +300,7 @@ fn fanout_table_keyed_member_errors() { " local t = {} t[{}] = 'x' - tasks.fanout('### Worker', t)", + tasks.fanout('Worker', t)", ); let trace = Scenario::new(source).run(); let error = trace.error(); @@ -312,18 +312,25 @@ fn fanout_table_keyed_member_errors() { ); } -/// A list section is not a worker template; naming one as the worker errors. +/// A section that holds only a list is an ordinary section: named as the +/// worker or by `call`, it is entered like any section with no Lua, and it +/// runs nothing of its own. #[test] -fn fanout_worker_that_is_a_list_section_errors() { +fn a_section_that_holds_only_a_list_runs_as_a_worker_with_nothing_to_run() { let source = PromptSource::new() - .section("Parent", "tasks.fanout('### Items', {'x'})") - .prose("### Items", "- a\n- b"); - let trace = Scenario::new(source.build()).run(); - let error = trace.error(); - assert!( - error - .to_string() - .contains("is a list section, not a worker template"), - "error was: {error}" + .section( + "Parent", + " + local arms = tasks.fanout('Items', {'x', 'y'}) + local called = call('Items') + return #arms .. '|' .. tostring(arms[1]) .. '|' .. tostring(arms[2].ok) .. '|' + .. tostring(called) .. '|' .. table.concat(prompt.section('Items', 'list').items, ',')", + ) + .prose("### Items", "- a\n-\n- b"); + assert_eq!( + Scenario::new(source.build()).run().text(), + "2||true||a,,b", + "each arm and the call run no Lua and reply nothing, and the list still reads, a bullet \ + with no content as the empty item" ); } diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/globals.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/globals.rs index 597a7b46b..091c60aab 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/globals.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/globals.rs @@ -8,8 +8,8 @@ use super::*; use crate::test_support::at_line; /// The `tasks` global is the task namespace (`tasks.spawn` and the -/// waits), not a control-flow table: indexing it by a heading string reads -/// nil, and control flow takes heading strings only. +/// waits), not a control-flow table: indexing it by a section path reads +/// nil, and control flow takes section paths only. #[test] fn tasks_global_is_the_task_namespace() { let source = prompt( @@ -19,7 +19,7 @@ fn tasks_global_is_the_task_namespace() { " assert(type(tasks) == 'table', 'the tasks namespace is installed') assert(type(tasks.spawn) == 'function', 'tasks.spawn is a namespace function') - assert(tasks['## Main'] == nil, 'the namespace is not a heading table') + assert(tasks['../Main'] == nil, 'the namespace is not a table of sections') return 'ok'", )], ); @@ -71,7 +71,7 @@ fn fanout_arms_get_fresh_var_clones() { .section( "Parent", " - local r = tasks.fanout('### Worker', {'a', 'b'}) + local r = tasks.fanout('Worker', {'a', 'b'}) assert(var.a == nil and var.b == nil, 'arm writes must not reach the caller') return r[1].text .. r[2].text", ) @@ -98,7 +98,7 @@ fn var_persists_across_sections_fallthrough_and_jump() { " assert(var.from_h1 == 'seed', 'H1 var writes must reach the first H2 section') var.from_a = 'a' - jump('## C')", + jump('../C')", ) .section("B", "error('the jump must skip B')") .section( @@ -128,7 +128,7 @@ fn call_clones_var_in_and_discards_child_writes() { "Main", " var.shared = 'caller' - local r = call('## Sub') + local r = call('../Sub') assert(r == 'sub saw caller', 'the child reads the cloned var') assert(var.child_write == nil, 'child writes must not reach the caller') return 'ok'", @@ -200,7 +200,7 @@ fn a_var_read_back_failure_at_a_jump_is_placed_on_the_jumping_block() { ```lua local leaving = true var = 5 - jump('## B') + jump('../B') ``` ```lua diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/h1_control.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/h1_control.rs index bfcb08db0..0a3b00bd5 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/h1_control.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/h1_control.rs @@ -11,15 +11,18 @@ fn h1_failure(lua: &str) -> String { Scenario::new(source).run().error().to_string() } -/// The control globals work in H1 (section 0): a `call` naming no -/// top-level section fails the run with the resolution error naming the -/// target. +/// What the H1 searched for a name no top-level section has. +const NO_SUCH_SECTION: &str = "section `Nope` not found: the top level has no section `Nope`"; + +/// The control globals work in H1 (section 0), where a name is a +/// top-level section: a `call` naming none fails the run with the +/// resolution error. #[test] fn call_from_h1_to_an_unknown_section_fails_the_run() { - let rendered = h1_failure("call('## Nope')"); + let rendered = h1_failure("call('Nope')"); assert!( - rendered.contains("## Nope"), - "the resolution error names the missing section: {rendered}" + rendered.contains(NO_SUCH_SECTION), + "the resolution error says where it looked: {rendered}" ); } @@ -27,10 +30,10 @@ fn call_from_h1_to_an_unknown_section_fails_the_run() { /// run with the resolution error. #[test] fn jump_from_h1_to_an_unknown_section_fails_the_run() { - let rendered = h1_failure("jump('## Nope')"); + let rendered = h1_failure("jump('Nope')"); assert!( - rendered.contains("## Nope"), - "the resolution error names the missing section: {rendered}" + rendered.contains(NO_SUCH_SECTION), + "the resolution error says where it looked: {rendered}" ); } @@ -38,9 +41,19 @@ fn jump_from_h1_to_an_unknown_section_fails_the_run() { /// unknown worker fails the run with the resolution error. #[test] fn fanout_from_h1_to_an_unknown_section_fails_the_run() { - let rendered = h1_failure("tasks.fanout('## Nope', {'a'})"); + let rendered = h1_failure("tasks.fanout('Nope', {'a'})"); + assert!( + rendered.contains(NO_SUCH_SECTION), + "the resolution error says where it looked: {rendered}" + ); +} + +/// The H1 has no siblings, so `../name` names nothing there. +#[test] +fn a_sibling_path_from_h1_names_nothing() { + let rendered = h1_failure("jump('../Nope')"); assert!( - rendered.contains("## Nope"), - "the resolution error names the missing section: {rendered}" + rendered.contains("section `../Nope` not found: the H1 has no siblings"), + "the resolution error says where it looked: {rendered}" ); } diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/prompt_blocks.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/prompt_blocks.rs new file mode 100644 index 000000000..a33d2a1a7 --- /dev/null +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/prompt_blocks.rs @@ -0,0 +1,371 @@ +//! The selector forms of `prompt.section` and `prompt.section_or` in a +//! running prompt: one block of a section's own content, read from a +//! section, from one level down, from any depth, from the H1 body, and +//! from a library. +//! +//! Each prompt probes expressions where they are written. A probe appends +//! one line to `probe.txt`: the value's JSON text, `nil`, or `raises: ` +//! and the error's text. The last section returns the file. + +use super::*; + +/// The first of a probe site's three lines: the function that records +/// one result. +const PROBE: &str = "local function probe(f) local ok, value = pcall(f) local line = \"nil\" \ + if not ok then line = \"raises: \" .. tostring(value) elseif value ~= nil then line = \ + json.encode(value) end store.append(\"probe.txt\", line .. \"\\n\") end"; + +/// The section that hands the probes' lines back as the run's text. +const REPORT: &str = "\n## Report\n\n```lua\nreturn store.read(\"probe.txt\")\n```\n"; + +/// Three lines of Lua that probe each of `expressions` where the lines +/// are written, and then run `then`. +fn probes(expressions: &[&str], then: &str) -> String { + let calls: Vec = expressions + .iter() + .map(|expression| format!("probe(function() return {expression} end)")) + .collect(); + format!("{PROBE}\n{}\n{then}", calls.join(" ")) +} + +/// Runs `source` and asserts that its probes gave `expected`, in order. An +/// expectation that starts with `raises: ` is a failure whose text holds +/// the rest, and any other is the whole line. +#[track_caller] +fn assert_probes(source: &str, expected: &[&str]) { + let trace = Scenario::new(source.to_owned()).run(); + let text = trace.text(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), expected.len(), "one line per probe: {text}"); + for (number, (line, want)) in lines.iter().zip(expected).enumerate() { + match want.strip_prefix("raises: ") { + Some(message) => assert!( + line.starts_with("raises: ") && line.contains(message), + "probe {} must raise {message}, got {line}", + number + 1 + ), + None => assert_eq!(line, want, "probe {}", number + 1), + } + } +} + +/// The report prompt: `Write` holds `house_style`, `Template`, and +/// `Topics`, and its sibling is `Review`. `write` is the three lines of +/// Lua in `Write`, and `template` is added to the end of `Template`. +fn report(write: &str, template: &str) -> String { + format!( + "---\nname: report\ndescription: Write one report per topic\nrunvil: 0\n---\n\n\ + # Report\n\n\ + ## Write\n\n\ + ```lua\n{write}\n```\n\n\ + ### house_style\n\n\ + Short sentences. No hype.\n\n\ + ### Template\n\n\ + Fill in this outline.\n\n\ + ```markdown\n# Title\n\n## Summary\n```\n{template}\n\ + ### Topics\n\n\ + - allocators\n- coroutines\n\n\ + ## Review\n\n\ + Check every claim.\n{REPORT}" + ) +} + +#[test] +fn a_section_reads_one_block_of_a_child_a_sibling_or_any_section() { + let write = probes( + &[ + r#"prompt.section("house_style")"#, + r#"prompt.section("Template")"#, + r#"prompt.section("Template", "fence markdown").text"#, + r#"prompt.section("Template", "fence markdown").line"#, + r#"prompt.section("Template", "fence").lang"#, + r#"prompt.section("Template", "prose").text"#, + r#"prompt.section("Template", "fence", 1).lang"#, + r#"prompt.section("Topics", "list").items"#, + r#"prompt.section("../Review")"#, + r#"prompt.section("/Review")"#, + r#"prompt.section("/Write/Template", "fence").lang"#, + r#"prompt.section("Missing")"#, + r#"prompt.section("Review")"#, + r#"prompt.section("../Write")"#, + r#"prompt.section("Template", "list")"#, + r#"prompt.section("Template", "fence", 2)"#, + r#"prompt.section("Template/Summary")"#, + r#"prompt.section_or("Missing")"#, + r#"prompt.section_or("Missing", "fence")"#, + r#"prompt.section_or("Template", "list")"#, + r#"prompt.section_or("Template", 'fence ""')"#, + r#"prompt.section_or("Template/Summary")"#, + ], + "local probed = true", + ); + assert_probes( + &report(&write, ""), + &[ + r#""Short sentences. No hype.""#, + r#""Fill in this outline.\n\n```markdown\n# Title\n\n## Summary\n```""#, + r##""# Title\n\n## Summary""##, + // A line of the prompt file. + "25", + r#""markdown""#, + r#""Fill in this outline.""#, + r#""markdown""#, + r#"["allocators","coroutines"]"#, + r#""Check every claim.""#, + r#""Check every claim.""#, + r#""markdown""#, + "raises: section `Missing` not found: `/Write` has no child `Missing`", + "raises: section `Review` not found: `/Write` has no child `Review`", + "raises: section `../Write` not found: `/Write` has no sibling `Write`", + "raises: prompt.section: no block in section `Template` matches selector \"list\"", + "raises: prompt.section: selector \"fence\" matches 1 block in section `Template`; \ + there is no block 2", + "raises: section path \"Template/Summary\" has more than one name; a relative path \ + is a child's name, or \"../\" and a sibling's name", + "nil", + "nil", + "nil", + "nil", + "raises: section path \"Template/Summary\" has more than one name", + ], + ); +} + +#[test] +fn a_section_one_level_down_reads_its_siblings_and_any_section() { + let template = format!( + "\n```lua\n{}\n```\n", + probes( + &[ + r#"prompt.section("../Topics", "list").items"#, + r#"prompt.section("/Review")"#, + r#"prompt.section("/Write", "fence lua").lang"#, + r#"prompt.section_or("../Review")"#, + // Its own content holds two fences now, and a path from + // the top reads them. + r#"prompt.section("/Write/Template", "fence", 1).lang"#, + r#"prompt.section("/Write/Template", "fence lua").kind"#, + ], + "local probed = true", + ) + ); + assert_probes( + &report("call(\"Template\")\n\n", &template), + &[ + r#"["allocators","coroutines"]"#, + r#""Check every claim.""#, + r#""lua""#, + "nil", + r#""markdown""#, + r#""fence""#, + ], + ); +} + +/// Two workers under different parents and one top-level `Templates`. +/// `research` is the Lua of `Research`, and `worker` the Lua of its +/// `Worker`. +fn shared_template(research: &str, worker: &str) -> String { + format!( + "---\nname: shared-template\ndescription: Two workers read one template\nrunvil: 0\n\ + ---\n\n\ + # Shared template\n\n\ + ## Research\n\n\ + ```lua\n{research}\n```\n\n\ + ### Worker\n\n\ + ```lua\n{worker}\n```\n\n\ + ## Review\n\n\ + ### Worker\n\n\ + ```lua\nlocal template = prompt.section(\"/Templates\", \"fence markdown\").text\n```\n\n\ + ## Templates\n\n\ + ```markdown\n# Title\n```\n{REPORT}" + ) +} + +#[test] +fn a_shared_section_has_one_path_at_every_depth() { + let worker = probes( + &[ + r#"prompt.section("/Templates", "fence markdown").text"#, + r#"prompt.section("/Review/Worker", "fence lua").lang"#, + r#"prompt.section_or("../Templates")"#, + r#"call("/Templates")"#, + ], + "local probed = true", + ); + let research = probes( + &[ + r#"prompt.section("../Templates", "fence markdown").text"#, + r#"prompt.section("/Templates", "fence markdown").text"#, + ], + "call(\"Worker\")", + ); + assert_probes( + &shared_template(&research, &worker), + &[ + r##""# Title""##, + r##""# Title""##, + r##""# Title""##, + r#""lua""#, + "nil", + "raises: section path \"/Templates\" starts with \"/\"; only require, \ + prompt.section, and prompt.section_or take a path from the top", + ], + ); +} + +#[test] +fn one_title_under_two_parents_is_one_section_from_each_and_one_from_the_top() { + let source = format!( + "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ + # H1\n\n\ + ```lua\n{}\n```\n\n\ + ## H2\n\n\ + ```lua\n{}\n```\n\n\ + ### Items\n\n\ + - a\n- b\n\n\ + ```lua\n{}\n```\n\n\ + ## H3\n\n\ + ### Items\n\n\ + - c\n{REPORT}", + probes( + &[ + r#"prompt.section("/H2/Items", "list").items"#, + r#"prompt.section("Items")"#, + r#"prompt.section_or("Items")"#, + r#"prompt.section("H3")"#, + ], + "local probed = true", + ), + probes( + &[ + r#"prompt.section("Items", "list").items"#, + r#"prompt.section("/H3/Items", "list").items"#, + r#"prompt.section("../H3/Items")"#, + ], + "call(\"Items\")", + ), + probes( + &[ + r#"prompt.section("../Items")"#, + r#"prompt.section("/H3")"#, + r#"prompt.section("/H3/Items", "list").items[1]"#, + ], + "local probed = true", + ), + ); + assert_probes( + &source, + &[ + // From the H1 body. + r#"["a","b"]"#, + "raises: section `Items` not found: the top level has no section `Items`", + "nil", + r#""""#, + // From H2. + r#"["a","b"]"#, + r#"["c"]"#, + "raises: section path \"../H3/Items\" has more than one name", + // From Items under H2. + "raises: section `../Items` not found: `/H2/Items` has no sibling `Items`", + r#""""#, + r#""c""#, + ], + ); +} + +#[test] +fn a_library_reads_blocks_of_its_own_sections_and_of_its_users_through_g() { + let library = " + return { + read = function() + local template = prompt.section('Template', 'fence markdown').text + local topics = _G.prompt.section_or('Topics', 'list') + return template .. '|' .. (topics and table.concat(topics.items, ',') or 'none') + end, + strict = function() return _G.prompt.section('Topics', 'list') end, + }"; + let source = PromptSource::new() + .section( + "Research", + " + var.research = require('/Library').read()", + ) + .prose("### Topics", "- allocators\n- coroutines") + .section( + "Review", + " + local library = require('/Library') + local ok, err = pcall(library.strict) + return var.research .. '\\n' .. library.read() .. '\\n' .. tostring(err)", + ) + .section("Library", library) + .prose("### Template", "```markdown\n# Title\n```"); + let trace = Scenario::new(source.build()).run(); + let text = trace.text(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!( + lines[..2], + ["# Title|allocators,coroutines", "# Title|none"], + "the library's template is its own, and the topics are its user's: {text}" + ); + assert!( + lines[2].contains("section `Topics` not found: `/Review` has no child `Topics`"), + "a strict read through _G names the user: {text}" + ); +} + +#[test] +fn an_uncaught_strict_read_in_the_h1_body_fails_the_gate_before_any_section_runs() { + let source = PromptSource::new() + .h1("local template = prompt.section('/Templates', 'fence markdown').text") + .section("Main", "store.write('ran.txt', 'yes')\nreturn 'ran'") + .prose("Templates", "No fence here."); + let trace = Scenario::new(source.build()).run(); + let rendered = trace.error().to_string(); + assert!( + rendered.contains( + "prompt.section: no block in section `/Templates` matches selector \"fence markdown\"" + ), + "the read is a check that the prompt has what it needs: {rendered}" + ); + assert!( + trace.read("ran.txt").is_err(), + "no section runs after a failed gate" + ); +} + +/// The items of a section's list follow the block rules: the markers are +/// removed, a nested bullet stays in its parent's item, and a bullet with +/// no content is the item `""`. +#[test] +fn the_items_of_a_sections_list_follow_the_block_rules() { + let source = format!( + "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ + # H1\n\n\ + ## Main\n\n\ + ```lua\n{}\n```\n\n\ + ### Bullets\n\n\ + - first\n - nested\n-\n- last\n\n\ + ### Numbers\n\n\ + 1. one\n2. two\n{REPORT}", + probes( + &[ + r#"prompt.section("Bullets", "list").items"#, + r#"prompt.section("Bullets", "list").ordered"#, + r#"prompt.section("Numbers", "list").items"#, + r#"prompt.section("Numbers", "list").ordered"#, + ], + "local probed = true", + ), + ); + assert_probes( + &source, + &[ + r#"["first\n- nested","","last"]"#, + "false", + r#"["one","two"]"#, + "true", + ], + ); +} diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/prompt_reads.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/prompt_reads.rs index 26f3885b9..c24c04ad0 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/prompt_reads.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/prompt_reads.rs @@ -1,13 +1,14 @@ -//! `prompt.section` and `prompt.list`: what each returns over the caller's -//! visible set, the only calls that raise, and the reads from H1 and a -//! library. +//! `prompt.section` and `prompt.section_or`: what each returns for a +//! relative path and for a path from the top, what each does when the +//! path names no section, the calls that raise from both, and the reads +//! from H1 and a library. use super::*; -/// The raw Markdown `## Rules` holds: an indented first line, inline markup, +/// The raw Markdown `Rules` holds: an indented first line, inline markup, /// a `lua` fence, a thematic break, and a template, all of which /// `prompt.section` returns verbatim, neither run nor rendered. The break -/// resets the section's prose and list capture but not its Markdown. +/// resets the section's prose but not its Markdown. const RULES: &str = " Indented first line.\n\n\ - **Bold** and `code` stay raw.\n\n\ ```lua\n\ @@ -16,10 +17,10 @@ var.never = 'run'\n\ ---\n\n\ Closing line with {{ args }}."; -/// The sections every test joins after its own `## Main`: a child list -/// `### Kid` with a grandchild `#### Grand`, a numbered `## Nums`, the raw -/// `## Rules`, an empty `## Empty`, and `## Other` with a niece `### Niece`. -/// It stays literal because `## Rules` must come back byte for byte. +/// The sections every test joins after its own `Main`: a child list `Kid` +/// with a grandchild `Grand`, a numbered `Nums`, the raw `Rules`, an empty +/// `Empty`, and `Other` with a niece `Niece`. It stays literal because +/// `Rules` must come back byte for byte. const DOCUMENT: [&str; 3] = [ "\n### Kid\n\n\ - kid-one\n\ @@ -40,30 +41,33 @@ Other prose.\n\n\ - niece-item\n", ]; -/// Both reads search only the caller's visible set (its siblings minus -/// itself, plus its direct children): `prompt.section` returns a visible -/// section's own raw Markdown, `prompt.list` its pre-parsed items in order, -/// and a niece, a grandchild, the caller itself, and a heading no section -/// has all read as `nil` from both. +/// A relative path names a neighbor: `name` is a direct child and +/// `../name` is a sibling. `prompt.section` returns the section's own raw +/// Markdown, and with the selector `list` the items of its list in order. +/// A niece, a grandchild, the caller itself, and a name no section has +/// are no neighbor's name: `prompt.section` raises for each, and +/// `prompt.section_or` returns `nil`, with a selector and without. #[test] -fn prompt_reads_resolve_over_the_visible_set() { +fn a_relative_path_reads_a_child_or_a_sibling() { let main = prompt( "", &[( "Main", " - assert(prompt.section('### Kid') == '- kid-one\\n- kid-two', 'a child reads its own Markdown, without its children') - local kids = prompt.list('### Kid') + assert(prompt.section('Kid') == '- kid-one\\n- kid-two', 'a child reads its own Markdown, without its children') + local kids = prompt.section('Kid', 'list').items assert(#kids == 2 and kids[1] == 'kid-one' and kids[2] == 'kid-two', 'a child list reads its items') - local nums = prompt.list('## Nums') + local nums = prompt.section('../Nums', 'list').items assert(#nums == 3 and nums[1] == 'one' and nums[2] == 'two' and nums[3] == 'three', 'a sibling list reads its items in order') - assert(prompt.section('## Nums') == '1. one\\n2. two\\n3. three', 'a list section reads its raw Markdown too') - assert(prompt.section('## Empty') == '', 'an empty section reads as the empty string') - for _, heading in ipairs({'### Niece', '#### Grand', '## Main', '## Missing'}) do - assert(prompt.section(heading) == nil, heading .. ' must read nil from prompt.section') - assert(prompt.list(heading) == nil, heading .. ' must read nil from prompt.list') + assert(prompt.section('../Nums') == '1. one\\n2. two\\n3. three', 'a section that holds a list reads its raw Markdown too') + assert(prompt.section('../Empty') == '', 'an empty section reads as the empty string') + assert(prompt.section_or('Kid') == prompt.section('Kid'), 'both reads return a section that is there') + for _, path in ipairs({'Niece', 'Grand', '../Kid', 'Nums', '../Main', '../Missing', '## Nums'}) do + assert(not pcall(prompt.section, path), path .. ' must raise from prompt.section') + assert(prompt.section_or(path) == nil, path .. ' must read nil from prompt.section_or') + assert(prompt.section_or(path, 'list') == nil, path .. ' must read nil with a selector') end - return prompt.section('## Rules')", + return prompt.section('../Rules')", )], ); let trace = Scenario::new(main + &DOCUMENT.concat()).run(); @@ -74,14 +78,53 @@ fn prompt_reads_resolve_over_the_visible_set() { ); } -/// A read raises only when the call itself is wrong: a target that is not a -/// string, a malformed heading, or `prompt.list` naming a section with no -/// pre-parsed items, whose message names the trimmed heading. A heading no -/// section has and a heading outside the visible set are not mistakes, so -/// both read as `nil`. A `call` to a heading no section has still raises, -/// and its listing of the available sections names only the visible set. +/// A path from the top names any section, and the same one from +/// everywhere: the caller itself, its parent, a grandchild, and a section +/// nested under another parent. A path that names no section raises from +/// `prompt.section` and reads as `nil` from `prompt.section_or`. #[test] -fn prompt_reads_raise_only_for_mistakes_in_the_call() { +fn a_path_from_the_top_reads_any_section_from_any_place() { + let reads = " + assert(prompt.section('/Main') ~= nil, 'a top-level section reads') + assert(prompt.section('/Main/Kid') == '- kid-one\\n- kid-two', 'a nested section reads its own Markdown') + assert(prompt.section('/Main/Kid/Grand', 'list').items[1] == 'grand-item', 'a grandchild reads') + assert(prompt.section(' / Other / Niece ', 'list').items[1] == 'niece-item', 'a section under another parent reads') + assert(prompt.section('/Other') == 'Other prose.', 'a section reads without its children') + assert(prompt.section('/Empty') == '', 'an empty section reads as the empty string') + assert(prompt.section('/Tree/Leaf'):find('leaf', 1, true), 'a nested section with Lua reads') + assert(prompt.section('/Tree'):find('Leaf', 1, true), 'its parent reads') + for _, path in ipairs({'/Kid', '/Main/Niece', '/Missing/Kid', '/Other/Niece/Deeper', '/Reads/Main'}) do + assert(not pcall(prompt.section, path), path .. ' must raise from prompt.section') + assert(prompt.section_or(path) == nil, path .. ' must read nil from prompt.section_or') + assert(prompt.section_or(path, 'list') == nil, path .. ' must read nil with a selector') + end"; + let head = PromptSource::new() + .h1(reads) + .section( + "Main", + &format!("{reads}\nreturn call('../Tree') .. '|' .. prompt.section('/Rules')"), + ) + .build(); + // `Leaf` reads itself and its parent by the same paths as everyone. + let tree = format!( + "\n## Tree\n\n```lua\nreturn call('Leaf')\n```\n\n\ + ### Leaf\n\n```lua\n{reads}\nreturn 'leaf'\n```\n" + ); + let trace = Scenario::new([head, DOCUMENT.concat(), tree].concat()).run(); + assert_eq!( + trace.text(), + format!("leaf|{RULES}"), + "the H1, a top-level section, and a nested one read the same sections" + ); +} + +/// Both reads raise when the call itself is wrong: a target that is not a +/// string, or a malformed path. A path that names no section is an +/// absence, not a mistake: `prompt.section` raises the not-found error, +/// which is the one `call` raises for the same path, says where it +/// looked, and lists no section, and `prompt.section_or` returns `nil`. +#[test] +fn both_reads_raise_for_a_mistake_and_only_section_raises_for_a_miss() { let main = prompt( "", &[( @@ -92,29 +135,37 @@ fn prompt_reads_raise_only_for_mistakes_in_the_call() { assert(not ok, 'expected a raise for ' .. tostring(target)) assert(tostring(err):find(needle, 1, true), tostring(target) .. ' must raise with ' .. needle .. ', got: ' .. tostring(err)) end - for _, read in ipairs({prompt.section, prompt.list}) do + for _, read in ipairs({prompt.section, prompt.section_or}) do expect_raise(read, 42, 'section target must be a string, got integer') - expect_raise(read, 'Nums', 'got bare name: Nums') - expect_raise(read, '##Nums', 'must have whitespace after the ## markers') - for _, heading in ipairs({'## Missing', '### Niece'}) do - local ok, value = pcall(read, heading) - assert(ok and value == nil, heading .. ' must read as nil, not raise: ' .. tostring(value)) - end + expect_raise(read, '', 'section path \"\" has an empty step') + expect_raise(read, 'Kid/', 'section path \"Kid/\" has an empty step') + expect_raise(read, 'Kid/Grand', 'section path \"Kid/Grand\" has more than one name; a relative path is a child\\'s name, or \"../\" and a sibling\\'s name') + expect_raise(read, '..', 'section path \"..\" needs a name after \"..\"') + expect_raise(read, 'Kid[1]', 'section path \"Kid[1]\" has an index; a path in a prompt takes none') + expect_raise(read, '/Main/../Nums', 'section path \"/Main/../Nums\" uses \"..\"; a path from the top takes none') end - expect_raise(prompt.list, ' ## Rules ', 'section `## Rules` has no pre-parsed items') - expect_raise(prompt.list, '## Empty', 'section `## Empty` has no pre-parsed items') - assert(prompt.section('## Rules') ~= nil, 'prompt.section reads a section prompt.list refuses') - local ok, err = pcall(call, '## Missing') - assert(not ok, 'a call to a missing heading still raises') - local message = tostring(err) - local at = message:find('available sections:', 1, true) - assert(at, 'the call error lists the available sections: ' .. message) - local listed = message:sub(at) - for _, heading in ipairs({'## Other', '### Kid'}) do - assert(listed:find(heading, 1, true), 'the listing names the visible ' .. heading .. ': ' .. message) + local misses = { + ['../Missing'] = 'section `../Missing` not found: `/Main` has no sibling `Missing`', + ['Niece'] = 'section `Niece` not found: `/Main` has no child `Niece`', + [' Nums '] = 'section `Nums` not found: `/Main` has no child `Nums`', + ['/Missing'] = 'section `/Missing` not found: the top level has no section `Missing`', + ['/Other/Neice'] = 'section `/Other/Neice` not found: `/Other` has no child `Neice`', + } + for path, message in pairs(misses) do + expect_raise(prompt.section, path, message) + local ok, value = pcall(prompt.section_or, path) + assert(ok and value == nil, path .. ' must read as nil, not raise: ' .. tostring(value)) end - for _, hidden in ipairs({'## Main', 'Niece', 'Grand'}) do - assert(not listed:find(hidden, 1, true), 'the listing leaks ' .. hidden .. ': ' .. message) + local names = {} + for name in pairs(prompt) do names[#names + 1] = name end + table.sort(names) + assert(table.concat(names, ',') == 'section,section_or', 'the prompt table holds the two reads and nothing else') + local ok_call, err_call = pcall(call, '../Missing'); local ok_read, err_read = pcall(prompt.section, '../Missing') + assert(not ok_call and not ok_read, 'both raise for a missing section') + assert(tostring(err_call) == tostring(err_read), tostring(err_call) .. ' vs ' .. tostring(err_read)) + local message = tostring(err_read) + for _, other in ipairs({'Other', 'Kid', 'Nums', 'Niece', 'Grand'}) do + assert(not message:find(other, 1, true), 'the error lists ' .. other .. ': ' .. message) end return 'ok'", )], @@ -123,47 +174,58 @@ fn prompt_reads_raise_only_for_mistakes_in_the_call() { assert_eq!( trace.text(), "ok", - "every raise must be caught and every miss must read nil" + "every raise must be caught and every lenient miss must read nil" ); } -/// Both reads work in H1, whose visible set is the whole top-level slice -/// (every top-level section and no child), and in a library, which reads -/// from the calling section: at load time, where each requiring section's -/// load resolves over that section's own visible set, and from a library -/// function, which resolves over the visible set of whichever section -/// calls it. +/// Both reads work in H1, whose children are the top-level sections and +/// which has no siblings, and in a library. A library's own `prompt` +/// reads from the library, and `_G.prompt` in its code reads from the +/// section that is using it: at load time, where each requiring section +/// loads the library for itself, and from a library function. #[test] fn prompt_reads_work_in_h1_and_a_library() { let head = PromptSource::new() .h1( " - require '## kid_reader' - assert(prompt.section('## Main') ~= nil, 'H1 sees every top-level section') - assert(prompt.section('### Kid') == nil, 'H1 sees no child') - assert(kids_at_load == nil, 'the load-time read in the H1 resolves over the H1 visible set') - assert(kid_items() == nil, 'a library function reads over the H1 visible set') - var.nums = table.concat(prompt.list('## Nums'), ',')", + require '/kid_reader' + assert(prompt.section('Main') ~= nil, 'a top-level section is a child of the H1') + assert(prompt.section_or('Kid') == nil, 'a nested section is no child of the H1') + assert(prompt.section_or('../Main') == nil, 'the H1 has no siblings') + assert(kids_at_load == nil, 'the load-time read through _G in the H1 resolves from the H1') + assert(kid_items() == nil, 'a library function reads through _G from the H1') + assert(own_kid() == 'own kid', 'a library reads its own child from the H1') + var.nums = table.concat(prompt.section('Nums', 'list').items, ',')", ) .section( "Main", " - require '## kid_reader' - assert(kids_at_load ~= nil, 'the load-time read in Main resolves over the Main visible set') - return var.nums .. '|' .. table.concat(kids_at_load, ',') .. '|' .. table.concat(kid_items(), ',')", + require '/kid_reader' + assert(kids_at_load ~= nil, 'the load-time read through _G in Main resolves from Main') + return table.concat({ var.nums, table.concat(kids_at_load, ','), + table.concat(kid_items(), ','), own_kid() }, '|')", ); let library = "\n## kid_reader\n\n\ ```lua\n\ -kids_at_load = prompt.list('### Kid')\n\ +local function users_kids()\n\ + local list = _G.prompt.section_or('Kid', 'list')\n\ + return list and list.items\n\ +end\n\ +kids_at_load = users_kids()\n\ function kid_items()\n\ - return prompt.list('### Kid')\n\ + return users_kids()\n\ +end\n\ +function own_kid()\n\ + return prompt.section('Kid')\n\ end\n\ -```\n"; +```\n\n\ +### Kid\n\n\ +own kid\n"; let source = [head.build().as_str(), &DOCUMENT.concat(), library].concat(); let trace = Scenario::new(source).run(); let out = trace.text(); assert_eq!( - out, "one,two,three|kid-one,kid-two|kid-one,kid-two", - "H1 reads the top-level list, and both the load-time read and the library function read the caller's child" + out, "one,two,three|kid-one,kid-two|kid-one,kid-two|own kid", + "H1 reads the top-level list, the library reads its user's child through _G, and its own child by a plain name" ); } diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/store_failures.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/store_failures.rs index 48f8fb76c..60db1b82a 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/store_failures.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/exec_flow/store_failures.rs @@ -147,7 +147,7 @@ fn a_caught_store_error_raised_again_after_a_suspending_call_keeps_store() { " local ok, err = pcall(function() return store.read('missing.txt') end) assert(ok == false, 'the missing read fails') - call('### Sibling', '') + call('Sibling', '') error(err)", ), ("### Sibling", "return 'sibling'"), @@ -207,13 +207,13 @@ fn arms_requiring_a_seeder(seeder: &str) -> String { ( "Parent", " - local r = tasks.fanout('### Worker', {'alpha', 'beta'}) + local r = tasks.fanout('Worker', {'alpha', 'beta'}) return r[1].text", ), ( "### Worker", " - require '## seeder' + require '/seeder' return item", ), ("seeder", seeder), @@ -270,13 +270,13 @@ fn a_captured_store_function_conflict_caught_with_pcall_still_ends_the_run_with_ ( "Parent", " - local r = tasks.fanout('### Worker', {'alpha', 'beta'}) + local r = tasks.fanout('Worker', {'alpha', 'beta'}) return r[1].text", ), ( "### Worker", " - require '## notes' + require '/notes' pcall(save_note, 'note.txt', item) return item", ), @@ -301,7 +301,7 @@ fn a_captured_store_function_called_after_load_reaches_the_caller_as_a_store_eff ( "Only", " - require '## notes' + require '/notes' save_note('note.txt', 'kept') return 'saved'", ), diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/fanout.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/fanout.rs index 44051efb9..035f6120d 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/fanout.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/fanout.rs @@ -19,7 +19,7 @@ const FANOUT_CROSS_ARM_APPEND: &str = /// task the shim spawns, so its `Task started` reports under the caller. const CALLER_SECTION: &str = "Research"; -/// The worker-template section name both fanout arms execute under: an +/// The name of the worker section both fanout arms execute under: an /// arm's terminal task observation reports under it. The stream keys arm /// events by section, not by `sys.index` (which the runtime injects only /// into arm Lua), so the exact per-arm index pairing is proven by the arms' @@ -157,7 +157,7 @@ fn json_encode_of_a_fanout_result_gives_its_fields_as_an_object() { ( "Parent", " - local r = tasks.fanout('### Worker', { 'alpha' }) + local r = tasks.fanout('Worker', { 'alpha' }) return json.encode(r[1])", ), ("### Worker", "return item .. '-' .. sys.index"), diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/global_metatable.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/global_metatable.rs index 67fc2c4e2..894dec9d5 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/global_metatable.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/global_metatable.rs @@ -18,7 +18,7 @@ fn a_library_defaults_metatable_works_beside_frozen_argv_and_read_only_prose() { let source = query_source() .h1( " - require '## defaults' + require '/defaults' assert(tone == 'friendly', 'H1 reads the default') argv = { query = 'repaired' } assert(not pcall(function() prose = 'x' end), 'prose is read-only in H1')", @@ -29,7 +29,7 @@ fn a_library_defaults_metatable_works_beside_frozen_argv_and_read_only_prose() { Tone: {{ tone }}. ```lua - require '## defaults' + require '/defaults' assert(prose == 'Tone: friendly.', 'a default renders in prose: ' .. prose) assert(missing == nil, 'a global with no default reads nil') local ok, err = pcall(function() argv = 'hijacked' end) @@ -49,7 +49,7 @@ fn a_library_defaults_metatable_works_beside_frozen_argv_and_read_only_prose() { .section( "Second", " - require '## defaults' + require '/defaults' assert(argv.query == 'repaired', 'the H1 repair reaches a later section') assert(not pcall(function() argv = 'hijacked' end), 'argv stays frozen in a later section') return argv.query .. ' ' .. tone", @@ -69,7 +69,7 @@ fn a_strict_metatable_raises_its_own_error_while_the_guard_serves_argv_prose_and let source = query_source() .h1( " - require '## strict' + require '/strict' local ok, err = pcall(function() return undefined_in_h1 end) assert(not ok and tostring(err):find('undefined global undefined_in_h1', 1, true), 'H1 is strict: ' .. tostring(err))", ) @@ -79,7 +79,7 @@ fn a_strict_metatable_raises_its_own_error_while_the_guard_serves_argv_prose_and Hi. ```lua - require '## strict' + require '/strict' local ok, err = pcall(function() return undefined_here end) assert(not ok, 'an undefined global raises') assert(type(err) == 'string', 'the author error comes back as raised, got ' .. type(err)) @@ -107,13 +107,13 @@ fn a_strict_metatable_raises_its_own_error_while_the_guard_serves_argv_prose_and fn an_h1_repair_lands_under_a_strict_index_and_a_write_hook() { let source = query_source() .h1(" - require '## hooks' + require '/hooks' if argv == nil then argv = { query = 'repaired' } end assert(seen.argv == nil, 'the write hook never sees argv')") .section( "Only", " - require '## hooks' + require '/hooks' assert(argv.query == 'repaired', 'the repair reaches the walk') return argv.query", ) @@ -177,7 +177,7 @@ fn a_library_cannot_assign_prose() { The real prose. ```lua - require '## lib' + require '/lib' assert(library_prose == 'Library prose.', 'the library reads its own prose: ' .. tostring(library_prose)) assert(library_refusal and library_refusal:find('prose is read-only', 1, true), 'the library cannot assign prose: ' .. tostring(library_refusal)) return prose @@ -206,7 +206,7 @@ fn getmetatable_on_g_returns_a_librarys_metatable_and_edits_apply_live() { .section( "Only", " - require '## metatable' + require '/metatable' assert(getmetatable(_G) == author_mt, 'getmetatable(_G) is the library metatable') getmetatable(_G).__index = function(_, key) return 'live ' .. key end assert(tone == 'live tone', 'an edit applies at once') diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/lazy_prose.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/lazy_prose.rs index 0754f7a3e..a806b90c2 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/lazy_prose.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/lazy_prose.rs @@ -240,7 +240,7 @@ fn lazy_prose_composes_with_a_library_metatable() { Hello {{ var.word }}. ```lua - require '## capture' + require '/capture' var.word = 'world' plain = 'x' assert(captured.plain == 'x', 'the library __newindex still captures') diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/markdown.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/markdown.rs new file mode 100644 index 000000000..2f40b6e49 --- /dev/null +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/markdown.rs @@ -0,0 +1,149 @@ +//! The `markdown` global in a running prompt: text from the store and +//! from a `prompt` read parsed into blocks and written back as the same +//! text, a parse result carried to a later section in `var`, and parsed +//! sections as a fanout collection. + +use runvil_prompt::test_support::PromptSource; + +use crate::test_support::scenario::Scenario; + +/// A stored document with a title, two sections, and a fence in the +/// second. +const NOTES: &str = "# Notes\\n\\n## Abstract\\n\\nShort and plain.\\n\\n## Data\\n\\n\ +```json\\n{ \"ok\": true }\\n```\\n"; + +#[test] +fn a_stored_file_is_read_then_parsed_and_one_section_is_written_back_as_text() { + let source = PromptSource::new().section( + "Main", + &format!( + " + store.write('notes.md', '{NOTES}') + local paper = markdown.parse(store.read('notes.md')) + local abstract = markdown.render(markdown.section(paper, '//Abstract').blocks) + local data = json.decode(markdown.section(paper, '/Notes/Data', 'fence json').text) + local titles = {{}} + for _, section in ipairs(markdown.select(paper, 'section')) do + titles[#titles + 1] = section.title + end + return abstract .. '|' .. tostring(data.ok) .. '|' .. table.concat(titles, ',') + .. '|' .. tostring(markdown.section_or(paper, '//Findings'))" + ), + ); + assert_eq!( + Scenario::new(source.build()).run().text(), + "Short and plain.\n\n|true|Notes,Abstract,Data|nil", + "a section's blocks are written with the blank lines that followed them" + ); +} + +#[test] +fn a_stored_file_is_parsed_and_written_back_as_the_same_text() { + let source = PromptSource::new().section( + "Main", + &format!( + " + store.write('notes.md', '{NOTES}') + local text = store.read('notes.md') + local paper = markdown.parse(text) + local sources = {{}} + for index, block in ipairs(markdown.select(paper, '*')) do + sources[index] = block.source + end + store.write('copy.md', markdown.render(paper)) + return table.concat({{ + tostring(store.read('copy.md') == text), + tostring(table.concat(sources) == text), + #sources, + #text, + }}, '|')" + ), + ); + // The file is five blocks and 76 bytes, so the comparisons are not of + // empty text. + assert_eq!(Scenario::new(source.build()).run().text(), "true|true|5|76"); +} + +#[test] +fn the_text_a_prompt_read_returns_parses_and_its_fence_parses_again() { + let source = PromptSource::new() + .section( + "Write", + " + local blocks = markdown.parse(prompt.section('Template')) + local outline = markdown.parse(blocks[2].text) + return table.concat({ #blocks, blocks[1].kind, blocks[2].lang, blocks[2].line, + markdown.section(outline, '/Title/Summary').level }, '|')", + ) + .prose( + "### Template", + " + Fill in this outline. + + ```markdown + # Title + + ## Summary + ```", + ); + assert_eq!( + Scenario::new(source.build()).run().text(), + "2|prose|markdown|3|2", + "a parse of a section's text counts lines from the section's first line" + ); +} + +#[test] +fn a_parse_result_kept_in_var_works_with_every_function_in_a_later_section() { + let source = PromptSource::new() + .section( + "Parse", + &format!( + " + var.text = '{NOTES}' + var.doc = markdown.parse(var.text)" + ), + ) + .section( + "Use", + " + local doc = var.doc + return table.concat({ + markdown.section(doc, '/Notes/Abstract', 'prose').text, + markdown.section(doc, '//Data', 'fence').lang, + tostring(markdown.section_or(doc, '/Notes/Missing')), + #markdown.select(doc, 'section'), + #markdown.select(doc, '*'), + markdown.render(markdown.section(doc, '//Data').blocks), + tostring(markdown.render(doc) == var.text), + }, '|')", + ); + assert_eq!( + Scenario::new(source.build()).run().text(), + "Short and plain.|json|nil|3|5|```json\n{ \"ok\": true }\n```\n|true", + "the copy kept in var holds every source, so it is written back as the text" + ); +} + +#[test] +fn parsed_sections_are_a_fanout_collection_and_each_arm_gets_one_as_its_item() { + let source = PromptSource::new() + .section( + "Main", + &format!( + " + local doc = markdown.parse('{NOTES}') + local sections = markdown.section(doc, '/Notes').blocks + local replies = tasks.fanout('Worker', sections) + return tostring(replies[1]) .. '|' .. tostring(replies[2])" + ), + ) + .section( + "### Worker", + "return item.title .. ':' .. item.kind .. ':' .. #item.blocks .. ':' .. sys.index", + ); + assert_eq!( + Scenario::new(source.build()).run().text(), + "Abstract:section:1:1|Data:section:1:2" + ); +} diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/parsing.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/parsing.rs index 699366e11..906db7d3b 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/parsing.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/parsing.rs @@ -79,7 +79,7 @@ fn verify_require_library(prompt: &Prompt) { assert!(prepare.prologue().is_none()); assert_eq!( prepare.epilog().map(LuaProgram::source), - Some("require '## helpers'\nvar.subject = normalize(args)") + Some("require '/helpers'\nvar.subject = normalize(args)") ); assert_eq!(prepare.children().len(), 1); assert_eq!(prepare.children()[0].name(), "Author note"); @@ -94,7 +94,7 @@ fn verify_require_library(prompt: &Prompt) { assert_eq!(finish.prose(), "Return the normalized subject."); assert_eq!( finish.epilog().map(LuaProgram::source), - Some("require '## helpers'\nreturn normalize(var.subject)") + Some("require '/helpers'\nreturn normalize(var.subject)") ); assert!(finish.children().is_empty()); diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/require.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/require.rs index b81dfc4ca..690f5523f 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/require.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/require.rs @@ -31,6 +31,7 @@ fn load(section: &str, detail: &str) -> (String, String) { } mod effects; +mod environment; mod failures; mod loading; mod prose; diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/require/effects.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/require/effects.rs index 8770a9435..b894ffe8c 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/require/effects.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/require/effects.rs @@ -25,7 +25,7 @@ fn effect_source() -> PromptSource { #[test] fn library_code_makes_store_model_and_tool_calls_while_it_loads() { let source = effect_source() - .section("Main", "return require '## loader'") + .section("Main", "return require '/loader'") .section( "loader", " @@ -44,11 +44,11 @@ fn library_code_makes_store_model_and_tool_calls_while_it_loads() { #[test] fn an_h1_library_sets_prompt_wide_state_every_later_section_sees() { let source = effect_source() - .h1("require '## setup'") + .h1("require '/setup'") .section( "Main", " - require '## offers' + require '/offers' messages:user('ping') models.loop() return var.tag .. ':' .. messages[#messages].content", diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/require/environment.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/require/environment.rs new file mode 100644 index 000000000..700c1b30d --- /dev/null +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/require/environment.rs @@ -0,0 +1,383 @@ +//! A relative path in a library's code starts at the library's section. +//! +//! Whole prompts through the scheduler. In each, the library and the +//! section using it both have a child with the same title, so a lookup +//! made from the wrong place returns the wrong text. + +use super::*; + +#[test] +fn a_library_function_fans_out_over_the_librarys_worker_from_every_kind_of_caller() { + let source = PromptSource::new() + .h1("var.h1 = tostring(require('/kit').run({ 'h' })[1])") + .section( + "Research", + " + local kit = require '/kit' + local own = tostring(tasks.fanout('Worker', { 'r' })[1]) + local mine = table.concat(kit.run({ 'alpha', 'beta' }), ',') + return var.h1 .. '|' .. own .. '|' .. mine .. '|' .. call('Deep')", + ) + .section("### Worker", "return 'research worker ' .. item") + .section( + "### Deep", + "return tostring(require('/kit').run({ 'deep' })[1])", + ) + .section( + "kit", + "return { run = function(items) return tasks.fanout('Worker', items) end }", + ) + .section( + "### Worker", + " + return 'kit worker ' .. item .. ' in ' .. sys.section_name .. ' beside ' + .. tostring(prompt.section('../Notes'))", + ) + .prose("### Notes", "Kit notes."); + let trace = require_case(source.build()).run(); + let arm = |item: &str| format!("kit worker {item} in Worker beside Kit notes."); + assert_eq!( + trace.text(), + [ + arm("h"), + "research worker r".to_owned(), + format!("{},{}", arm("alpha"), arm("beta")), + arm("deep"), + ] + .join("|"), + "from the H1 pass, a top-level section, and a nested section, the library's fanout \ + runs the library's worker; the section's own fanout runs its own; each arm runs in \ + the library's subtree" + ); +} + +#[test] +fn a_library_call_reaches_the_librarys_child_at_load_and_later() { + let source = PromptSource::new() + .section( + "Main", + " + local kit = require '/kit' + return kit.at_load .. '|' .. kit.help() .. '|' .. call('helper')", + ) + .section("### helper", "return 'main helper'") + .section( + "kit", + " + local at_load = call('helper') + return { at_load = at_load, help = function() return call('helper') end }", + ) + .section("### helper", "return 'kit helper'"); + let trace = require_case(source.build()).run(); + assert_eq!(trace.text(), "kit helper|kit helper|main helper"); +} + +#[test] +fn a_library_read_reaches_the_librarys_child_and_g_prompt_reaches_the_callers() { + let source = PromptSource::new() + .section( + "Main", + " + local kit = require '/kit' + return kit.at_load .. '|' .. kit.template() .. '|' .. kit.users() .. '|' + .. prompt.section('Template')", + ) + .prose("### Template", "Main template.") + .section( + "kit", + " + local at_load = prompt.section('Template') + return { + at_load = at_load, + template = function() return prompt.section('Template') end, + users = function() return _G.prompt.section('Template') end, + }", + ) + .prose("### Template", "Kit template."); + let trace = require_case(source.build()).run(); + assert_eq!( + trace.text(), + "Kit template.|Kit template.|Main template.|Main template." + ); +} + +#[test] +fn g_call_and_g_tasks_in_a_librarys_code_start_at_the_running_section() { + let source = PromptSource::new() + .section( + "Main", + " + local kit = require '/kit' + return kit.users_call() .. '|' .. tostring(kit.users_fanout({ 'a' })[1]) .. '|' + .. tasks.join({ kit.users_spawn('b') })[1].result", + ) + .section("### helper", "return 'main helper'") + .section("### Worker", "return 'main worker ' .. item") + .section( + "kit", + " + return { + users_call = function() return _G.call('helper') end, + users_fanout = function(items) return _G.tasks.fanout('Worker', items) end, + users_spawn = function(item) return _G.tasks.spawn('Worker', { item = item }) end, + }", + ) + .section("### helper", "return 'kit helper'") + .section("### Worker", "return 'kit worker ' .. item"); + let trace = require_case(source.build()).run(); + assert_eq!(trace.text(), "main helper|main worker a|main worker b"); +} + +#[test] +fn a_miss_in_a_librarys_code_names_the_library_and_one_through_g_names_the_running_section() { + let source = PromptSource::new() + .section( + "Review", + " + local kit = require '/kit' + local messages = {} + for _, read in ipairs({ kit.own, kit.users, kit.users_call }) do + local ok, err = pcall(read) + assert(not ok, 'each lookup misses') + messages[#messages + 1] = tostring(err) + end + return table.concat(messages, '\\n')", + ) + .section( + "kit", + " + return { + own = function() return prompt.section('Topics') end, + users = function() return _G.prompt.section('Topics') end, + users_call = function() return _G.call('../Topics') end, + }", + ); + let trace = require_case(source.build()).run(); + let text = trace.text(); + let messages: Vec<&str> = text.lines().collect(); + let [own, users, users_call] = messages.as_slice() else { + panic!("three messages: {messages:?}"); + }; + assert!( + own.contains("section `Topics` not found: `/kit` has no child `Topics`"), + "the library's own read searched the library: {own}" + ); + assert!( + users.contains("section `Topics` not found: `/Review` has no child `Topics`"), + "a read through _G searched the running section: {users}" + ); + assert!( + users_call.contains("section `../Topics` not found: `/Review` has no sibling `Topics`"), + "a call through _G searched the running section: {users_call}" + ); +} + +#[test] +fn a_library_spawn_starts_the_librarys_worker() { + let source = PromptSource::new() + .section( + "Main", + " + local kit = require '/kit' + local results = tasks.join({ kit.start('x'), tasks.spawn('Worker', { item = 'y' }) }) + return results[1].result .. '|' .. results[2].result", + ) + .section("### Worker", "return 'main worker ' .. item") + .section( + "kit", + "return { start = function(item) return tasks.spawn('Worker', { item = item }) end }", + ) + .section("### Worker", "return 'kit worker ' .. item"); + let trace = require_case(source.build()).run(); + assert_eq!(trace.text(), "kit worker x|main worker y"); +} + +#[test] +fn each_of_two_libraries_reaches_its_own_child_when_one_requires_the_other() { + let source = PromptSource::new() + .section( + "Main", + " + local a = require '/a' + return a.help() .. '|' .. a.b_help()", + ) + .section( + "a", + " + local b = require '/b' + return { help = function() return call('helper') end, b_help = b.help }", + ) + .section("### helper", "return 'helper of a'") + .section( + "b", + "return { help = function() return call('helper') end }", + ) + .section("### helper", "return 'helper of b'"); + let trace = require_case(source.build()).run(); + assert_eq!(trace.text(), "helper of a|helper of b"); +} + +#[test] +fn every_arm_that_requires_a_library_reaches_the_librarys_child() { + let source = PromptSource::new() + .section( + "Main", + "return table.concat(tasks.fanout('Arm', { 'a', 'b' }), ',')", + ) + .section("### Arm", "return require('/kit').help() .. ' ' .. item") + .section("### helper", "return 'main helper'") + .section( + "kit", + "return { help = function() return call('helper') end }", + ) + .section("### helper", "return 'kit helper'"); + let trace = require_case(source.build()).run(); + assert_eq!(trace.text(), "kit helper a,kit helper b"); +} + +#[test] +fn a_tool_handler_written_in_a_library_reaches_the_librarys_child() { + let source = PromptSource::new() + .section( + "Main", + " + local kit = require '/kit' + local mine = tools.adhoc({ name = 'mine', description = 'Mine' }, function() + return call('helper') + end) + return tools.call(kit.tool, {}) .. '|' .. tools.call(mine, {})", + ) + .section("### helper", "return 'main helper'") + .section( + "kit", + " + return { tool = tools.adhoc({ name = 'grab', description = 'Grab' }, function() + return call('helper') + end) }", + ) + .section("### helper", "return 'kit helper'"); + let trace = require_case(source.build()).bound().run(); + assert_eq!( + trace.text(), + "kit helper|main helper", + "a handler keeps the environment of the code it was written in" + ); +} + +#[test] +fn a_library_function_that_jumps_still_moves_the_callers_walk() { + // `jump` moves the running section's walk, so its path names a + // neighbor of the running section, in a library's code too. + let source = PromptSource::new() + .section( + "Main", + " + require('/kit').go()", + ) + .section("### next", "return 'main next'") + .section("kit", "return { go = function() jump 'next' end }") + .section("### next", "return 'kit next'"); + let trace = require_case(source.build()).run(); + assert_eq!(trace.text(), "main next"); +} + +#[test] +fn a_name_the_caller_passes_to_a_library_is_looked_up_in_the_library() { + let source = PromptSource::new() + .section( + "Main", + " + local kit = require '/kit' + local ok, err = pcall(kit.run, 'Mine') + return tostring(ok) .. '|' .. tostring(err)", + ) + .section("### Mine", "return 'mine'") + .section( + "kit", + "return { run = function(name) return call(name) end }", + ) + .section("### Worker", "return 'w'"); + let trace = require_case(source.build()).run(); + let text = trace.text(); + assert!( + text.starts_with("false|") + && text.contains("section `Mine` not found: `/kit` has no child `Mine`"), + "the miss names the library as the section it searched, got {text}" + ); +} + +#[test] +fn an_uncaught_miss_in_a_library_function_is_placed_on_the_librarys_line() { + let source = PromptSource::new() + .section("Main", "return require('/kit').run()") + .section( + "kit", + " + return { run = function() + local found = call('Missing') + return found + end }", + ) + .build(); + let line = source + .lines() + .position(|line| line.contains("call('Missing')")) + .expect("the source holds the call") + + 1; + let trace = require_case(source).run(); + let error = trace.error(); + assert!( + error + .message + .contains("section `Missing` not found: `/kit` has no child `Missing`"), + "got {error:?}" + ); + assert_eq!( + error.at.as_ref().map(|at| at.start_line.get() as usize), + Some(line), + "the failure names the line of the call inside the library" + ); +} + +#[test] +fn a_tail_called_miss_resolves_from_the_library_and_is_placed_on_the_callers_line() { + // Where the target resolves from does not depend on the stack: the + // library's `call` carries the library. Where the failure is placed + // does: a tail call drops the library function's frame, so the + // innermost author frame is the caller's. + let source = PromptSource::new() + .section( + "Main", + " + local kit = require '/kit' + local out = kit.run() + return out", + ) + .section("### Missing", "return 'the callers own'") + .section( + "kit", + " + return { run = function() + return call('Missing') + end }", + ) + .build(); + let line = source + .lines() + .position(|line| line.contains("kit.run()")) + .expect("the source holds the call") + + 1; + let trace = require_case(source).run(); + let error = trace.error(); + assert!( + error + .message + .contains("section `Missing` not found: `/kit` has no child `Missing`"), + "the lookup ran in the library, where there is no such child, got {error:?}" + ); + assert_eq!( + error.at.as_ref().map(|at| at.start_line.get() as usize), + Some(line), + "the failure names the caller's line" + ); +} diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/require/failures.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/require/failures.rs index 7f587d928..270bbc863 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/require/failures.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/require/failures.rs @@ -1,5 +1,6 @@ -//! What raises: a cycle, a heading out of reach or misspelled, a `jump` -//! during a load, and a Pack prelude that defines or calls `require`. +//! What raises: a cycle, a path that names no section or does not start +//! at the top, a `jump` during a load, and a Pack prelude that defines or +//! calls `require`. //! A library that raises while loading runs again on the next `require`, //! and a raise in library code reports the library's location and //! prompt-file line. @@ -33,45 +34,72 @@ fn a_cycle_raises_naming_its_sections_in_order() { let source = prompt( "", &[ - ("Main", "require '## a'"), - ("a", "require '## b'"), - ("b", "require '## a'"), + ("Main", "require '/a'"), + ("a", "require '/b'"), + ("b", "require '/a'"), ], ); let message = lua_failure(&require_case(source).run()); assert!( - message.contains("require cycle: ## a -> ## b -> ## a"), + message.contains("require cycle: /a -> /b -> /a"), "the cycle names both libraries: {message}" ); } #[test] -fn an_out_of_reach_or_misspelled_heading_parses_and_raises_when_the_call_runs() { +fn a_section_that_requires_itself_meets_the_cycle_error() { + let source = prompt("", &[("Main", "require '/Main'")]); + let message = lua_failure(&require_case(source).run()); + assert!( + message.contains("require cycle: /Main -> /Main"), + "the load of Main requires Main again: {message}" + ); +} + +#[test] +fn a_path_that_names_no_section_or_is_not_from_the_top_raises_when_the_call_runs() { let source = PromptSource::new() .section( "Main", " - local _, far = pcall(require, '### deep') - local _, typo = pcall(require, '## helpres') - return tostring(far) .. '\\n' .. tostring(typo)", + local messages = {} + for _, path in ipairs({ '/helpres', '/other/deeper', 'helpers', '../helpers', '## helpers' }) do + local ok, err = pcall(require, path) + assert(not ok, path .. ' must raise') + messages[#messages + 1] = tostring(err) + end + return table.concat(messages, '\\n')", ) .prose("other", "") .section("### deep", "return 'deep'") .section("helpers", "return 'helpers'"); let trace = require_case(source.build()).run(); - let (far, typo) = trace.text().split_once('\n').expect("two messages"); + let text = trace.text(); + let messages: Vec<&str> = text.lines().collect(); + let [typo, deeper, child, sibling, marks] = messages.as_slice() else { + panic!("five messages: {messages:?}"); + }; assert!( - far.contains( - "section heading `### deep` not found; available sections: ## other, ## helpers" - ), - "a sibling's child is out of reach: {far}" + typo.contains("section `/helpres` not found: the top level has no section `helpres`"), + "a misspelled title names where the walk stopped: {typo}" ); assert!( - typo.contains( - "section heading `## helpres` not found; available sections: ## other, ## helpers" - ), - "a misspelled heading names the reach: {typo}" + deeper.contains("section `/other/deeper` not found: `/other` has no child `deeper`"), + "a miss below the top names the section that was searched: {deeper}" ); + for (path, message) in [ + ("helpers", child), + ("../helpers", sibling), + ("## helpers", marks), + ] { + assert!( + message.contains(&format!( + "section path \"{path}\" does not start with \"/\"; require takes a path \ + from the top, such as \"/helpers\"" + )), + "a relative path is refused: {message}" + ); + } } #[test] @@ -79,8 +107,8 @@ fn a_jump_during_a_load_raises() { let source = prompt( "", &[ - ("Main", "require '## jumper'"), - ("jumper", "jump('## Last')"), + ("Main", "require '/jumper'"), + ("jumper", "jump('../Last')"), ("Last", "return 'jumped'"), ], ); @@ -100,8 +128,8 @@ fn a_library_that_raises_while_loading_runs_again_on_the_next_require() { "Main", " var.attempts = 0 - local first_ok = pcall(require, '## flaky') - local second = require '## flaky' + local first_ok = pcall(require, '/flaky') + local second = require '/flaky' return tostring(first_ok) .. '|' .. second .. '|' .. var.attempts", ), ( @@ -134,7 +162,7 @@ fn a_prelude_cannot_define_require_or_call_it_while_it_installs() { ); let calls = lua_failure(&run_with_preludes( &source, - vec![prelude("eager", "require '## lib'")], + vec![prelude("eager", "require '/lib'")], )); assert!( calls.contains("Pack `eager`: its prelude failed to load") @@ -157,7 +185,7 @@ fn a_raise_during_the_load_reports_the_library_line() { ## Main\n\ \n\ ```lua\n\ - require '## lib'\n\ + require '/lib'\n\ ```\n\ \n\ ## lib\n\ @@ -187,7 +215,7 @@ fn a_raise_while_a_long_titled_library_loads_reports_the_library_line() { ## Main\n\ \n\ ```lua\n\ - require '## Shared Research Helper Functions'\n\ + require '/Shared Research Helper Functions'\n\ ```\n\ \n\ ## Shared Research Helper Functions\n\ @@ -217,7 +245,7 @@ fn a_raise_from_a_library_function_called_later_reports_the_library_line() { ## Main\n\ \n\ ```lua\n\ - require '## lib'\n\ + require '/lib'\n\ boom()\n\ ```\n\ \n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/require/loading.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/require/loading.rs index 4f2c569f3..7a1e92b7e 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/require/loading.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/require/loading.rs @@ -13,7 +13,7 @@ fn a_section_uses_the_table_of_functions_a_sibling_library_returns() { ( "Main", " - local shared = require '## shared_stuff' + local shared = require '/shared_stuff' return shared.search_all(argv.prose)", ), ( @@ -39,10 +39,10 @@ fn a_second_require_returns_the_saved_value_without_rerunning_it() { ( "Main", " - local first = require '## counted' - local second = require '## counted' - local no_first = require '## falsy' - local no_second = require '## falsy' + local first = require '/counted' + local second = require '/counted' + local no_first = require '/falsy' + local no_second = require '/falsy' return table.concat({ tostring(first == second), var.counted, tostring(no_first), tostring(no_second), var.falsy }, '|')", ), @@ -76,9 +76,9 @@ fn a_first_load_reports_its_start_and_end_naming_the_library() { ( "Main", " - require '## lib' - require '## lib' - assert(not pcall(require, '## broken'), 'the broken library raises') + require '/lib' + require '/lib' + assert(not pcall(require, '/broken'), 'the broken library raises') return 'done'", ), ("lib", "loaded = true"), @@ -90,10 +90,10 @@ fn a_first_load_reports_its_start_and_end_naming_the_library() { assert_eq!( library_loads(&trace.records()), [ - load("Main", "Lua library load started: ## lib"), - load("Main", "Lua library load succeeded: ## lib"), - load("Main", "Lua library load started: ## broken"), - load("Main", "Lua library load failed: ## broken"), + load("Main", "Lua library load started: /lib"), + load("Main", "Lua library load succeeded: /lib"), + load("Main", "Lua library load started: /broken"), + load("Main", "Lua library load failed: /broken"), ], "a saved library reports nothing on its second require" ); @@ -107,14 +107,14 @@ fn load_time_code_runs_once_in_each_requiring_section() { ( "First", " - require '## lib' - require '## lib'", + require '/lib' + require '/lib'", ), ( "Second", " - require '## lib' - require '## lib' + require '/lib' + require '/lib' return tostring(var.loads)", ), ("lib", "var.loads = (var.loads or 0) + 1"), @@ -129,10 +129,10 @@ fn load_time_code_runs_once_in_each_requiring_section() { assert_eq!( library_loads(&trace.records()), [ - load("First", "Lua library load started: ## lib"), - load("First", "Lua library load succeeded: ## lib"), - load("Second", "Lua library load started: ## lib"), - load("Second", "Lua library load succeeded: ## lib"), + load("First", "Lua library load started: /lib"), + load("First", "Lua library load succeeded: /lib"), + load("Second", "Lua library load started: /lib"), + load("Second", "Lua library load succeeded: /lib"), ] ); } @@ -145,15 +145,15 @@ fn a_child_section_starts_without_its_parents_libraries_and_loads_its_own() { ( "Parent", " - require '## lib' + require '/lib' assert(greet, 'the parent sees the library global') - return call('### Child')", + return call('Child')", ), ( "### Child", " local before = tostring(greet) - require '## lib' + require '/lib' return before .. '|' .. greet('child')", ), ("lib", "function greet(name) return 'hello ' .. name end"), @@ -168,10 +168,10 @@ fn a_child_section_starts_without_its_parents_libraries_and_loads_its_own() { assert_eq!( library_loads(&trace.records()), [ - load("Parent", "Lua library load started: ## lib"), - load("Parent", "Lua library load succeeded: ## lib"), - load("Child", "Lua library load started: ## lib"), - load("Child", "Lua library load succeeded: ## lib"), + load("Parent", "Lua library load started: /lib"), + load("Parent", "Lua library load succeeded: /lib"), + load("Child", "Lua library load started: /lib"), + load("Child", "Lua library load succeeded: /lib"), ] ); } diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/require/prose.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/require/prose.rs index 70dfcfe7f..8319e31e2 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/require/prose.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/require/prose.rs @@ -12,7 +12,7 @@ use crate::test_support::at_line; #[test] fn a_failed_substitution_in_library_prose_is_placed_on_the_librarys_template_line() { let source = PromptSource::new() - .section("Main", "return require '## lib'") + .section("Main", "return require '/lib'") .prose( "lib", " @@ -47,8 +47,8 @@ fn a_library_hands_its_prose_to_the_caller_and_leaves_the_callers_prose_alone() local before = prose var.word = 'changed' style = 'terse' - local text = require '## house_style' - local ok = pcall(require, '## broken_style') + local text = require '/house_style' + local ok = pcall(require, '/broken_style') var.summary = table.concat({ before, prose, text, tostring(ok) }, '|') ``` @@ -56,7 +56,7 @@ fn a_library_hands_its_prose_to_the_caller_and_leaves_the_callers_prose_alone() ```lua var.word = 'two' - local ok = pcall(require, '## broken_style') + local ok = pcall(require, '/broken_style') var.word = 'three' return table.concat({ var.summary, tostring(ok), var.broken_saw, prose }, '|') ```", diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/require/reach.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/require/reach.rs index 96248ef8e..78db59f9a 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/require/reach.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/require/reach.rs @@ -1,8 +1,6 @@ -//! What a `require` reaches: a nested fanout worker reaches a top-level -//! library its prompt reads cannot see, a library's own `require` during -//! its load resolves from the library's position, and a library function -//! called later resolves `call`, `prompt.section`, and `require` from the -//! calling section. +//! What a `require` names: a path from the top is the same library from a +//! nested fanout worker, from a section under another parent, and from a +//! library's own code, while it loads and when its functions run later. use super::*; @@ -12,10 +10,10 @@ fn a_nested_fanout_worker_requires_a_top_level_library() { .section( "Research", " - local results = tasks.fanout('### Worker', { 'alpha', 'beta' }) + local results = tasks.fanout('Worker', { 'alpha', 'beta' }) return table.concat(results, ',')", ) - .section("### Worker", "return require('## helpers').label(item)") + .section("### Worker", "return require('/helpers').label(item)") .prose( "helpers", " @@ -37,7 +35,7 @@ fn a_nested_fanout_worker_requires_a_top_level_library() { loads .iter() .filter(|(section, detail)| section == "Worker" - && detail == "Lua library load succeeded: ## helpers") + && detail == "Lua library load succeeded: /helpers") .count(), 2, "each arm's VM loads the library itself: {loads:?}" @@ -45,17 +43,18 @@ fn a_nested_fanout_worker_requires_a_top_level_library() { } #[test] -fn a_nested_worker_requires_a_library_its_prompt_section_read_cannot_see() { +fn a_nested_worker_reads_and_requires_a_top_level_section_by_one_path() { let source = PromptSource::new() .section( "Research", - "return tostring(tasks.fanout('### Worker', { 'alpha' })[1])", + "return tostring(tasks.fanout('Worker', { 'alpha' })[1])", ) .section( "### Worker", " - local read = prompt.section('## house_style') - return tostring(read) .. '|' .. require '## house_style'", + local read = prompt.section('/house_style') + local raw = read:find('{{ item }}', 1, true) ~= nil + return tostring(raw) .. '|' .. require '/house_style'", ) .prose( "house_style", @@ -69,50 +68,56 @@ fn a_nested_worker_requires_a_library_its_prompt_section_read_cannot_see() { let trace = require_case(source.build()).run(); assert_eq!( trace.text(), - "nil|Plain words for alpha.", - "a parent's sibling is outside the visible set but within the reach" + "true|Plain words for alpha.", + "the read returns the raw text and the library renders its prose" ); } #[test] -fn a_load_resolves_from_the_library_and_a_later_call_from_the_caller() { +fn a_library_requires_its_own_helper_by_one_path_while_it_loads_and_later() { let source = PromptSource::new() .section( "Main", " - local lib = require '## lib' - return lib.helped .. '|' .. lib.later()", + local lib = require '/lib' + return lib.helped .. '|' .. lib.later() .. '|' .. call('Nested')", ) - .prose( - "### mine", - " - Mine text. - - ```lua - return 'mine' - ```", + .section( + "### Nested", + "return require('/lib').later() .. ',' .. require '/lib/helper'", ) .section( "lib", " - local helped = require '### helper' + local helped = require '/lib/helper' return { helped = helped, - later = function() - local text = prompt.section('### mine') - local found = text ~= nil and text:find('Mine text.', 1, true) ~= nil - local helper_ok = pcall(require, '### helper') - return table.concat({ require '### mine', call('### mine'), tostring(found), - tostring(helper_ok) }, ',') - end, + later = function() return require '/lib/helper' end, }", ) .section("### helper", "return 'helped'"); let trace = require_case(source.build()).run(); assert_eq!( trace.text(), - "helped|mine,mine,true,false", - "the load reaches the library's child; the later call reaches the caller's \ - child and no longer the library's" + "helped|helped|helped,helped", + "the path names the library's helper wherever the require runs" ); } + +#[test] +fn a_section_requires_sections_nested_under_other_parents() { + let source = PromptSource::new() + .section("Main", "return call('Deep')") + .section( + "### Deep", + " + return table.concat({ require '/Other/Niece', require '/Other/Niece/Grand', + require '/Last' }, ',')", + ) + .prose("Other", "") + .section("### Niece", "return 'niece'") + .section("#### Grand", "return 'grand'") + .section("Last", "return 'last'"); + let trace = require_case(source.build()).run(); + assert_eq!(trace.text(), "niece,grand,last"); +} diff --git a/crates/runvil-internal/runtime/src/execute/tests/suite/require/walk.rs b/crates/runvil-internal/runtime/src/execute/tests/suite/require/walk.rs index c1964d04b..274edce32 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/suite/require/walk.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/suite/require/walk.rs @@ -14,7 +14,7 @@ fn the_walk_runs_a_globals_only_library_harmlessly() { ( "Main", " - require '## helpers' + require '/helpers' var.greeting = greet('main')", ), ( @@ -37,7 +37,7 @@ fn the_walk_fails_the_run_on_a_library_that_returns_a_table() { let source = prompt( "", &[ - ("Main", "var.name = (require '## lib').name"), + ("Main", "var.name = (require '/lib').name"), ("lib", "return { name = 'lib' }"), ], ); @@ -57,7 +57,7 @@ fn the_walk_ends_the_chain_with_a_library_that_returns_a_scalar() { let source = prompt( "", &[ - ("Main", "var.from_main = require '## lib'"), + ("Main", "var.from_main = require '/lib'"), ("lib", "return 'from lib'"), ("Last", "error('the chain ends at the library')"), ], @@ -75,14 +75,14 @@ fn a_returning_library_after_a_return_or_under_its_user_stays_off_the_walk() { let after = prompt( "", &[ - ("Main", "return (require '## lib').name"), + ("Main", "return (require '/lib').name"), ("lib", "return { name = 'after' }"), ], ); let nested = prompt( "", &[ - ("Main", "var.name = (require '### lib').name"), + ("Main", "var.name = (require '/Main/lib').name"), ("### lib", "return { name = 'nested' }"), ("Last", "return var.name"), ], diff --git a/crates/runvil-internal/runtime/src/execute/tests/tasks-chain-end.rs b/crates/runvil-internal/runtime/src/execute/tests/tasks-chain-end.rs index f41221a1d..cba48cc7b 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/tasks-chain-end.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/tasks-chain-end.rs @@ -42,8 +42,8 @@ fn a_chain_ending_with_live_author_tasks_fails_as_tasks_live_naming_the_ids() { // never ran reports no start and no terminal, though its entry still // reaches Abandoned. let md = spawner_prompt( - "tasks.spawn('## Child')\n\ - tasks.spawn('## Child')\n\ + "tasks.spawn('../Child')\n\ + tasks.spawn('../Child')\n\ return 'done'", CHILD_PARKED_ON_STORE_WRITE, ); @@ -85,7 +85,7 @@ fn a_chain_failing_with_a_live_task_keeps_its_own_error_and_abandons_the_task() // admitted - the spawner failed without suspending - so it reports no // start and no terminal. let md = spawner_prompt( - "tasks.spawn('## Child')\n\ + "tasks.spawn('../Child')\n\ error('spawner boom')", CHILD_PARKED_ON_STORE_WRITE, ); @@ -152,7 +152,7 @@ fn a_returning_owner_abandons_its_running_task_as_owner_returned() { // is admitted and parks on its held round; the spawner then returns // with the child live, which is the leak. let md = spawner_prompt( - "tasks.spawn('## Child')\n\ + "tasks.spawn('../Child')\n\ store.write('own', 'x')\n\ return 'done'", CHILD_PARKED_ON_HELD_ROUND, @@ -167,7 +167,7 @@ fn a_returning_owner_abandons_its_running_task_as_owner_returned() { #[test] fn a_failing_owner_abandons_its_running_task_as_owner_failed() { let md = spawner_prompt( - "tasks.spawn('## Child')\n\ + "tasks.spawn('../Child')\n\ store.write('own', 'x')\n\ error('spawner boom')", CHILD_PARKED_ON_HELD_ROUND, @@ -186,7 +186,7 @@ fn an_exhausted_tool_loop_abandons_its_running_task_as_tool_loop_exhausted() { // it fails as exhausted while the child is parked on its held round. let gateway = ScriptedChat::new(vec![calls(&[("call_0", "echo", "{\"value\":\"x\"}")])]); let owner = format!( - "tasks.spawn('## Child')\n\ + "tasks.spawn('../Child')\n\ store.write('own', 'x')\n\ tools.offer('tools/echo')\n\ {LOOP_TO_TEXT}" @@ -216,7 +216,7 @@ fn moving_spawner_prompt(movement: &str) -> String { # Tasks\n\n\ ## Spawner\n\n\ ```lua\n\ - tasks.spawn('## Child')\n\ + tasks.spawn('../Child')\n\ {movement}\ ```\n\n\ ## Sibling\n\n\ @@ -267,7 +267,7 @@ fn a_task_survives_its_spawning_sections_fall_through() { fn a_task_survives_its_spawning_sections_jump() { // A `jump` moves the walk within the same chain, so it settles nothing: // the jumped-to sibling's return leaks the task. - assert_task_outlives_movement(moving_spawner_prompt("jump('## Sibling')\n")); + assert_task_outlives_movement(moving_spawner_prompt("jump('../Sibling')\n")); } #[test] @@ -279,13 +279,13 @@ fn a_call_chain_ending_with_a_live_task_answers_tasks_live_to_its_caller() { # Tasks\n\n\ ## Main\n\n\ ```lua\n\ - local ok, err = pcall(call, '## Leaky')\n\ + local ok, err = pcall(call, '../Leaky')\n\ assert(not ok, 'the leaky call fails')\n\ return err.kind .. '|' .. err.tasks .. '|' .. tostring(err)\n\ ```\n\n\ ## Leaky\n\n\ ```lua\n\ - tasks.spawn('## Child')\n\ + tasks.spawn('../Child')\n\ return 'leaked'\n\ ```\n\n\ ## Child\n\n\ @@ -320,12 +320,12 @@ fn a_fanout_arm_leaking_a_task_fails_on_the_arms_own_spawn_line() { # Tasks\n\n\ ## Main\n\n\ ```lua\n\ - return tasks.fanout('## Worker', {'only'})[1].text\n\ + return tasks.fanout('../Worker', {'only'})[1].text\n\ ```\n\n\ ## Worker\n\n\ ```lua\n\ local note = item\n\ - tasks.spawn('## Child')\n\ + tasks.spawn('../Child')\n\ return note\n\ ```\n\n\ ## Child\n\n\ @@ -361,7 +361,7 @@ fn a_task_spawned_in_h1_belongs_to_the_main_walk() { let md = "---\nname: h1\ndescription: d\nrunvil: 0\n---\n\n\ # Tasks\n\n\ ```lua\n\ - tasks.spawn('## Child')\n\ + tasks.spawn('Child')\n\ ```\n\n\ ## Main\n\n\ ```lua\n\ @@ -402,14 +402,14 @@ fn aborting_a_chain_abandons_the_tasks_it_owns() { # Tasks\n\n\ ## Main\n\n\ ```lua\n\ - local ok, err = pcall(tasks.fanout, '## Worker', {'spawner', 'boom'})\n\ + local ok, err = pcall(tasks.fanout, '../Worker', {'spawner', 'boom'})\n\ assert(not ok, 'the fatal arm fails the fanout')\n\ return tostring(err)\n\ ```\n\n\ ## Worker\n\n\ ```lua\n\ if item == 'spawner' then\n\ - tasks.spawn('## Child')\n\ + tasks.spawn('../Child')\n\ store.write('park-' .. sys.id, 'x')\n\ return 'never'\n\ end\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/tasks.rs b/crates/runvil-internal/runtime/src/execute/tests/tasks.rs index 3ce0cc767..92e567ab5 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/tasks.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/tasks.rs @@ -98,7 +98,7 @@ fn spawner_prompt(spawner_body: &str, child_body: &str) -> String { #[test] fn spawn_returns_to_its_caller_before_the_child_runs() { let md = spawner_prompt( - "local t = tasks.spawn('## Child')\n\ + "local t = tasks.spawn('../Child')\n\ log('spawned ' .. t.task)\n\ store.write('park', 'x')\n\ return 'done'", @@ -130,7 +130,7 @@ fn spawn_returns_to_its_caller_before_the_child_runs() { #[test] fn a_finished_child_moves_its_entry_to_done_and_reports_task_succeeded() { let md = spawner_prompt( - "tasks.spawn('## Child')\n\ + "tasks.spawn('../Child')\n\ store.write('park', 'x')\n\ return 'done'", "return 'child result'", @@ -170,7 +170,7 @@ fn a_finished_child_moves_its_entry_to_done_and_reports_task_succeeded() { #[test] fn a_failed_child_moves_its_entry_to_done_and_reports_task_failed() { let md = spawner_prompt( - "tasks.spawn('## Child')\n\ + "tasks.spawn('../Child')\n\ store.write('park', 'x')\n\ return 'done'", "error('child boom')", @@ -203,7 +203,7 @@ fn task_started_includes_the_spawn_seeds_and_the_child_sees_them() { let md = spawner_prompt( "var.k = 1\n\ log('root taskid=' .. sys.taskid)\n\ - tasks.spawn('## Child', { input = 'child args', item = { name = 'alpha' }, index = 7 })\n\ + tasks.spawn('../Child', { input = 'child args', item = { name = 'alpha' }, index = 7 })\n\ store.write('park', 'x')\n\ return 'done'", "log('taskid=' .. sys.taskid .. ' id=' .. sys.id .. ' index=' .. sys.index\n\ @@ -234,11 +234,11 @@ fn task_started_includes_the_spawn_seeds_and_the_child_sees_them() { #[test] fn spawn_shares_calls_target_resolution_and_raises_at_the_call_site() { // An unresolvable target is the call's answer: `pcall` catches it, and - // the message is exactly the one `call` produces for the same heading. + // the message is exactly the one `call` produces for the same path. // Both calls share a line, so both are placed on it. let md = spawner_prompt( - "local ok_s, err_s = pcall(tasks.spawn, '## Missing'); \ - local ok_c, err_c = pcall(call, '## Missing')\n\ + "local ok_s, err_s = pcall(tasks.spawn, '../Missing'); \ + local ok_c, err_c = pcall(call, '../Missing')\n\ assert(not ok_s and not ok_c, 'both resolutions fail')\n\ assert(err_s.kind == 'lua', err_s.kind)\n\ assert(tostring(err_s) == tostring(err_c), tostring(err_s) .. ' vs ' .. tostring(err_c))\n\ @@ -248,7 +248,7 @@ fn spawn_shares_calls_target_resolution_and_raises_at_the_call_site() { let rig = Rig::run(scheduler_scenario(md)); let out = rig.trace().text(); assert!( - out.contains("section heading `## Missing` not found"), + out.contains("section `../Missing` not found: `/Spawner` has no sibling `Missing`"), "unexpected message: {out}" ); } @@ -263,14 +263,14 @@ fn spawn_shares_calls_depth_cap() { # Depth\n\n\ ## Alpha\n\n\ ```lua\n\ - local ok, err = pcall(tasks.spawn, '## Beta')\n\ + local ok, err = pcall(tasks.spawn, '../Beta')\n\ if not ok then log(tostring(err)) end\n\ store.write('park-' .. sys.id, 'x')\n\ return 'done'\n\ ```\n\n\ ## Beta\n\n\ ```lua\n\ - local ok, err = pcall(tasks.spawn, '## Alpha')\n\ + local ok, err = pcall(tasks.spawn, '../Alpha')\n\ if not ok then log(tostring(err)) end\n\ store.write('park-' .. sys.id, 'x')\n\ return 'done'\n\ @@ -304,29 +304,5 @@ fn spawn_shares_calls_depth_cap() { assert_eq!(starts, 8, "depths 1 through 8 start; depth 9 is refused"); } -#[test] -fn spawn_rejects_a_list_section_target_with_the_worker_message() { - let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ - # Lists\n\n\ - ## Parent\n\n\ - ```lua\n\ - local ok, err = pcall(tasks.spawn, '### Items')\n\ - assert(not ok, 'a list section is not a worker template')\n\ - return tostring(err)\n\ - ```\n\n\ - ### Items\n\n\ - - a\n\ - - b\n"; - let rig = Rig::run(scheduler_scenario(md)); - assert_eq!( - rig.trace().text(), - refused( - 12, - "section `Items` is a list section, not a worker template" - ), - "the caught error ends the run normally" - ); -} - #[path = "tasks-chain-end.rs"] mod chain_end; diff --git a/crates/runvil-internal/runtime/src/execute/tests/timeouts.rs b/crates/runvil-internal/runtime/src/execute/tests/timeouts.rs index 47aa7eb6d..8bd378364 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/timeouts.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/timeouts.rs @@ -26,7 +26,7 @@ fn join_any_returns_nil_when_the_timer_wins_and_the_member_keeps_running() { // end. let gateway = ScriptedChat::new(vec![text("slow answer")]); let md = tasks_prompt( - "local t = tasks.spawn('## Child')\n\ + "local t = tasks.spawn('../Child')\n\ local first, ok, result = tasks.join_any({ t }, { timeout = 0.05 })\n\ log('timed out first=' .. tostring(first) .. ' ok=' .. tostring(ok) .. ' result=' .. tostring(result))\n\ local s = tasks.status(t)\n\ @@ -75,7 +75,7 @@ fn join_any_cancels_the_timer_when_a_member_wins() { // fires: the shim cancels it, its entry is `Canceled`, and the run // ends without waiting on it or leaking it. let md = tasks_prompt( - "local t = tasks.spawn('## Child')\n\ + "local t = tasks.spawn('../Child')\n\ local first, ok, result = tasks.join_any({ t }, { timeout = 30 })\n\ assert(first.task == t.task and ok and result == 'quick', tostring(result))\n\ assert(#tasks.pending() == 0, 'nothing is pending')\n\ @@ -108,7 +108,7 @@ fn a_send_to_the_internal_timer_of_a_timed_join_any_raises_task_not_owned() { let send = "local ok, err = pcall(tasks.send, '0.1', 'x')\n"; let md = tasks_prompt( &format!( - "local t = tasks.spawn('## Child')\n\ + "local t = tasks.spawn('../Child')\n\ tasks.join_any({{ t }}, {{ timeout = 30 }})\n\ {send}\ return tostring(ok) .. ' ' .. err.kind" @@ -129,7 +129,7 @@ fn a_send_to_the_internal_timer_of_a_timed_join_any_raises_task_not_owned() { let gateway = ScriptedChat::new(vec![text("slow answer")]); let md = tasks_prompt( &format!( - "local t = tasks.spawn('## Child')\n\ + "local t = tasks.spawn('../Child')\n\ tasks.join_any({{ t }}, {{ timeout = 0.05 }})\n\ {send}\ tasks.join_any({{ t }})\n\ @@ -165,8 +165,8 @@ fn join_returns_timed_out_with_the_unfinished_members_absent() { // `timed_out = false`. let gateway = ScriptedChat::new(vec![text("slow answer")]); let md = tasks_prompt( - "local q = tasks.spawn('## Quick')\n\ - local s = tasks.spawn('## Slow')\n\ + "local q = tasks.spawn('../Quick')\n\ + local s = tasks.spawn('../Slow')\n\ local results, timed_out = tasks.join({ q, s }, { timeout = 0.05 })\n\ log('timed_out=' .. tostring(timed_out) .. ' n=' .. #results\n\ .. ' quick=' .. tostring(results[1] and results[1].result)\n\ @@ -204,8 +204,8 @@ fn join_cancels_the_timer_when_every_member_finishes() { // present, `timed_out` is false, and the timer (the owner's third // child) is canceled rather than waited out or leaked. let md = tasks_prompt( - "local a = tasks.spawn('## Alpha')\n\ - local b = tasks.spawn('## Beta')\n\ + "local a = tasks.spawn('../Alpha')\n\ + local b = tasks.spawn('../Beta')\n\ local results, timed_out = tasks.join({ a, b }, { timeout = 30 })\n\ assert(timed_out == false, 'no timeout')\n\ assert(#results == 2 and results[1].result == 'alpha' and results[2].result == 'beta')\n\ @@ -235,7 +235,7 @@ fn the_timer_is_invisible_to_pending_and_to_a_status_tasks_list() { // win. let gateway = ScriptedChat::new(vec![text("slow answer")]); let md = tasks_prompt( - "local c = tasks.spawn('## Child')\n\ + "local c = tasks.spawn('../Child')\n\ store.write('park', 'x')\n\ local s = tasks.status(c)\n\ log('child blocked=' .. tostring(s.blocked) .. ' tasks=' .. #s.tasks .. ' first=' .. tostring(s.tasks[1]))\n\ @@ -245,7 +245,7 @@ fn the_timer_is_invisible_to_pending_and_to_a_status_tasks_list() { &[ ( "Child", - "local g = tasks.spawn('## Grandchild')\n\ + "local g = tasks.spawn('../Grandchild')\n\ local first = tasks.join_any({ g }, { timeout = 30 })\n\ assert(first.task == g.task, 'the grandchild wins')\n\ return 'child done'", @@ -273,7 +273,7 @@ fn the_timer_is_invisible_to_pending_and_to_a_status_tasks_list() { #[test] fn the_timeout_option_is_validated_at_the_call_site() { let md = tasks_prompt( - "local t = tasks.spawn('## Child')\n\ + "local t = tasks.spawn('../Child')\n\ local ok1, e1 = pcall(tasks.join_any, { t }, { timeout = 'soon' })\n\ assert(not ok1 and e1.kind == 'lua', tostring(e1))\n\ local ok2, e2 = pcall(tasks.join, { t }, { timeout = -1 })\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/tool_call_access.rs b/crates/runvil-internal/runtime/src/execute/tests/tool_call_access.rs index eca477508..251dcd949 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/tool_call_access.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/tool_call_access.rs @@ -184,7 +184,7 @@ fn a_tool_in_a_spawned_task_forks_from_the_task_and_the_owner_reads_its_write_af # Task\n\n\ ## Main\n\n\ ```lua\n\ - local t = tasks.spawn('## Worker')\n\ + local t = tasks.spawn('../Worker')\n\ tasks.join({ t })\n\ return store.read('out.txt')\n\ ```\n\n\ @@ -258,7 +258,7 @@ fn a_canceled_tasks_tool_call_is_joined_and_its_later_writes_are_refused() { # Cancel\n\n\ ## Main\n\n\ ```lua\n\ - local a = tasks.spawn('## Task')\n\ + local a = tasks.spawn('../Task')\n\ store.exists('p1')\n\ store.exists('p2')\n\ tasks.cancel(a)\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/tool_call_arm-adhoc-handlers.rs b/crates/runvil-internal/runtime/src/execute/tests/tool_call_arm-adhoc-handlers.rs index 50dd188b0..553fc0f7a 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/tool_call_arm-adhoc-handlers.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/tool_call_arm-adhoc-handlers.rs @@ -139,7 +139,7 @@ fn a_script_caller_catches_the_handlers_own_error_and_jump_is_restored() { local ok, err = pcall(tools.call, 'adhoc/grab', {}) assert(not ok, 'the handler raise reaches the call site') assert(err == raised and err.kind == 'custom', 'the caller catches the handler table') - jump('## Other')"; + jump('../Other')"; let source = prompt("", &[("Only", only), ("Other", "return 'jumped'")]); let trace = arm_scenario(source).run(); assert_eq!( diff --git a/crates/runvil-internal/runtime/src/execute/tests/tool_call_origins.rs b/crates/runvil-internal/runtime/src/execute/tests/tool_call_origins.rs index 1fd6bde13..a6718862d 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/tool_call_origins.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/tool_call_origins.rs @@ -185,7 +185,7 @@ fn re_entering_a_section_gives_each_entrys_call_its_own_entry_id() { if var.again then return 'done' end\n\ var.again = true", ), - ("Back", "jump('## Loop')"), + ("Back", "jump('../Loop')"), ], ); let rig = run_scripted(&source); @@ -204,7 +204,7 @@ fn two_fanout_arms_of_one_section_name_different_entries() { &[ ( "Main", - "local r = tasks.fanout('## Worker', { 'a', 'b' })\n\ + "local r = tasks.fanout('../Worker', { 'a', 'b' })\n\ return r[1].text .. '|' .. r[2].text", ), ( diff --git a/crates/runvil-internal/runtime/src/execute/tests/tool_loop-scope-gate.rs b/crates/runvil-internal/runtime/src/execute/tests/tool_loop-scope-gate.rs index d619da438..8df653975 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/tool_loop-scope-gate.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/tool_loop-scope-gate.rs @@ -309,7 +309,7 @@ fn the_gate_reads_the_sections_frozen_sets_after_a_handler_runs_model_rounds_of_ ]); let only = "tools.offer('tools/echo')\n\ tools.offer(tools.adhoc({ name = 'grab', description = 'Grab' }, function()\n\ - return models.infer('aside') .. '+' .. call('## Helper')\n\ + return models.infer('aside') .. '+' .. call('../Helper')\n\ end))\n\ messages:user('go')\n\ models.loop()\n\ diff --git a/crates/runvil-internal/runtime/src/execute/tests/unified_pipeline.rs b/crates/runvil-internal/runtime/src/execute/tests/unified_pipeline.rs index 3f9c5864a..4fa203f0c 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/unified_pipeline.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/unified_pipeline.rs @@ -35,7 +35,7 @@ fn finite_pipeline_runs_the_unified_surface_end_to_end() { messages:user(var.draft) assert(models.loop() == nil, 'models.loop returns nil') assert(messages[#messages].role == 'assistant', 'the terminal record is the assistant reply') - return call('## Deliver', messages[#messages].content)", + return call('../Deliver', messages[#messages].content)", ) .section("Deliver", "return 'delivered: ' .. args"); let trace = Scenario::new(source.build()) diff --git a/crates/runvil-internal/runtime/src/execute/tests/waits.rs b/crates/runvil-internal/runtime/src/execute/tests/waits.rs index f6de381db..ba59adf81 100644 --- a/crates/runvil-internal/runtime/src/execute/tests/waits.rs +++ b/crates/runvil-internal/runtime/src/execute/tests/waits.rs @@ -42,8 +42,8 @@ fn join_reports_a_failed_member_without_raising() { // input order and the caller decides; the failed member's result is // the error table, and nothing leaks at chain end. let md = tasks_prompt( - "local a = tasks.spawn('## Alpha')\n\ - local b = tasks.spawn('## Beta')\n\ + "local a = tasks.spawn('../Alpha')\n\ + local b = tasks.spawn('../Beta')\n\ local results = tasks.join({ b, a })\n\ assert(#results == 2, 'two results')\n\ assert(results[1].task == b.task, 'input order: beta first')\n\ @@ -70,8 +70,8 @@ fn join_fills_every_position_of_a_member_named_twice() { // and `#results` is the input's length; the second wait would have // raised `make_task_consumed_error` had the shim waited twice. let md = tasks_prompt( - "local a = tasks.spawn('## Alpha')\n\ - local b = tasks.spawn('## Beta')\n\ + "local a = tasks.spawn('../Alpha')\n\ + local b = tasks.spawn('../Beta')\n\ local results = tasks.join({ a, b, a })\n\ assert(#results == 3, 'three positions, got ' .. #results)\n\ local count = 0\n\ @@ -99,8 +99,8 @@ fn join_any_returns_the_first_finished_member_and_the_rest_keep_running() { // both delivers Alpha and leaves Beta live, so the caller must still // wait on Beta before it ends - which it does. let md = tasks_prompt( - "local a = tasks.spawn('## Alpha')\n\ - local b = tasks.spawn('## Beta')\n\ + "local a = tasks.spawn('../Alpha')\n\ + local b = tasks.spawn('../Beta')\n\ local first, ok, result = tasks.join_any({ a, b })\n\ assert(first.task == a.task, 'alpha finishes first, got ' .. first.task)\n\ assert(ok and result == 'alpha', tostring(result))\n\ @@ -135,7 +135,7 @@ fn status_reports_a_parked_task_and_then_a_finished_one() { table.sort(names)\n\ return table.concat(names, ',')\n\ end\n\ - local t = tasks.spawn('## Child')\n\ + local t = tasks.spawn('../Child')\n\ local fresh = tasks.status(t)\n\ log('fresh state=' .. fresh.state .. ' section=' .. tostring(fresh.section)\n\ .. ' blocked=' .. tostring(fresh.blocked))\n\ @@ -190,7 +190,7 @@ fn a_non_owner_is_refused_with_task_not_owned() { // but waiting on or canceling it is the owner's alone, and a task id // the caller never spawned is refused the same way. let md = tasks_prompt( - "local t = tasks.spawn('## Child')\n\ + "local t = tasks.spawn('../Child')\n\ local ok, err = pcall(tasks.cancel, '9.9')\n\ assert(not ok and err.kind == 'task_not_owned', tostring(err))\n\ assert(err.task == '9.9', tostring(err.task))\n\ @@ -225,7 +225,7 @@ fn a_non_owner_is_refused_with_task_not_owned() { #[test] fn waiting_on_a_delivered_task_raises_task_consumed() { let md = tasks_prompt( - "local t = tasks.spawn('## Child')\n\ + "local t = tasks.spawn('../Child')\n\ local _, ok, result = tasks.join_any({ t })\n\ assert(ok and result == 'once', tostring(result))\n\ assert(tasks.ready(t), 'a delivered task is ready')\n\ @@ -260,7 +260,7 @@ fn cancel_ends_a_parked_task_idempotently_and_reports_task_canceled_once() { // cancels. let child_write = |effect: &Effect| matches!(effect, Effect::Vfs { op: VfsOp::Write { path, .. }, .. } if path == "child-park"); let md = tasks_prompt( - "local t = tasks.spawn('## Child')\n\ + "local t = tasks.spawn('../Child')\n\ store.exists('park')\n\ tasks.cancel(t)\n\ tasks.cancel(t)\n\ @@ -337,9 +337,9 @@ fn pending_lists_the_callers_live_tasks_in_spawn_order() { // after a leading nil are each the call's error, naming the argument's // type. let md = tasks_prompt( - "local a = tasks.spawn('## Parked')\n\ - local b = tasks.spawn('## Quick')\n\ - local c = tasks.spawn('## Parked')\n\ + "local a = tasks.spawn('../Parked')\n\ + local b = tasks.spawn('../Quick')\n\ + local c = tasks.spawn('../Parked')\n\ tasks.join_any({ b })\n\ local live = tasks.pending()\n\ assert(#live == 2, 'two live tasks, got ' .. #live)\n\ @@ -377,8 +377,8 @@ fn the_waits_old_names_are_not_defined() { // The spawned children are canceled, so nothing stays live at chain // end. let md = tasks_prompt( - "local a = tasks.spawn('## Child')\n\ - local b = tasks.spawn('## Child')\n\ + "local a = tasks.spawn('../Child')\n\ + local b = tasks.spawn('../Child')\n\ local ok1, e1 = pcall(tasks.when_any, { a })\n\ local ok2, e2 = pcall(tasks.when_all, { b })\n\ tasks.cancel(a)\n\ diff --git a/crates/runvil-internal/runtime/src/execute/walk_target-tests.rs b/crates/runvil-internal/runtime/src/execute/walk_target-tests.rs index e7548e275..b3695460a 100644 --- a/crates/runvil-internal/runtime/src/execute/walk_target-tests.rs +++ b/crates/runvil-internal/runtime/src/execute/walk_target-tests.rs @@ -1,15 +1,14 @@ -//! Tests for library resolution: what a `require` builds from a section, -//! the key naming its position, and the reach a keyed lookup resolves from. +//! Tests for walk-target resolution: where a relative path lands, what a +//! `require` builds from a section, and the key naming its position. use runvil_lua::LuaProgram; use runvil_prompt::test_support::{prose_block, synthetic_section}; use super::*; -/// `## Grand` holds `### Parent` and a `### Helpers`, `### Parent` holds -/// `#### Sibling` and `#### Self`, `## Top` holds a second `### Helpers`, -/// and `## Lib` is laid out as prose, Lua, Lua, prose, Lua, then trailing -/// prose. +/// `Grand` holds `Parent` and a `Helpers`, `Parent` holds `Sibling` and +/// `Self`, `Top` holds a second `Helpers`, and `Lib` is laid out as prose, +/// Lua, Lua, prose, Lua, then trailing prose. const LIBRARY_TREE: &str = concat!( "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n# Title\n\n", "## Top\n\nx\n\n", @@ -32,21 +31,9 @@ fn library_tree() -> Prompt { Prompt::parse(&|_: &str| Ok(LIBRARY_TREE.to_owned()), "prompt").expect("the tree parses") } -/// The section at `index` of the slice under `ancestors`, from the top -/// level down. -fn pos(ancestors: &[usize], index: usize) -> SectionPos { - SectionPos { - slice: ancestors.iter().fold(SlicePath::root(), |path, &ancestor| { - path.child(SectionIndex::new(ancestor)) - }), - index: SectionIndex::new(index), - } -} - -/// `#### Self`: index 1 of `### Parent`'s children, which sit under -/// `## Grand`. -fn self_origin() -> RequireOrigin { - RequireOrigin::Section(pos(&[1, 0], 1)) +/// The position of the section `path` names from the top. +fn at(prompt: &Prompt, path: &str) -> SectionPos { + section_path::resolve_from_top(path, prompt).expect("the path resolves") } fn program(source: &str) -> LuaProgram { @@ -78,6 +65,122 @@ fn template_lines(library: &Library) -> Vec> { .collect() } +#[test] +fn a_jump_to_a_child_descends_and_a_jump_to_a_sibling_does_not() { + let prompt = library_tree(); + let parent = at(&prompt, "/Grand/Parent"); + let child = resolve_jump_target("Self", &prompt, Origin::Section(&parent), None) + .expect("a child resolves"); + assert_eq!(child.pos, at(&prompt, "/Grand/Parent/Self")); + assert!(child.descends, "a child target starts a child-level walk"); + let sibling = resolve_jump_target("../Helpers", &prompt, Origin::Section(&parent), None) + .expect("a sibling resolves"); + assert_eq!(sibling.pos, at(&prompt, "/Grand/Helpers")); + assert!(!sibling.descends, "a sibling stays in the caller's slice"); +} + +#[test] +fn a_jump_from_the_h1_lands_on_a_top_level_section_and_never_descends() { + let prompt = library_tree(); + let target = + resolve_jump_target("Grand", &prompt, Origin::H1, None).expect("a section resolves"); + assert_eq!(target.pos, at(&prompt, "/Grand")); + assert!(!target.descends, "the walk already runs at the top level"); +} + +/// The Markdown a read of `path` from `origin` finds, or the message of +/// its not-found error. +fn read(prompt: &Prompt, origin: Origin<'_>, path: &str) -> std::result::Result { + match read_section(path, prompt, origin, None).expect("the path is well-formed") { + SectionRead::Found(text) => Ok(text.markdown().to_owned()), + SectionRead::Missing(error) => Err(error.to_string()), + } +} + +#[test] +fn a_read_finds_the_named_sections_own_markdown_or_the_not_found_error() { + let prompt = library_tree(); + let own = at(&prompt, "/Grand/Parent/Self"); + let origin = Origin::Section(&own); + for path in ["../Sibling", "/Grand/Parent/Sibling", "/Top/Helpers"] { + assert_eq!(read(&prompt, origin, path).as_deref(), Ok("x"), "{path}"); + } + let lib = read(&prompt, origin, "/Lib").expect("the section is there"); + assert!(lib.starts_with("Intro."), "{lib}"); + for (path, message) in [ + ( + "Sibling", + "section `Sibling` not found: `/Grand/Parent/Self` has no child `Sibling`", + ), + ( + "../Missing", + "section `../Missing` not found: `/Grand/Parent/Self` has no sibling `Missing`", + ), + ( + "/Grand/Missing", + "section `/Grand/Missing` not found: `/Grand` has no child `Missing`", + ), + ] { + assert_eq!( + read(&prompt, origin, path), + Err(message.to_owned()), + "{path}" + ); + } +} + +#[test] +fn a_path_in_a_librarys_code_starts_at_the_library_whatever_section_is_running() { + let prompt = library_tree(); + let library = resolve_library(&prompt, "/Grand").expect("the library resolves"); + let from = Some(&library.key); + for origin in [Origin::H1, Origin::Section(&at(&prompt, "/Top"))] { + let child = resolve_jump_target("Parent", &prompt, origin, from).expect("a child"); + assert_eq!(child.pos, at(&prompt, "/Grand/Parent")); + let sibling = resolve_jump_target("../Lib", &prompt, origin, from).expect("a sibling"); + assert_eq!(sibling.pos, at(&prompt, "/Lib")); + let text = read_section("Helpers", &prompt, origin, from).expect("a well-formed path"); + assert!(matches!(text, SectionRead::Found(text) if text.markdown() == "x")); + let miss = read_section("Niece", &prompt, origin, from).expect("a well-formed path"); + let SectionRead::Missing(error) = miss else { + panic!("the library has no such child"); + }; + assert_eq!( + error.to_string(), + "section `Niece` not found: `/Grand` has no child `Niece`", + "the miss names the library" + ); + } +} + +#[test] +fn a_key_names_its_sections_position_and_a_foreign_key_is_an_internal_error() { + let prompt = library_tree(); + for path in ["/Top", "/Grand/Parent/Self", "/Grand/Parent/Sibling/Niece"] { + let library = resolve_library(&prompt, path).expect("the library resolves"); + assert_eq!( + library_position(&library.key).expect("the key parses"), + at(&prompt, path), + "{path}" + ); + } + let invariant = "internal invariant violated: a library key names no section position: "; + for identity in ["", "x", "1.", "1..2"] { + let error = library_position(&LibraryKey::new(identity.to_owned())) + .expect_err("a foreign key must not parse"); + assert_eq!( + error.code, + runvil_types::error::ErrorCode::Internal, + "{identity:?} gave {error}" + ); + assert!( + error.message.len() > invariant.len() && error.message.starts_with(invariant), + "required the index parse failure after the invariant, actual {:?} for {identity:?}", + error.message + ); + } +} + #[test] fn a_library_pairs_each_program_with_the_prose_right_before_it() { let section = synthetic_section( @@ -92,9 +195,10 @@ fn a_library_pairs_each_program_with_the_prose_right_before_it() { ], ); let key = LibraryKey::new("2".to_owned()); - let library = build_library(§ion, key.clone()).expect("the library builds"); + let library = + build_library(§ion, key.clone(), "/Lib".to_owned()).expect("the library builds"); assert_eq!(library.key, key); - assert_eq!(library.heading, "## Lib"); + assert_eq!(library.path, "/Lib"); assert_eq!(sources(&library), ["first = 1", "second = 2", "third = 3"]); assert_eq!(templates(&library), ["Intro.", "", "Later."]); assert_eq!( @@ -107,9 +211,8 @@ fn a_library_pairs_each_program_with_the_prose_right_before_it() { #[test] fn resolve_library_builds_a_parsed_section_without_its_trailing_prose() { let prompt = library_tree(); - let library = - resolve_library(&prompt, &RequireOrigin::H1, "## Lib", None).expect("## Lib resolves"); - assert_eq!(library.heading, "## Lib"); + let library = resolve_library(&prompt, "/Lib").expect("/Lib resolves"); + assert_eq!(library.path, "/Lib"); assert_eq!(sources(&library), ["first = 1", "second = 2", "third = 3"]); assert_eq!(templates(&library), ["Intro.", "", "Later."]); let lines = template_lines(&library); @@ -121,68 +224,49 @@ fn resolve_library_builds_a_parsed_section_without_its_trailing_prose() { } #[test] -fn a_key_maps_back_to_its_section_position() { +fn a_library_is_reported_by_its_path_from_the_top_however_the_path_was_spaced() { let prompt = library_tree(); - let library = - resolve_library(&prompt, &self_origin(), "#### Sibling", None).expect("a sibling resolves"); - assert_eq!( - RequireOrigin::from_key(&library.key).expect("the key parses"), - RequireOrigin::Section(pos(&[1, 0], 0)) - ); - let top = resolve_library(&prompt, &RequireOrigin::H1, "## Top", None) - .expect("a top-level section resolves"); - assert_eq!( - RequireOrigin::from_key(&top.key).expect("the key parses"), - RequireOrigin::Section(SectionPos::top(SectionIndex::FIRST)) - ); + let library = resolve_library(&prompt, " / Grand / Parent / Sibling ").expect("it resolves"); + assert_eq!(library.path, "/Grand/Parent/Sibling"); } #[test] -fn one_heading_from_two_positions_names_two_libraries() { +fn two_sections_with_one_title_are_two_libraries_with_their_own_keys() { let prompt = library_tree(); - let near = resolve_library(&prompt, &self_origin(), "### Helpers", None) - .expect("the parent's sibling resolves"); - let top = RequireOrigin::Section(SectionPos::top(SectionIndex::FIRST)); - let far = resolve_library(&prompt, &top, "### Helpers", None) - .expect("a top-level section's child resolves"); - assert_eq!(near.heading, far.heading); + let near = resolve_library(&prompt, "/Grand/Helpers").expect("the nested section resolves"); + let far = resolve_library(&prompt, "/Top/Helpers").expect("the other one resolves"); assert_ne!(near.key, far.key, "each position keys its own library"); + assert_ne!(near.path, far.path); + let again = resolve_library(&prompt, "/Grand/Helpers").expect("it resolves again"); + assert_eq!(near.key, again.key, "one position has one key"); } #[test] -fn a_keyed_lookup_resolves_from_the_librarys_reach() { +fn require_reaches_every_section_and_takes_no_relative_path() { let prompt = library_tree(); - let sibling = - resolve_library(&prompt, &self_origin(), "#### Sibling", None).expect("a sibling resolves"); - resolve_library(&prompt, &RequireOrigin::H1, "##### Niece", None) - .expect_err("the H1 cannot reach a nested section"); - let niece = resolve_library( - &prompt, - &RequireOrigin::H1, - "##### Niece", - Some(&sibling.key), - ) - .expect("the library reaches its own child"); - assert_eq!(niece.heading, "##### Niece"); - resolve_library(&prompt, &RequireOrigin::H1, "## Grand", Some(&sibling.key)) - .expect_err("the key replaces the caller's reach: ## Grand is the library's ancestor"); -} - -#[test] -fn a_key_that_names_no_position_is_an_internal_error_that_names_its_cause() { - let invariant = "internal invariant violated: a library key names no section position: "; - for identity in ["", "x", "1.", "1..2"] { - let error = RequireOrigin::from_key(&LibraryKey::new(identity.to_owned())) - .expect_err("a foreign key must not parse"); + for path in [ + "/Top", + "/Grand", + "/Grand/Parent", + "/Grand/Parent/Self", + "/Grand/Parent/Sibling/Niece", + ] { + let library = resolve_library(&prompt, path).expect("every section can be required"); + assert_eq!(library.path, path); + } + for path in ["Lib", "../Lib"] { + let error = resolve_library(&prompt, path).expect_err("a relative path is refused"); assert_eq!( - error.code, - runvil_types::error::ErrorCode::Internal, - "{identity:?} gave {error}" - ); - assert!( - error.message.len() > invariant.len() && error.message.starts_with(invariant), - "required the index parse failure after the invariant, actual {:?} for {identity:?}", - error.message + error.to_string(), + format!( + "section path \"{path}\" does not start with \"/\"; require takes a path from \ + the top, such as \"/helpers\"" + ) ); } + let error = resolve_library(&prompt, "/Grand/Missing").expect_err("a miss is an error"); + assert_eq!( + error.to_string(), + "section `/Grand/Missing` not found: `/Grand` has no child `Missing`" + ); } diff --git a/crates/runvil-internal/runtime/src/execute/walk_target.rs b/crates/runvil-internal/runtime/src/execute/walk_target.rs index f005ef45f..ae2e3d0c0 100644 --- a/crates/runvil-internal/runtime/src/execute/walk_target.rs +++ b/crates/runvil-internal/runtime/src/execute/walk_target.rs @@ -1,166 +1,105 @@ -//! Walk-target resolution: the visible-set machinery every control transfer -//! resolves against, and the wider reach `require` resolves libraries in. +//! Walk-target resolution: where a control transfer lands, what a prompt +//! read sees, and the library a `require` loads. //! -//! A control transfer or a prompt read addresses only the running section's -//! visible set: its home slice minus itself, plus its direct children. The -//! parent, aunts/uncles, nieces/nephews, and grandchildren are never in the -//! set, so a resolution error that lists the set cannot leak the rest of -//! the document's structure. The scheduler's chains resolve jumps and -//! `call` and `tasks.spawn` targets, fanout workers included, through these -//! helpers, and the -//! `prompt.section` and `prompt.list` reads resolve through them too, so -//! every such surface agrees on what a heading may name. `require` alone -//! also reaches the siblings of each ancestor (see [`Reachable`]), and -//! [`resolve_library`] turns the section it names into a [`Library`] keyed -//! by the section's position. +//! Each of them names a section by a path and resolves it through +//! [`section_path`]. A control transfer takes a relative path, so it +//! reaches only a neighbor of the section it starts from: a sibling or a +//! direct child. The parent, the parent's siblings, a sibling's children, +//! and grandchildren have no relative form. The scheduler's chains resolve +//! jumps and `call` and `tasks.spawn` targets, fanout workers included, +//! through [`resolve_jump_target`]. The `prompt` reads take a path from +//! the top as well, which names any section, and [`read_section`] +//! resolves them. A relative path in a library's code starts at the +//! library, whose position [`library_position`] reads back from its key. +//! `require` takes only a path from the +//! top, and [`resolve_library`] turns the section it names into a +//! [`Library`] keyed by the section's position. -use crate::heading_address::{self, Reachable, SectionIndex, SectionPos, SlicePath, Visible}; -use crate::lua::{Library, LibraryBlock, LibraryKey, SectionText}; -use crate::prompt::{Block, Prompt, Section, detail}; +use crate::lua::{Library, LibraryBlock, LibraryKey, SectionRead, SectionText}; +use crate::prompt::{Block, Prompt, Section}; +use crate::section_path::{self, Lookup, Origin}; +use crate::section_pos::{SectionIndex, SectionPos, SlicePath}; use crate::{Result, error}; -/// The position of `target` in `slice`, matched on the parser-unique -/// `(level, name)` pair; `None` when the slice does not contain it. -pub(super) fn section_position(slice: &[Section], target: &Section) -> Option { - slice - .iter() - .position(|s| s.level() == target.level() && s.name() == target.name()) - .map(SectionIndex::new) -} - -/// The H1 pass's visible set: the whole top-level slice. H1 is -/// section 0, so it excludes nothing and has no children. -pub(super) fn h1_visible_sections(prompt: &Prompt) -> &[Section] { - detail::sections(prompt) +/// What a prompt read sees of the section `path` names: its own raw +/// Markdown, read in place from the shared prompt text, with the file line +/// it starts on, or the not-found error when +/// the path names no section. A relative path starts at the library `from` +/// names, when a library's code made the read, and otherwise at `origin`, +/// the section that is running. +/// +/// # Errors +/// Returns a `LuaRuntime` failure when the path is malformed (see +/// [`section_path::find`]), and an `Internal` failure for a key that names +/// no section position. +pub(super) fn read_section( + path: &str, + prompt: &Prompt, + origin: Origin<'_>, + from: Option<&LibraryKey>, +) -> Result { + let library = from.map(library_position).transpose()?; + let origin = library.as_ref().map_or(origin, Origin::Section); + let pos = match section_path::find(path, prompt, origin)? { + Lookup::Found(pos) => pos, + Lookup::Missing(error) => return Ok(SectionRead::Missing(error)), + }; + let section = found_section(prompt, &pos)?; + let (text, range) = section.markdown_text().shared(); + Ok(SectionRead::Found(SectionText::new( + text, + range, + section.markdown_line(), + ))) } -/// What a prompt read sees of the section `heading` names in a caller's -/// visible set, read in place through `visible`: its own raw Markdown and -/// its pre-parsed list items, or `None` when no visible section has that -/// heading. +/// The section a lookup found at `pos`. /// /// # Errors -/// Returns a `LuaRuntime` failure when the heading is malformed or matches more -/// than one visible section (see [`heading_address::find_visible`]). -pub(super) fn read_visible(heading: &str, visible: Visible<'_>) -> Result> { - Ok( - heading_address::find_visible(heading, visible)?.map(|section| SectionText { - markdown: section.markdown().to_owned(), - items: section - .items() - .iter() - .map(|item| item.as_str().to_owned()) - .collect(), - }), - ) +/// Returns an `Internal` failure when `pos` names no section, which a +/// position a lookup returned never does. +fn found_section<'p>(prompt: &'p Prompt, pos: &SectionPos) -> Result<&'p Section> { + pos.section(prompt).ok_or(error::make_internal_error( + "a resolved section path names no section", + )) } -/// Where a jump, a `call`, or a spawn lands, resolved against the caller's -/// visible set: the target's position, and whether reaching it descends -/// into the caller's child slice. +/// Where a jump, a `call`, or a spawn lands: the target's position, and +/// whether reaching it descends into the caller's child slice. #[derive(Debug)] pub(super) struct JumpTarget { /// The target's position. pub(super) pos: SectionPos, - /// Whether the target is a direct child of the caller, so a jump to it - /// starts a child-level walk. + /// Whether the target is a direct child of a walked caller, so a jump + /// to it starts a child-level walk. pub(super) descends: bool, } -/// Resolves `heading` against the visible set of the section at `jumper` -/// (its sibling slice minus itself, plus its direct children), read in -/// place through [`Visible::of`], and classifies the target: a direct -/// child of the jumper starts a child-level walk; anything else is a -/// sibling within the jumper's own slice. +/// Resolves the relative `path` and classifies the target: a direct child +/// of a walked section starts a child-level walk, and anything else is a +/// section of the caller's own slice. The H1's children are the top-level +/// sections, where the walk already runs, so its targets never descend. /// -/// Resolution is an exact `(level, name)` match (see -/// [`heading_address::resolve_visible`]): two visible sections sharing an -/// address error loudly as ambiguous instead of silently resolving to the -/// first. +/// The path starts at the library `from` names, when a library's code +/// made the request, and otherwise at `origin`, the section that is +/// running. /// /// # Errors -/// Returns a `LuaRuntime` failure when the heading is malformed, matches no visible -/// section, or matches more than one. +/// Returns a `LuaRuntime` failure when the path is malformed, is not one +/// of the two relative forms, or names no section (see +/// [`section_path::resolve_neighbor`]), and an `Internal` failure for a +/// key that names no section position. pub(super) fn resolve_jump_target( - heading: &str, + path: &str, prompt: &Prompt, - jumper: &SectionPos, + origin: Origin<'_>, + from: Option<&LibraryKey>, ) -> Result { - let siblings = jumper.siblings(prompt); - let jumper_section = jumper.section(prompt).ok_or(error::make_internal_error( - "a jumping section is absent from its sibling slice", - ))?; - let target = heading_address::resolve_visible(heading, Visible::of(siblings, jumper.index))?; - if let Some(index) = section_position(jumper_section.children(), target) { - return Ok(JumpTarget { - pos: jumper.child(index), - descends: true, - }); - } - // `target` was resolved out of the visible set read from exactly these - // two slices, so a miss here is an internal invariant violation, not a - // user-facing Lua error. - let index = section_position(siblings, target).ok_or(error::make_internal_error( - "resolved jump target is absent from the jumper's sibling slice", - ))?; - Ok(JumpTarget { - pos: SectionPos { - slice: jumper.slice.clone(), - index, - }, - descends: false, - }) -} - -/// Where a `require` resolves from: the H1 pass, or a walked section. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) enum RequireOrigin { - /// The H1 pass, which reaches every top-level section. - H1, - /// The walked section at this position. - Section(SectionPos), -} - -impl RequireOrigin { - /// The position `key` names: its library section's own. - /// - /// # Errors - /// Returns an `Internal` failure when `key` is not one [`library_key`] - /// built. - fn from_key(key: &LibraryKey) -> Result { - let mut indices = key - .identity() - .split('.') - .map(str::parse) - .collect::, _>>() - .map_err(|cause| { - error::make_internal_with_cause_error( - "a library key names no section position", - &cause, - ) - })?; - let index = indices.pop().ok_or(error::make_internal_error( - "a library key names no section position", - ))?; - let slice = indices - .into_iter() - .fold(SlicePath::root(), |path, ancestor| { - path.child(SectionIndex::new(ancestor)) - }); - Ok(Self::Section(SectionPos { - slice, - index: SectionIndex::new(index), - })) - } - - /// What a `require` from here can name in `prompt`. - fn reach<'p>(&'p self, prompt: &'p Prompt) -> Reachable<'p> { - let root = detail::sections(prompt); - match self { - Self::H1 => Reachable::top(root), - Self::Section(pos) => Reachable::of(root, pos), - } - } + let library = from.map(library_position).transpose()?; + let origin = library.as_ref().map_or(origin, Origin::Section); + let pos = section_path::resolve_neighbor(path, prompt, origin)?; + let descends = matches!(origin, Origin::Section(caller) if caller.slice != pos.slice); + Ok(JumpTarget { pos, descends }) } /// The key naming the section at `pos`: every index along its path, then @@ -177,36 +116,61 @@ fn library_key(pos: &SectionPos) -> LibraryKey { LibraryKey::new(indices.join(".")) } -/// Resolves `heading` for a `require` and builds the library it names: -/// from the position `key` names when a library is loading, otherwise from -/// `origin`. +/// The position of the library section `key` names: where a relative path +/// in that library's code starts. /// /// # Errors -/// Returns a `LuaRuntime` failure when the heading is malformed or names no -/// section in the reach, the miss listing the reach and nothing else, and -/// an `Internal` failure for a key [`library_key`] did not build or a block -/// kind the Engine cannot run. -pub(super) fn resolve_library( - prompt: &Prompt, - origin: &RequireOrigin, - heading: &str, - key: Option<&LibraryKey>, -) -> Result { - let keyed = key.map(RequireOrigin::from_key).transpose()?; - let from = keyed.as_ref().unwrap_or(origin); - let reached = heading_address::resolve_reachable(heading, from.reach(prompt))?; - build_library(reached.section, library_key(&reached.pos)) +/// Returns an `Internal` failure when `key` is not one [`library_key`] +/// built. +pub(super) fn library_position(key: &LibraryKey) -> Result { + let mut indices = key + .identity() + .split('.') + .map(str::parse) + .collect::, _>>() + .map_err(|cause| { + error::make_internal_with_cause_error("a library key names no section position", &cause) + })?; + let index = indices.pop().ok_or(error::make_internal_error( + "a library key names no section position", + ))?; + let slice = indices + .into_iter() + .fold(SlicePath::root(), |path, ancestor| { + path.child(SectionIndex::new(ancestor)) + }); + Ok(SectionPos { + slice, + index: SectionIndex::new(index), + }) } -/// The library `section` makes under `key`: each Lua block in order, -/// paired with the prose block right before it and the line that prose -/// starts on, or with an empty template and no line when no prose block -/// comes right before it. Prose after the last Lua block pairs with -/// nothing. +/// Resolves `path` for a `require` and builds the library it names. The +/// path starts at the top of the prompt, so it names the same library +/// wherever the `require` is written. +/// +/// # Errors +/// Returns a `LuaRuntime` failure when the path is malformed, does not +/// start with `/`, or names no section, and an `Internal` failure for a +/// block kind the Engine cannot run. +pub(super) fn resolve_library(prompt: &Prompt, path: &str) -> Result { + let pos = section_path::resolve_from_top(path, prompt)?; + build_library( + found_section(prompt, &pos)?, + library_key(&pos), + section_path::path_from_top(prompt, &pos), + ) +} + +/// The library `section` makes under `key`, reported by `path`: each Lua +/// block in order, paired with the prose block right before it and the +/// line that prose starts on, or with an empty template and no line when +/// no prose block comes right before it. Prose after the last Lua block +/// pairs with nothing. /// /// # Errors /// Returns an `Internal` failure for a block kind the Engine cannot run. -fn build_library(section: &Section, key: LibraryKey) -> Result { +fn build_library(section: &Section, key: LibraryKey, path: String) -> Result { let mut blocks = Vec::new(); let mut pending = None; for block in section.blocks() { @@ -229,11 +193,7 @@ fn build_library(section: &Section, key: LibraryKey) -> Result { } } } - Ok(Library { - key, - heading: heading_address::section_heading(section), - blocks, - }) + Ok(Library { key, path, blocks }) } #[cfg(test)] diff --git a/crates/runvil-internal/runtime/src/heading_address.rs b/crates/runvil-internal/runtime/src/heading_address.rs deleted file mode 100644 index f47c9c7f3..000000000 --- a/crates/runvil-internal/runtime/src/heading_address.rs +++ /dev/null @@ -1,636 +0,0 @@ -//! Heading resolution: the exact `(level, name)` address every control -//! surface resolves through. -//! -//! A section's Lua names other sections by heading string - `call`, `jump`, -//! `prompt.section`, `prompt.list`, `tasks.spawn`, and `tasks.fanout`'s -//! worker template. This module defines [`Visible`], the view of a caller's -//! visible set read in place over the shared prompt, [`find_visible`], the -//! one heading lookup those surfaces share, and [`resolve_visible`], its -//! adapter for surfaces where a miss is an error. -//! -//! `require` names a library section the same way but reaches wider: a -//! walked section's direct children, its siblings, and the siblings of each -//! of its ancestors up to the top level, or every top-level section from -//! the H1. [`Reachable`] is that view, [`find_reachable`] its lookup, and -//! [`resolve_reachable`] its adapter where a miss is an error. Each scope -//! of a reach sits at its own heading level and sibling names are unique, -//! so an exact address names at most one reachable section. -//! -//! [`SectionPos`] names a section's place in the shared prompt tree: its -//! sibling slice by [`SlicePath`], and its [`SectionIndex`] in that slice. -//! A chain and a section's `prompt` reads find their slice by path and -//! read it in place. - -use crate::prompt::{Prompt, Section, detail}; -use crate::{Error, Result, error}; - -/// Where a sibling slice sits in the prompt tree: the index of each -/// ancestor section from the top level down to the slice's parent. The -/// empty path is the top-level slice. A chain names its walk position by -/// path so it borrows nothing from the tree the run shares through its -/// `Arc`; the path resolves to the slice on demand. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(crate) struct SlicePath(Vec); - -impl SlicePath { - /// The top-level slice. - pub(crate) fn root() -> Self { - Self::default() - } - - /// The slice of `parent`'s children, where `parent` is the section at - /// `index` of this slice. - pub(crate) fn child(&self, index: SectionIndex) -> Self { - let mut path = self.0.clone(); - path.push(index.0); - Self(path) - } - - /// Each ancestor's index, from the top level down. - pub(crate) fn indices(&self) -> &[usize] { - &self.0 - } - - /// Resolves the path against `prompt`. A path the scheduler built is - /// always in range; an out-of-range index resolves to the empty slice, - /// which the walk treats as exhausted rather than panicking. - pub(crate) fn resolve<'p>(&self, prompt: &'p Prompt) -> &'p [Section] { - let mut slice = detail::sections(prompt); - for &index in &self.0 { - slice = slice.get(index).map_or(&[], Section::children); - } - slice - } -} - -/// A section's position in its sibling slice. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct SectionIndex(usize); - -impl SectionIndex { - /// The slice's first section. - pub(crate) const FIRST: SectionIndex = SectionIndex(0); - - /// The section at `index` in its slice. - pub(crate) fn new(index: usize) -> SectionIndex { - SectionIndex(index) - } - - /// The position as a slice index. - pub(crate) fn get(self) -> usize { - self.0 - } - - /// The section after this one in the same slice. - pub(crate) fn next(self) -> SectionIndex { - SectionIndex(self.0 + 1) - } -} - -/// A section's place in the prompt tree: the path of its sibling slice and -/// its index in that slice. A position the walk moves to may sit past its -/// slice's last section, where it names no section. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct SectionPos { - /// The path of the section's sibling slice. - pub(crate) slice: SlicePath, - /// The section's index in that slice. - pub(crate) index: SectionIndex, -} - -impl SectionPos { - /// The top-level section at `index`. - pub(crate) fn top(index: SectionIndex) -> SectionPos { - SectionPos { - slice: SlicePath::root(), - index, - } - } - - /// The next sibling. - pub(crate) fn next(&self) -> SectionPos { - SectionPos { - slice: self.slice.clone(), - index: self.index.next(), - } - } - - /// This section's child at `index`. - pub(crate) fn child(&self, index: SectionIndex) -> SectionPos { - SectionPos { - slice: self.slice.child(self.index), - index, - } - } - - /// The section's sibling slice, resolved against `prompt`. - pub(crate) fn siblings<'p>(&self, prompt: &'p Prompt) -> &'p [Section] { - self.slice.resolve(prompt) - } - - /// The section, or `None` when the index is past its slice. - pub(crate) fn section<'p>(&self, prompt: &'p Prompt) -> Option<&'p Section> { - self.siblings(prompt).get(self.index.0) - } - - /// The section's name, or the prompt's title when the index is past - /// its slice: a chain's section name before its first entry. - pub(crate) fn name<'p>(&self, prompt: &'p Prompt) -> &'p str { - self.section(prompt).map_or(prompt.title(), Section::name) - } -} - -/// A section's visible set, read in place: its home slice minus itself, -/// plus its direct children. -#[derive(Clone, Copy, Debug)] -pub(crate) struct Visible<'a> { - home: &'a [Section], - caller: Option, -} - -impl<'a> Visible<'a> { - /// A walked section's set: `home` minus `home[caller]`, plus - /// `home[caller]`'s children. `caller` must index `home`, which the - /// `debug_assert!` below enforces: a caller outside the slice is a - /// scheduler bug, not a case this view answers. - pub(crate) fn of(home: &'a [Section], caller: SectionIndex) -> Visible<'a> { - debug_assert!( - caller.0 < home.len(), - "a walked section lies inside its home slice" - ); - Visible { - home, - caller: Some(caller), - } - } - - /// A whole slice and nothing else: the H1 pass's top-level set, and - /// synthetic sets in tests. - pub(crate) fn all(slice: &'a [Section]) -> Visible<'a> { - Visible { - home: slice, - caller: None, - } - } - - /// The sections in order: the home slice in order with the caller - /// skipped, then the children. - pub(crate) fn iter(self) -> impl Iterator { - let caller = self.caller; - let children = caller - .and_then(|c| self.home.get(c.0)) - .map_or(&[][..], Section::children); - self.home - .iter() - .enumerate() - .filter(move |&(index, _)| Some(SectionIndex(index)) != caller) - .map(|(_, section)| section) - .chain(children) - } -} - -/// What `require` can name from one place, read in place: from a walked -/// section, its home slice minus itself, its direct children, and each -/// ancestor's slice minus that ancestor; from the H1, the top-level slice. -#[derive(Clone, Copy, Debug)] -pub(crate) struct Reachable<'a> { - root: &'a [Section], - /// The walked section the reach is from, or `None` for the H1 pass. - from: Option<&'a SectionPos>, -} - -/// A reachable section and where it sits. -#[derive(Debug)] -pub(crate) struct Reached<'a> { - pub(crate) section: &'a Section, - pub(crate) pos: SectionPos, -} - -impl<'a> Reachable<'a> { - /// A walked section's reach: the section at `from`, read from `root`. - /// An out-of-range index along the way leads to an empty slice, as a - /// walk path's does. - pub(crate) fn of(root: &'a [Section], from: &'a SectionPos) -> Reachable<'a> { - Reachable { - root, - from: Some(from), - } - } - - /// The H1's reach: every top-level section. - pub(crate) fn top(root: &'a [Section]) -> Reachable<'a> { - Reachable { root, from: None } - } - - /// The sections with their positions, nearest scope first: the home - /// slice with the caller skipped, the caller's children, then each - /// ancestor's slice with that ancestor skipped, from the parent's - /// outward to the top level. - pub(crate) fn iter(self) -> impl Iterator> { - let ancestors = self.from.map_or(&[][..], |from| from.slice.indices()); - let caller = self.from.map(|from| from.index); - let mut slices = Vec::with_capacity(ancestors.len()); - let mut home = self.root; - for &index in ancestors { - slices.push(home); - home = home.get(index).map_or(&[][..], Section::children); - } - let path = SlicePath(ancestors.to_vec()); - let children = caller.map(|caller| { - ( - path.child(caller), - home.get(caller.0).map_or(&[][..], Section::children), - None, - ) - }); - let mut scopes = vec![(path, home, caller)]; - scopes.extend(children); - for (depth, (&slice, &ancestor)) in slices.iter().zip(ancestors).enumerate().rev() { - scopes.push(( - SlicePath(ancestors[..depth].to_vec()), - slice, - Some(SectionIndex(ancestor)), - )); - } - scopes.into_iter().flat_map(|(path, slice, skip)| { - slice - .iter() - .enumerate() - .map(|(index, section)| (SectionIndex(index), section)) - .filter(move |&(index, _)| Some(index) != skip) - .map(move |(index, section)| Reached { - section, - pos: SectionPos { - slice: path.clone(), - index, - }, - }) - }) - } -} - -/// A section's heading with its markers, such as `### Name`. -pub(crate) fn section_heading(section: &Section) -> String { - format!("{} {}", "#".repeat(section.level().into()), section.name()) -} - -/// Parses a section heading like `"### Name"` into an exact `(level, name)` -/// address. -/// -/// The marker run must be one-or-more `#`, immediately followed by whitespace, -/// then a non-empty name. `"###Name"` (no whitespace) and a bare name are both -/// rejected, so a malformed heading can never be silently reinterpreted. -/// -/// # Errors -/// Returns a `LuaRuntime` failure when the marker run is absent, is not followed by -/// whitespace, or the name is empty. -fn parse_heading_address(heading: &str) -> Result<(usize, String)> { - let stripped = heading.trim(); - let level = stripped.chars().take_while(|&c| c == '#').count(); - if level == 0 { - return Err(error::make_lua_runtime_error(format!( - "section heading must include ### markers, got bare name: {stripped}" - ))); - } - // The `#` run is ASCII, so a byte slice at `level` is a valid boundary. - let rest = &stripped[level..]; - // Checked before the whitespace gate: a marker-only heading (`###`) has - // no name to parse whether or not whitespace followed the markers. - let name = rest.trim(); - if name.is_empty() { - return Err(error::make_lua_runtime_error(format!( - "section heading has no name: {stripped}" - ))); - } - if !rest.starts_with(|c: char| c.is_whitespace()) { - return Err(error::make_lua_runtime_error(format!( - "section heading must have whitespace after the {} markers: {stripped}", - "#".repeat(level) - ))); - } - Ok((level, name.to_owned())) -} - -/// Looks up a heading string like `"### Name"` in a caller's visible set, -/// returning the single section at its exact address or `None`. A heading -/// outside the visible set and a heading that names no section anywhere are -/// the same `None`, because only the visible set is ever searched. -/// -/// # Errors -/// Returns a `LuaRuntime` failure when the heading is malformed (see -/// [`parse_heading_address`]) or when more than one visible section matches -/// the exact address, so an ambiguous heading never resolves to an arbitrary -/// first hit. -pub(crate) fn find_visible<'a>(heading: &str, visible: Visible<'a>) -> Result> { - find_one(heading, visible.iter(), |section| *section, "visible") -} - -/// [`find_visible`] over a [`Reachable`] view: the single reachable section -/// at `heading`'s exact address, with its position, or `None`. -/// -/// # Errors -/// Returns a `LuaRuntime` failure when the heading is malformed (see -/// [`parse_heading_address`]) or when more than one reachable section -/// matches the exact address. -pub(crate) fn find_reachable<'a>( - heading: &str, - reach: Reachable<'a>, -) -> Result>> { - find_one( - heading, - reach.iter(), - |reached| reached.section, - "reachable", - ) -} - -/// The one candidate whose section has `heading`'s exact address, or -/// `None`; `set` names the candidates in the ambiguity error. -fn find_one<'a, T>( - heading: &str, - candidates: impl Iterator, - section: impl Fn(&T) -> &'a Section, - set: &str, -) -> Result> { - let (level, name) = parse_heading_address(heading)?; - let mut matches = candidates.filter(|candidate| { - let section = section(candidate); - usize::from(section.level()) == level && section.name() == name - }); - let found = matches.next(); - if found.is_some() && matches.next().is_some() { - return Err(error::make_lua_runtime_error(format!( - "section heading `{}` is ambiguous; more than one {set} section matches {} {name}", - heading.trim(), - "#".repeat(level) - ))); - } - Ok(found) -} - -/// The miss error for `heading`, listing `available` in order and nothing -/// else, so the error channel cannot leak the rest of the document's -/// structure. -fn make_not_found_error<'a>(heading: &str, available: impl Iterator) -> Error { - let available: Vec = available.map(section_heading).collect(); - error::make_lua_runtime_error(format!( - "section heading `{}` not found; available sections: {}", - heading.trim(), - available.join(", ") - )) -} - -/// [`find_visible`] where a heading with no visible match is an error, for -/// the surfaces that must reach a section, such as `call` and `jump`. -/// -/// # Errors -/// Returns a `LuaRuntime` failure for every [`find_visible`] error, and, through -/// [`make_not_found_error`], when no visible section matches the exact address. -pub(crate) fn resolve_visible<'a>(heading: &str, visible: Visible<'a>) -> Result<&'a Section> { - find_visible(heading, visible)?.ok_or_else(|| make_not_found_error(heading, visible.iter())) -} - -/// [`find_reachable`] where a heading with no reachable match is an error, -/// for `require`. -/// -/// # Errors -/// Returns a `LuaRuntime` failure for every [`find_reachable`] error, and, through -/// [`make_not_found_error`], when no reachable section matches the exact address. -pub(crate) fn resolve_reachable<'a>(heading: &str, reach: Reachable<'a>) -> Result> { - find_reachable(heading, reach)? - .ok_or_else(|| make_not_found_error(heading, reach.iter().map(|reached| reached.section))) -} - -#[cfg(test)] -mod tests { - //! Tests for heading lookup over a visible set. - - use super::*; - - #[test] - fn resolve_visible_finds_exact_match() { - let sections = vec![sibling("Worker", 3), sibling("Topics", 3)]; - let found = resolve_visible("### Worker", Visible::all(§ions)).expect("must resolve"); - assert_eq!(found.name(), "Worker"); - } - - #[test] - fn resolve_visible_missing_heading_lists_available() { - let sections = vec![sibling("Worker", 3)]; - let err = resolve_visible("### Missing", Visible::all(§ions)) - .expect_err("missing heading must error"); - assert!(err.to_string().contains("### Worker"), "error was: {err}"); - } - - #[test] - fn resolve_visible_bare_name_errors() { - let sections = vec![sibling("Worker", 3)]; - let err = resolve_visible("Worker", Visible::all(§ions)) - .expect_err("bare name without ### must error"); - assert!(err.to_string().contains("### markers"), "error was: {err}"); - } - - fn sibling(name: &str, level: u8) -> Section { - runvil_prompt::test_support::synthetic_section( - name, - level, - vec![runvil_prompt::test_support::prose_block(String::new())], - ) - } - - #[test] - fn resolve_visible_requires_whitespace_after_markers() { - let sections = vec![sibling("Worker", 3)]; - let err = resolve_visible("###Worker", Visible::all(§ions)) - .expect_err("no whitespace after markers must error"); - assert!(err.to_string().contains("whitespace"), "error was: {err}"); - } - - #[test] - fn resolve_visible_marker_only_heading_errors_as_nameless() { - let sections = vec![sibling("Worker", 3)]; - let err = resolve_visible("### ", Visible::all(§ions)) - .expect_err("a marker-only heading must error"); - assert!(err.to_string().contains("has no name"), "error was: {err}"); - } - - #[test] - fn resolve_visible_requires_exact_level() { - let sections = vec![sibling("Worker", 3)]; - // Same name, wrong marker level, must not resolve. - let err = resolve_visible("## Worker", Visible::all(§ions)) - .expect_err("a level mismatch must not resolve by name alone"); - assert!(err.to_string().contains("not found"), "error was: {err}"); - // The exact address resolves. - let ok = - resolve_visible("### Worker", Visible::all(§ions)).expect("exact address resolves"); - assert_eq!(ok.name(), "Worker"); - } - - #[test] - fn resolve_visible_rejects_more_than_one_match() { - let sections = vec![sibling("Worker", 3), sibling("Worker", 3)]; - let err = resolve_visible("### Worker", Visible::all(§ions)) - .expect_err("two identical siblings must be rejected as ambiguous"); - assert!(err.to_string().contains("ambiguous"), "error was: {err}"); - } - - #[test] - fn find_visible_returns_none_for_an_address_not_in_the_set() { - let sections = vec![sibling("Worker", 3)]; - let missing = find_visible("### Missing", Visible::all(§ions)) - .expect("a missing name is not an error"); - assert!(missing.is_none(), "a missing name must be None"); - let wrong_level = find_visible("## Worker", Visible::all(§ions)) - .expect("a level mismatch is not an error"); - assert!(wrong_level.is_none(), "a level mismatch must be None"); - let found = - find_visible("### Worker", Visible::all(§ions)).expect("exact address resolves"); - assert_eq!(found.map(Section::name), Some("Worker")); - } - - /// A tree around `#### Self`, four levels deep: `## Grand` holds - /// `### Uncle` and `### Parent`, and `### Parent` holds `#### Sibling` and - /// `#### Self`. - const TREE: &str = concat!( - "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n# Title\n\n", - "## Top\n\nx\n\n", - "## Grand\n\nx\n\n", - "### Uncle\n\nx\n\n", - "#### Cousin\n\nx\n\n", - "### Parent\n\nx\n\n", - "#### Sibling\n\nx\n\n", - "##### Niece\n\nx\n\n", - "#### Self\n\nx\n\n", - "##### Child\n\nx\n\n", - "###### Grandchild\n\nx\n\n", - "## Last\n\nx\n", - ); - - /// `#### Self`: index 1 of `### Parent`'s children, which is index 1 of - /// `## Grand`'s. - fn self_pos() -> SectionPos { - SectionPos::top(SectionIndex::new(1)) - .child(SectionIndex::new(1)) - .child(SectionIndex::new(1)) - } - - fn tree() -> crate::prompt::Prompt { - crate::prompt::Prompt::parse(&|_: &str| Ok(TREE.to_owned()), "prompt") - .expect("the tree parses") - } - - /// `#### Self`'s reach. - fn from_self<'a>(root: &'a [Section], pos: &'a SectionPos) -> Reachable<'a> { - Reachable::of(root, pos) - } - - #[test] - fn a_nested_section_reaches_its_children_siblings_and_each_ancestors_siblings() { - let prompt = tree(); - let root = runvil_prompt::detail::sections(&prompt); - let pos = self_pos(); - let expected: [(&str, &[usize], usize); 5] = [ - ("##### Child", &[1, 1, 1], 0), - ("#### Sibling", &[1, 1], 0), - ("### Uncle", &[1], 0), - ("## Top", &[], 0), - ("## Last", &[], 2), - ]; - for (heading, path, index) in expected { - let reached = resolve_reachable(heading, from_self(root, &pos)) - .unwrap_or_else(|error| panic!("{heading} must resolve: {error}")); - assert_eq!(section_heading(reached.section), heading); - assert_eq!( - (reached.pos.slice.indices(), reached.pos.index.get()), - (path, index), - "{heading} sits at its own position" - ); - } - } - - #[test] - fn a_nested_section_reaches_no_grandchild_niece_cousin_itself_or_ancestor() { - let prompt = tree(); - let root = runvil_prompt::detail::sections(&prompt); - let pos = self_pos(); - for heading in [ - "###### Grandchild", - "##### Niece", - "#### Cousin", - "#### Self", - "### Parent", - "## Grand", - ] { - let found = - find_reachable(heading, from_self(root, &pos)).expect("a miss is not an error"); - assert!(found.is_none(), "{heading} must be out of reach"); - } - } - - #[test] - fn the_h1_reaches_every_top_level_section_and_no_nested_one() { - let prompt = tree(); - let root = runvil_prompt::detail::sections(&prompt); - for heading in ["## Top", "## Grand", "## Last"] { - let reached = resolve_reachable(heading, Reachable::top(root)) - .unwrap_or_else(|error| panic!("{heading} must resolve: {error}")); - assert_eq!( - reached.pos.slice, - SlicePath::root(), - "{heading} is top-level" - ); - } - for heading in ["### Uncle", "### Parent", "#### Self"] { - let found = - find_reachable(heading, Reachable::top(root)).expect("a miss is not an error"); - assert!(found.is_none(), "{heading} must be out of the H1's reach"); - } - } - - #[test] - fn a_reachable_name_at_another_level_does_not_resolve() { - let prompt = tree(); - let root = runvil_prompt::detail::sections(&prompt); - let pos = self_pos(); - for heading in ["### Sibling", "## Uncle", "#### Child", "### Top"] { - let found = - find_reachable(heading, from_self(root, &pos)).expect("a miss is not an error"); - assert!( - found.is_none(), - "{heading} names a reachable section at the wrong level" - ); - } - } - - #[test] - fn a_reach_miss_lists_only_the_reachable_sections() { - let prompt = tree(); - let root = runvil_prompt::detail::sections(&prompt); - let pos = self_pos(); - let error = resolve_reachable("## Missing", from_self(root, &pos)) - .expect_err("an unknown heading must error"); - assert_eq!( - error.to_string(), - "section heading `## Missing` not found; available sections: \ - #### Sibling, ##### Child, ### Uncle, ## Top, ## Last" - ); - } - - #[test] - fn the_visible_set_stays_narrower_than_the_reach() { - let prompt = tree(); - let root = runvil_prompt::detail::sections(&prompt); - let slice = root[1].children()[1].children(); - let visible = find_visible("### Uncle", Visible::of(slice, SectionIndex::new(1))) - .expect("a miss is not an error"); - assert!(visible.is_none(), "call and jump keep their narrower set"); - let pos = self_pos(); - assert!( - find_reachable("### Uncle", from_self(root, &pos)) - .expect("a miss is not an error") - .is_some(), - "require reaches the parent's sibling" - ); - } -} diff --git a/crates/runvil-internal/runtime/src/lib.rs b/crates/runvil-internal/runtime/src/lib.rs index 1b1859570..4e82fb2b9 100644 --- a/crates/runvil-internal/runtime/src/lib.rs +++ b/crates/runvil-internal/runtime/src/lib.rs @@ -11,7 +11,7 @@ //! ## Invariants //! //! - May depend on: `runvil-types`, `runvil-lua`, -//! `runvil-prompt`, `runvil-vfs`, and +//! `runvil-prompt`, `runvil-markdown`, `runvil-vfs`, and //! `runvil-model-client`. `cargo test -p build-xtask` enforces the //! product and container boundaries. //! - A run ends with `Ok` holding its final text or `Err` holding the @@ -20,10 +20,11 @@ mod error; mod execute; -mod heading_address; mod lua; mod model; mod prompt; +mod section_path; +mod section_pos; mod subst; #[cfg(any(test, feature = "test-support"))] pub mod test_support; diff --git a/crates/runvil-internal/runtime/src/lua.rs b/crates/runvil-internal/runtime/src/lua.rs index 197cf2c11..cc6698771 100644 --- a/crates/runvil-internal/runtime/src/lua.rs +++ b/crates/runvil-internal/runtime/src/lua.rs @@ -20,10 +20,10 @@ pub(crate) use runvil_lua::{ AdhocToolOutcome, Answer, Argv, Blocked, ChatOutcome, ChatResult, CoroStep, DiscoveryTool, Library, LibraryBlock, LibraryKey, LuaBlockResult, MessageList, ModelCall, NonEmpty, OfferMode, OfferedTool, OverflowReason, ProseState, RenderedTools, Request, RosterEntry, ScriptReport, - SectionText, SectionVm, TaskDelivery, TaskPhase, TaskStatus, ToolBinding, ToolCallCounts, - ToolCallOutcome, ToolCallRecord, ToolSet, Uninjected, UsageAnchor, VfsOp, VfsOutcome, - YieldParse, current_catalog_packs, current_dynamic_set, current_static_set, install_preludes, - install_store_shims, install_ui, output_reserve, precheck, prepare_dispatch, + SectionRead, SectionText, SectionVm, TaskDelivery, TaskPhase, TaskStatus, ToolBinding, + ToolCallCounts, ToolCallOutcome, ToolCallRecord, ToolSet, Uninjected, UsageAnchor, VfsOp, + VfsOutcome, YieldParse, current_catalog_packs, current_dynamic_set, current_static_set, + install_preludes, install_store_shims, install_ui, output_reserve, precheck, prepare_dispatch, prepare_model_dispatch, project_messages, render_item, wire_name_of, }; diff --git a/crates/runvil-internal/runtime/src/lua/tests.rs b/crates/runvil-internal/runtime/src/lua/tests.rs index f5bb54739..ec3800f94 100644 --- a/crates/runvil-internal/runtime/src/lua/tests.rs +++ b/crates/runvil-internal/runtime/src/lua/tests.rs @@ -31,7 +31,7 @@ use runvil_types::untrusted::GuardNonce; use serde_json::json; use crate::execute::section_vm::{SectionVmSetup, VmSeed, setup_section_vm}; -use crate::lua::{Answer, Argv, Library, Request, SectionText, SectionVm, ToolSet, YieldParse}; +use crate::lua::{Answer, Argv, Library, Request, SectionRead, SectionVm, ToolSet, YieldParse}; use crate::model::ModelSet; use crate::test_support::fresh_access; use crate::test_support::recording::null_emitter; @@ -142,10 +142,14 @@ fn set_up_vm(binds: &VmBinds<'_>) -> SectionVm { preludes: &[], raw_shims: false, }; - let read = |_: String| -> std::result::Result, crate::Error> { Ok(None) }; - let resolve = |heading: String, _| -> std::result::Result { + let read = |path: String, _| -> std::result::Result { + Ok(SectionRead::Missing(crate::error::make_lua_runtime_error( + format!("section `{path}` not found"), + ))) + }; + let resolve = |path: String| -> std::result::Result { Err(crate::error::make_lua_runtime_error(format!( - "no library `{heading}` in reach" + "section `{path}` not found" ))) }; setup_section_vm(vm, &setup, read, resolve, |template, _, _| { diff --git a/crates/runvil-internal/runtime/src/lua/tests/coroutine.rs b/crates/runvil-internal/runtime/src/lua/tests/coroutine.rs index 8b506be78..011f3a313 100644 --- a/crates/runvil-internal/runtime/src/lua/tests/coroutine.rs +++ b/crates/runvil-internal/runtime/src/lua/tests/coroutine.rs @@ -94,17 +94,17 @@ fn a_shim_yield_suspends_and_resumes_across_pcall() { #[test] fn jump_transfers_through_thread_resume_unchanged() { - // Spike (c): `jump` records the heading and raises its transfer + // Spike (c): `jump` records the path and raises its transfer // marker; through `Thread::resume` the slot still takes precedence // over the chunk's error, so the outcome matches the direct // `run_chunk` path. let vm = scheduler_vm(&ModelSet::default(), None); - let program = compile_block("jump(\"## Target\")\nerror(\"unreachable\")"); + let program = compile_block("jump(\"../Target\")\nerror(\"unreachable\")"); match vm .start_block_coro(&program) .expect("a jump is not a failure") { - CoroStep::Done(LuaBlockResult::Jump(heading, _)) => assert_eq!(heading, "## Target"), + CoroStep::Done(LuaBlockResult::Jump(path, _)) => assert_eq!(path, "../Target"), other => panic!("expected the jump transfer, got {other:?}"), } } diff --git a/crates/runvil-internal/runtime/src/lua/tests/errors.rs b/crates/runvil-internal/runtime/src/lua/tests/errors.rs index 7ea2b4d7d..e04f65603 100644 --- a/crates/runvil-internal/runtime/src/lua/tests/errors.rs +++ b/crates/runvil-internal/runtime/src/lua/tests/errors.rs @@ -387,7 +387,7 @@ fn a_traceback_through_a_shim_shows_unmapped_impl_frames() { // The var_snapshot capture fails on a reassigned `var` global: an // unexpected shim error, whose frames must render verbatim. let program = LuaProgram::compile( - "var = 5\ncall(\"## Child\")", + "var = 5\ncall(\"../Child\")", "prompt", "section `Test` prologue", NonZeroU32::new(40).expect("40 is non-zero"), diff --git a/crates/runvil-internal/runtime/src/lua/tests/shims.rs b/crates/runvil-internal/runtime/src/lua/tests/shims.rs index 6a7bc2d7a..67d4dc968 100644 --- a/crates/runvil-internal/runtime/src/lua/tests/shims.rs +++ b/crates/runvil-internal/runtime/src/lua/tests/shims.rs @@ -34,11 +34,17 @@ fn models_infer_yields_a_well_formed_request() { fn call_yields_target_input_and_the_var_snapshot() { let var = json!({ "k": 1 }); let vm = scheduler_vm(&ModelSet::default(), Some(&var)); - match yielded_request(&vm, r###"return call("## Child", "override")"###) { - Request::Call { target, input, var } => { - assert_eq!(target, "## Child"); + match yielded_request(&vm, r#"return call("../Child", "override")"#) { + Request::Call { + target, + input, + var, + from, + } => { + assert_eq!(target, "../Child"); assert_eq!(input.as_deref(), Some("override")); assert_eq!(var, json!({ "k": 1 })); + assert_eq!(from, None, "a section's own call names no library"); } other => panic!("expected a call request, got {other:?}"), } @@ -51,22 +57,21 @@ fn fanout_yields_a_spawn_per_member_starting_with_the_first() { // member as the `item` seed, its 1-based position as `index`, and the // caller's `var` snapshot. let vm = scheduler_vm(&ModelSet::default(), None); - match yielded_request( - &vm, - r####"return tasks.fanout("### Worker", {"a", "b"})"####, - ) { + match yielded_request(&vm, r#"return tasks.fanout("Worker", {"a", "b"})"#) { Request::Spawn { target, input, item, index, var, + from, } => { - assert_eq!(target, "### Worker"); + assert_eq!(target, "Worker"); assert_eq!(input, None); assert_eq!(item, Some(json!("a"))); assert_eq!(index, Some(1)); assert_eq!(var, json!({})); + assert_eq!(from, None, "a section's own fanout names no library"); } other => panic!("expected a spawn request, got {other:?}"), } @@ -81,7 +86,7 @@ fn fanout_rejects_an_empty_collection_before_any_spawn() { let (kind, message): (String, String) = vm .lua() .load( - "local ok, err = pcall(tasks.fanout, '### Worker', {})\n\ + "local ok, err = pcall(tasks.fanout, 'Worker', {})\n\ assert(not ok, 'an empty collection must fail')\n\ return err.kind, tostring(err)", ) @@ -99,7 +104,7 @@ fn tasks_spawn_yields_the_target_seeds_and_var() { let var = json!({ "k": 1 }); let vm = scheduler_vm(&ModelSet::default(), Some(&var)); let source = - r###"return tasks.spawn("## Child", { input = "in", item = { name = "a" }, index = 2 })"###; + r#"return tasks.spawn("../Child", { input = "in", item = { name = "a" }, index = 2 })"#; match yielded_request(&vm, source) { Request::Spawn { target, @@ -107,12 +112,14 @@ fn tasks_spawn_yields_the_target_seeds_and_var() { item, index, var, + from, } => { - assert_eq!(target, "## Child"); + assert_eq!(target, "../Child"); assert_eq!(input.as_deref(), Some("in")); assert_eq!(item, Some(json!({ "name": "a" }))); assert_eq!(index, Some(2)); assert_eq!(var, json!({ "k": 1 })); + assert_eq!(from, None, "a section's own spawn names no library"); } other => panic!("expected a spawn request, got {other:?}"), } @@ -126,7 +133,7 @@ fn tasks_spawn_resumes_with_a_methodless_task_table() { let vm = scheduler_vm(&ModelSet::default(), None); let (thread, _yielded) = start( &vm, - "local t = tasks.spawn('## Child')\n\ + "local t = tasks.spawn('../Child')\n\ return t.task, getmetatable(t) == nil, next(t, 'task') == nil", ); let (id, methodless, single_field): (String, bool, bool) = thread @@ -143,7 +150,7 @@ fn tasks_spawn_rejects_non_table_options_at_the_call_site() { let (kind, message): (String, String) = vm .lua() .load( - "local ok, err = pcall(tasks.spawn, '## Child', 5)\n\ + "local ok, err = pcall(tasks.spawn, '../Child', 5)\n\ assert(not ok, 'a non-table opts argument must fail')\n\ return err.kind, tostring(err)", ) @@ -184,7 +191,7 @@ fn an_adhoc_tool_handler_runs_inside_the_block_coroutine() { &vm, "local withheld\n\ tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args)\n\ - local ok, err = pcall(jump, '## Other')\n\ + local ok, err = pcall(jump, '../Other')\n\ withheld = not ok\n\ and tostring(err):find('jump is unavailable inside an adhoc tool handler', 1, true) ~= nil\n\ store.write('grab.txt', args.value)\n\ diff --git a/crates/runvil-internal/runtime/src/section_path-tests.rs b/crates/runvil-internal/runtime/src/section_path-tests.rs new file mode 100644 index 000000000..5469b5215 --- /dev/null +++ b/crates/runvil-internal/runtime/src/section_path-tests.rs @@ -0,0 +1,393 @@ +//! Tests of the section path lookup: which section each form names from +//! each place, and what a path that names none reports. + +use super::{Lookup, Origin, find, path_from_top, resolve_from_top, resolve_neighbor}; +use crate::prompt::Prompt; +use crate::section_pos::SectionPos; + +const FRONT: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n# Title\n\n"; + +/// `Main` with a child and a grandchild, a sibling, and a sibling that has +/// a child of its own. +const NEIGHBORS: &str = concat!( + "## Main\n\nx\n\n", + "### Kid\n\nx\n\n", + "#### Grand\n\nx\n\n", + "## Sibling\n\nx\n\n", + "## Other\n\nx\n\n", + "### Niece\n\nx\n", +); + +/// A tree around `Self`, five levels deep: `Grand` holds `Uncle` and +/// `Parent`, and `Parent` holds `Sibling` and `Self`. +const TREE: &str = concat!( + "## Top\n\nx\n\n", + "## Grand\n\nx\n\n", + "### Uncle\n\nx\n\n", + "#### Cousin\n\nx\n\n", + "### Parent\n\nx\n\n", + "#### Sibling\n\nx\n\n", + "##### Niece\n\nx\n\n", + "#### Self\n\nx\n\n", + "##### Child\n\nx\n\n", + "###### Grandchild\n\nx\n\n", + "## Last\n\nx\n", +); + +fn prompt(sections: &str) -> Prompt { + let source = format!("{FRONT}{sections}"); + Prompt::parse(&|_: &str| Ok(source.clone()), "prompt").expect("the prompt parses") +} + +/// The position of the section `path` names from the top. +fn at(prompt: &Prompt, path: &str) -> SectionPos { + resolve_from_top(path, prompt).unwrap_or_else(|error| panic!("{path} must resolve: {error}")) +} + +/// What a lookup gave. +#[derive(Debug, PartialEq, Eq)] +enum Outcome { + /// The section it named, as a path from the top. + Found(String), + /// The message of its miss or its refusal. + Failed(String), +} + +/// What a read of `path` from `origin` names, or the message of its miss. +fn read(prompt: &Prompt, origin: Origin<'_>, path: &str) -> Outcome { + match find(path, prompt, origin).unwrap_or_else(|error| panic!("{path} is refused: {error}")) { + Lookup::Found(pos) => Outcome::Found(path_from_top(prompt, &pos)), + Lookup::Missing(error) => Outcome::Failed(error.to_string()), + } +} + +/// What running `path` from `origin` names, or the message of its error. +fn run(prompt: &Prompt, origin: Origin<'_>, path: &str) -> Outcome { + match resolve_neighbor(path, prompt, origin) { + Ok(pos) => Outcome::Found(path_from_top(prompt, &pos)), + Err(error) => Outcome::Failed(error.to_string()), + } +} + +fn found(path: &str) -> Outcome { + Outcome::Found(path.to_owned()) +} + +fn failed(message: &str) -> Outcome { + Outcome::Failed(message.to_owned()) +} + +#[test] +fn a_name_is_a_direct_child_and_a_dotted_name_is_a_sibling() { + let prompt = prompt(NEIGHBORS); + let main = at(&prompt, "/Main"); + let origin = Origin::Section(&main); + assert_eq!(run(&prompt, origin, "Kid"), found("/Main/Kid")); + assert_eq!(run(&prompt, origin, "../Sibling"), found("/Sibling")); + assert_eq!(run(&prompt, origin, "../Other"), found("/Other")); + let kid = at(&prompt, "/Main/Kid"); + assert_eq!( + run(&prompt, Origin::Section(&kid), "Grand"), + found("/Main/Kid/Grand") + ); + let niece = at(&prompt, "/Other/Niece"); + assert_eq!( + run(&prompt, Origin::Section(&niece), "../Niece"), + failed("section `../Niece` not found: `/Other/Niece` has no sibling `Niece`") + ); +} + +#[test] +fn a_miss_says_which_section_was_searched_and_what_was_wanted_there() { + let prompt = prompt(NEIGHBORS); + let main = at(&prompt, "/Main"); + let origin = Origin::Section(&main); + for (path, message) in [ + // A grandchild and a sibling's child are not neighbors. + ( + "Grand", + "section `Grand` not found: `/Main` has no child `Grand`", + ), + ( + "Niece", + "section `Niece` not found: `/Main` has no child `Niece`", + ), + // A sibling needs `../`, and a child takes none. + ( + "Sibling", + "section `Sibling` not found: `/Main` has no child `Sibling`", + ), + ( + "../Kid", + "section `../Kid` not found: `/Main` has no sibling `Kid`", + ), + // A section is not its own sibling. + ( + "../Main", + "section `../Main` not found: `/Main` has no sibling `Main`", + ), + ] { + assert_eq!(run(&prompt, origin, path), failed(message)); + assert_eq!(read(&prompt, origin, path), failed(message)); + } +} + +#[test] +fn the_h1s_children_are_the_top_level_sections_and_it_has_no_siblings() { + let prompt = prompt(NEIGHBORS); + for name in ["Main", "Sibling", "Other"] { + assert_eq!(run(&prompt, Origin::H1, name), found(&format!("/{name}"))); + } + assert_eq!( + run(&prompt, Origin::H1, "Kid"), + failed("section `Kid` not found: the top level has no section `Kid`") + ); + assert_eq!( + run(&prompt, Origin::H1, "../Main"), + failed("section `../Main` not found: the H1 has no siblings") + ); +} + +#[test] +fn a_relative_path_is_one_name_or_two_dots_and_a_name() { + let prompt = prompt(NEIGHBORS); + let main = at(&prompt, "/Main"); + let more = |path: &str| { + format!( + "section path \"{path}\" has more than one name; a relative path is a child's \ + name, or \"../\" and a sibling's name" + ) + }; + for origin in [Origin::Section(&main), Origin::H1] { + for path in [ + "Kid/Grand", + "../Other/Niece", + "../../Main", + "../..", + "Kid/..", + ] { + assert_eq!(run(&prompt, origin, path), failed(&more(path))); + assert_eq!( + find(path, &prompt, origin).unwrap_err().to_string(), + more(path) + ); + } + assert_eq!( + run(&prompt, origin, ".."), + failed("section path \"..\" needs a name after \"..\"") + ); + } +} + +#[test] +fn a_path_from_the_top_names_any_section_and_the_same_one_from_everywhere() { + let prompt = prompt(TREE); + let own = at(&prompt, "/Grand/Parent/Self"); + let last = at(&prompt, "/Last"); + for path in [ + // Top-level sections. + "/Top", + "/Last", + // A sibling of the parent, and its child. + "/Grand/Uncle", + "/Grand/Uncle/Cousin", + // A sibling's child, and a grandchild. + "/Grand/Parent/Sibling/Niece", + "/Grand/Parent/Self/Child/Grandchild", + // Ancestors, and the section itself. + "/Grand/Parent", + "/Grand", + "/Grand/Parent/Self", + // A sibling and a child, which the relative forms name too. + "/Grand/Parent/Sibling", + "/Grand/Parent/Self/Child", + ] { + for origin in [Origin::Section(&own), Origin::Section(&last), Origin::H1] { + assert_eq!(read(&prompt, origin, path), found(path)); + } + assert_eq!(path_from_top(&prompt, &at(&prompt, path)), path); + } + assert_eq!( + read(&prompt, Origin::Section(&own), "../Sibling"), + found("/Grand/Parent/Sibling") + ); + assert_eq!( + read(&prompt, Origin::Section(&own), "Child"), + found("/Grand/Parent/Self/Child") + ); +} + +#[test] +fn a_path_from_the_top_reports_the_step_where_it_stopped() { + let prompt = prompt(NEIGHBORS); + let main = at(&prompt, "/Main"); + for (path, message) in [ + ( + "/Other/Neice", + "section `/Other/Neice` not found: `/Other` has no child `Neice`", + ), + ( + "/Othr/Niece", + "section `/Othr/Niece` not found: the top level has no section `Othr`", + ), + ( + "/Main/Kid/Grand/Deeper", + "section `/Main/Kid/Grand/Deeper` not found: `/Main/Kid/Grand` has no child `Deeper`", + ), + // The H1's title is not a step. + ( + "/Title/Main", + "section `/Title/Main` not found: the top level has no section `Title`", + ), + ] { + for origin in [Origin::Section(&main), Origin::H1] { + assert_eq!(read(&prompt, origin, path), failed(message)); + } + assert_eq!( + resolve_from_top(path, &prompt).unwrap_err().to_string(), + message + ); + } +} + +#[test] +fn the_functions_that_run_a_section_take_no_path_from_the_top() { + let prompt = prompt(NEIGHBORS); + let main = at(&prompt, "/Main"); + for origin in [Origin::Section(&main), Origin::H1] { + for path in ["/Sibling", "/Main/Kid", "/Missing"] { + assert_eq!( + run(&prompt, origin, path), + failed(&format!( + "section path \"{path}\" starts with \"/\"; only require, \ + prompt.section, and prompt.section_or take a path from the top" + )) + ); + } + } +} + +#[test] +fn require_takes_only_a_path_from_the_top() { + let prompt = prompt(NEIGHBORS); + assert_eq!( + path_from_top(&prompt, &at(&prompt, " / Main / Kid ")), + "/Main/Kid" + ); + for path in ["Sibling", "../Sibling", "Kid/Grand", "## Sibling", ".."] { + assert_eq!( + resolve_from_top(path, &prompt).unwrap_err().to_string(), + format!( + "section path \"{path}\" does not start with \"/\"; require takes a path from \ + the top, such as \"/helpers\"" + ) + ); + } + assert_eq!( + resolve_from_top("/Main/../Sibling", &prompt) + .unwrap_err() + .to_string(), + "section path \"/Main/../Sibling\" uses \"..\"; a path from the top takes none" + ); +} + +#[test] +fn a_malformed_path_is_refused_whatever_takes_it() { + let prompt = prompt(NEIGHBORS); + for (path, message) in [ + ("", "section path \"\" has an empty step"), + ("Kid/", "section path \"Kid/\" has an empty step"), + ("../", "section path \"../\" has an empty step"), + ( + "/Main//Grand", + "section path \"/Main//Grand\" has an empty step", + ), + ("//Grand", "section path \"//Grand\" has an empty step"), + ( + "Kid[1]", + "section path \"Kid[1]\" has an index; a path in a prompt takes none", + ), + ( + "/Main/Kid[-1]", + "section path \"/Main/Kid[-1]\" has an index; a path in a prompt takes none", + ), + ( + r"Kid\n", + r#"section path "Kid\n" has an unknown escape "\n"; the escapes are "\/", "\\", and "\[""#, + ), + ] { + assert_eq!(run(&prompt, Origin::H1, path), failed(message)); + assert_eq!( + find(path, &prompt, Origin::H1).unwrap_err().to_string(), + message + ); + assert_eq!( + resolve_from_top(path, &prompt).unwrap_err().to_string(), + message + ); + } +} + +#[test] +fn a_title_matches_exactly_and_whitespace_around_each_step_is_ignored() { + let prompt = prompt(NEIGHBORS); + let main = at(&prompt, "/Main"); + let origin = Origin::Section(&main); + assert_eq!(run(&prompt, origin, " Kid\t"), found("/Main/Kid")); + assert_eq!(run(&prompt, origin, " .. / Sibling "), found("/Sibling")); + assert_eq!( + read(&prompt, origin, " / Other / Niece "), + found("/Other/Niece") + ); + // The error shows the path without the whitespace around it. + assert_eq!( + run(&prompt, origin, " kid "), + failed("section `kid` not found: `/Main` has no child `kid`") + ); + assert_eq!( + run(&prompt, origin, "K id"), + failed("section `K id` not found: `/Main` has no child `K id`") + ); +} + +#[test] +fn a_name_that_starts_with_hash_marks_is_a_name_like_any_other() { + let prompt = prompt(NEIGHBORS); + let main = at(&prompt, "/Main"); + let origin = Origin::Section(&main); + let message = "section `### Kid` not found: `/Main` has no child `### Kid`"; + assert_eq!(run(&prompt, origin, "### Kid"), failed(message)); + assert_eq!(read(&prompt, origin, "### Kid"), failed(message)); +} + +#[test] +fn a_slash_and_a_trailing_bracketed_number_in_a_title_are_escaped() { + let prompt = prompt(concat!( + "## Input/Output\n\nx\n\n", + "### Appendix [2]\n\nx\n\n", + "## C:\\\\Users\n\nx\n", + )); + assert_eq!( + run(&prompt, Origin::H1, r"Input\/Output"), + found(r"/Input\/Output") + ); + assert_eq!( + read(&prompt, Origin::H1, r"/Input\/Output/Appendix \[2]"), + found(r"/Input\/Output/Appendix \[2]") + ); + assert_eq!( + read(&prompt, Origin::H1, r"C:\\Users"), + found(r"/C:\\Users") + ); + // Unescaped, the slash splits the title in two. + assert_eq!( + read(&prompt, Origin::H1, "/Input/Output"), + failed("section `/Input/Output` not found: the top level has no section `Input`") + ); + // The searched section is printed as a path that names it. + let io = at(&prompt, r"/Input\/Output"); + assert_eq!( + run(&prompt, Origin::Section(&io), "Missing"), + failed(r"section `Missing` not found: `/Input\/Output` has no child `Missing`") + ); +} diff --git a/crates/runvil-internal/runtime/src/section_path.rs b/crates/runvil-internal/runtime/src/section_path.rs new file mode 100644 index 000000000..34ac8aded --- /dev/null +++ b/crates/runvil-internal/runtime/src/section_path.rs @@ -0,0 +1,295 @@ +//! Section paths in a prompt: the forms a path takes, and the one lookup +//! every function that names a section resolves through. +//! +//! A section's Lua names another section by a path of titles. `name` is +//! the direct child with that title and `../name` is the sibling, both +//! counted from an [`Origin`], the section the path starts at. `/a/b` +//! starts at the top of the prompt and names one title per level, so it +//! names the same section from everywhere. The grammar of steps, escapes, +//! and trimming is `runvil_markdown::split`, which a path over parsed +//! text shares. +//! +//! Which forms a function takes is that function's rule, so the module +//! has one entry per rule: [`resolve_neighbor`] for the functions that run +//! a section, which take the two relative forms; [`find`] for the `prompt` +//! reads, which take all three; and [`resolve_from_top`] for `require`, +//! which takes only a path from the top. A well-formed path that names no +//! section is a miss, and its error says where the lookup looked and +//! lists nothing. + +use runvil_markdown::{Steps, escape_title, split}; + +use crate::prompt::{Prompt, Section, detail}; +use crate::section_pos::{SectionIndex, SectionPos}; +use crate::{Error, Result, error}; + +/// The section a relative path starts at. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Origin<'a> { + /// The H1, whose children are the top-level sections and which has no + /// siblings. + H1, + /// The section at this position. + Section(&'a SectionPos), +} + +impl Origin<'_> { + /// The direct child titled `title`. + fn child(self, prompt: &Prompt, title: &str) -> Option { + match self { + Origin::H1 => position(detail::sections(prompt), title, None).map(SectionPos::top), + Origin::Section(pos) => { + let children = pos.section(prompt)?.children(); + position(children, title, None).map(|index| pos.child(index)) + } + } + } + + /// The sibling titled `title`: another section under the same parent, + /// never the section itself. + fn sibling(self, prompt: &Prompt, title: &str) -> Option { + match self { + Origin::H1 => None, + Origin::Section(pos) => { + position(pos.siblings(prompt), title, Some(pos.index)).map(|index| SectionPos { + slice: pos.slice.clone(), + index, + }) + } + } + } +} + +/// The index of the section titled `title` in `slice`, passing over the +/// section at `skip`. Sibling titles are unique, so there is at most one. +fn position(slice: &[Section], title: &str, skip: Option) -> Option { + slice + .iter() + .enumerate() + .map(|(index, section)| (SectionIndex::new(index), section)) + .find(|(index, section)| Some(*index) != skip && section.name() == title) + .map(|(index, _)| index) +} + +/// What a well-formed path named. +#[derive(Debug)] +pub(crate) enum Lookup { + /// The section at this position. + Found(SectionPos), + /// No section. The error is the not-found error for the path. + Missing(Error), +} + +impl Lookup { + /// The section's position, for a function that must reach one. + /// + /// # Errors + /// Returns the not-found error of a miss. + pub(crate) fn required(self) -> Result { + match self { + Lookup::Found(pos) => Ok(pos), + Lookup::Missing(error) => Err(error), + } + } +} + +/// Resolves a path that names a neighbor of `origin`: `name` for a direct +/// child or `../name` for a sibling. These two forms are a section's +/// visible set, and they are all that `call`, `jump`, `tasks.spawn`, and +/// `tasks.fanout` take. +/// +/// # Errors +/// Returns a `LuaRuntime` failure when the path is malformed, starts with +/// `/`, is neither relative form, or names no section. +pub(crate) fn resolve_neighbor( + given: &str, + prompt: &Prompt, + origin: Origin<'_>, +) -> Result { + let path = titles(given)?; + if path.from_top { + return Err(error::make_lua_runtime_error(format!( + "section path \"{given}\" starts with \"/\"; only require, prompt.section, and \ + prompt.section_or take a path from the top" + ))); + } + neighbor(given, &path.titles, prompt, origin)?.required() +} + +/// Looks up a path a `prompt` read takes: either relative form from +/// `origin`, or a path from the top, which names any section. +/// +/// # Errors +/// Returns a `LuaRuntime` failure when the path is malformed, or is +/// relative and neither relative form. A path that names no section is a +/// [`Lookup::Missing`], not an error, so the caller decides whether a miss +/// raises. +pub(crate) fn find(given: &str, prompt: &Prompt, origin: Origin<'_>) -> Result { + let path = titles(given)?; + if path.from_top { + from_top(given, &path.titles, prompt) + } else { + neighbor(given, &path.titles, prompt, origin) + } +} + +/// Resolves a path from the top of the prompt, the only form `require` +/// takes. It names the same section wherever it is written. +/// +/// # Errors +/// Returns a `LuaRuntime` failure when the path is malformed, does not +/// start with `/`, or names no section. +pub(crate) fn resolve_from_top(given: &str, prompt: &Prompt) -> Result { + let path = titles(given)?; + if !path.from_top { + return Err(error::make_lua_runtime_error(format!( + "section path \"{given}\" does not start with \"/\"; require takes a path from the \ + top, such as \"/helpers\"" + ))); + } + from_top(given, &path.titles, prompt)?.required() +} + +/// The path from the top that names the section at `pos`, such as +/// `/Main/Kid`: how the Engine prints a section's address. +pub(crate) fn path_from_top(prompt: &Prompt, pos: &SectionPos) -> String { + let mut path = String::new(); + let mut slice = detail::sections(prompt); + for &index in pos.slice.indices().iter().chain([&pos.index.get()]) { + let Some(section) = slice.get(index) else { + break; + }; + path.push('/'); + path.push_str(&escape_title(section.name())); + slice = section.children(); + } + path +} + +/// A path's titles, after the checks every path in a prompt shares. +struct Titles { + /// Whether the path starts with `/`. + from_top: bool, + /// The title of each step, in order. There is at least one. + titles: Vec, +} + +/// Splits `given` into its titles. +/// +/// # Errors +/// Returns a `LuaRuntime` failure for a path the grammar refuses, and for +/// the two forms only a path over parsed text has: `//` and an index. +fn titles(given: &str) -> Result { + let Steps { from_top, steps } = split(given) + .map_err(|message| error::make_lua_runtime_error(format!("section {message}")))?; + if steps.iter().any(|step| step.deep) { + return Err(error::make_lua_runtime_error(format!( + "section path \"{given}\" has an empty step" + ))); + } + if steps.iter().any(|step| step.index.is_some()) { + return Err(error::make_lua_runtime_error(format!( + "section path \"{given}\" has an index; a path in a prompt takes none" + ))); + } + Ok(Titles { + from_top, + titles: steps.into_iter().map(|step| step.title).collect(), + }) +} + +/// What a lookup wanted of the section it searched. +#[derive(Clone, Copy)] +enum Wanted { + /// A direct child with the title. + Child, + /// A sibling with the title. + Sibling, +} + +/// Looks up the relative path whose titles are `titles` from `origin`. +/// +/// # Errors +/// Returns a `LuaRuntime` failure when the titles are neither `name` nor +/// `..` and a name. +fn neighbor(given: &str, titles: &[String], prompt: &Prompt, origin: Origin<'_>) -> Result { + let (wanted, title) = match titles { + [name] if name == ".." => { + return Err(error::make_lua_runtime_error(format!( + "section path \"{given}\" needs a name after \"..\"" + ))); + } + [name] => (Wanted::Child, name), + [parent, name] if parent == ".." && name != ".." => (Wanted::Sibling, name), + _ => { + return Err(error::make_lua_runtime_error(format!( + "section path \"{given}\" has more than one name; a relative path is a child's \ + name, or \"../\" and a sibling's name" + ))); + } + }; + let found = match wanted { + Wanted::Child => origin.child(prompt, title), + Wanted::Sibling => origin.sibling(prompt, title), + }; + Ok(match found { + Some(pos) => Lookup::Found(pos), + None => Lookup::Missing(make_not_found_error(given, prompt, origin, wanted, title)), + }) +} + +/// Looks up the path from the top whose titles are `titles`: one child +/// per step, starting from the top-level sections. +/// +/// # Errors +/// Returns a `LuaRuntime` failure when a title is `..`, and an `Internal` +/// failure for a path with no step. +fn from_top(given: &str, titles: &[String], prompt: &Prompt) -> Result { + if titles.iter().any(|title| title == "..") { + return Err(error::make_lua_runtime_error(format!( + "section path \"{given}\" uses \"..\"; a path from the top takes none" + ))); + } + let mut reached: Option = None; + for title in titles { + let origin = reached.as_ref().map_or(Origin::H1, Origin::Section); + let Some(pos) = origin.child(prompt, title) else { + // The walk stopped at `origin`, which has no such child. + let error = make_not_found_error(given, prompt, origin, Wanted::Child, title); + return Ok(Lookup::Missing(error)); + }; + reached = Some(pos); + } + reached + .map(Lookup::Found) + .ok_or(error::make_internal_error("a section path has no step")) +} + +/// The error for a path that names no section: the path as it was given, +/// the section that was searched, and what was wanted there. It lists no +/// other section, so the error cannot show the rest of the prompt's +/// structure. +fn make_not_found_error( + given: &str, + prompt: &Prompt, + searched: Origin<'_>, + wanted: Wanted, + title: &str, +) -> Error { + let looked = match (searched, wanted) { + (Origin::H1, Wanted::Child) => format!("the top level has no section `{title}`"), + (Origin::H1, Wanted::Sibling) => "the H1 has no siblings".to_owned(), + (Origin::Section(pos), Wanted::Child) => { + format!("`{}` has no child `{title}`", path_from_top(prompt, pos)) + } + (Origin::Section(pos), Wanted::Sibling) => { + format!("`{}` has no sibling `{title}`", path_from_top(prompt, pos)) + } + }; + + error::make_lua_runtime_error(format!("section `{}` not found: {looked}", given.trim())) +} + +#[cfg(test)] +#[path = "section_path-tests.rs"] +mod tests; diff --git a/crates/runvil-internal/runtime/src/section_pos.rs b/crates/runvil-internal/runtime/src/section_pos.rs new file mode 100644 index 000000000..31ba2d8b1 --- /dev/null +++ b/crates/runvil-internal/runtime/src/section_pos.rs @@ -0,0 +1,124 @@ +//! A section's place in the prompt tree. +//! +//! [`SectionPos`] names a section by its sibling slice, a [`SlicePath`], +//! and its [`SectionIndex`] in that slice. A chain and a section's lookups +//! find their slice by path and read it in place, so they borrow nothing +//! from the tree the run shares. + +use crate::prompt::{Prompt, Section, detail}; + +/// Where a sibling slice sits in the prompt tree: the index of each +/// ancestor section from the top level down to the slice's parent. The +/// empty path is the top-level slice. A chain names its walk position by +/// path so it borrows nothing from the tree the run shares through its +/// `Arc`; the path resolves to the slice on demand. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct SlicePath(Vec); + +impl SlicePath { + /// The top-level slice. + pub(crate) fn root() -> Self { + Self::default() + } + + /// The slice of `parent`'s children, where `parent` is the section at + /// `index` of this slice. + pub(crate) fn child(&self, index: SectionIndex) -> Self { + let mut path = self.0.clone(); + path.push(index.0); + Self(path) + } + + /// Each ancestor's index, from the top level down. + pub(crate) fn indices(&self) -> &[usize] { + &self.0 + } + + /// Resolves the path against `prompt`. A path the scheduler built is + /// always in range; an out-of-range index resolves to the empty slice, + /// which the walk treats as exhausted rather than panicking. + pub(crate) fn resolve<'p>(&self, prompt: &'p Prompt) -> &'p [Section] { + let mut slice = detail::sections(prompt); + for &index in &self.0 { + slice = slice.get(index).map_or(&[], Section::children); + } + slice + } +} + +/// A section's position in its sibling slice. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct SectionIndex(usize); + +impl SectionIndex { + /// The slice's first section. + pub(crate) const FIRST: SectionIndex = SectionIndex(0); + + /// The section at `index` in its slice. + pub(crate) fn new(index: usize) -> SectionIndex { + SectionIndex(index) + } + + /// The position as a slice index. + pub(crate) fn get(self) -> usize { + self.0 + } + + /// The section after this one in the same slice. + pub(crate) fn next(self) -> SectionIndex { + SectionIndex(self.0 + 1) + } +} + +/// A section's place in the prompt tree: the path of its sibling slice and +/// its index in that slice. A position the walk moves to may sit past its +/// slice's last section, where it names no section. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SectionPos { + /// The path of the section's sibling slice. + pub(crate) slice: SlicePath, + /// The section's index in that slice. + pub(crate) index: SectionIndex, +} + +impl SectionPos { + /// The top-level section at `index`. + pub(crate) fn top(index: SectionIndex) -> SectionPos { + SectionPos { + slice: SlicePath::root(), + index, + } + } + + /// The next sibling. + pub(crate) fn next(&self) -> SectionPos { + SectionPos { + slice: self.slice.clone(), + index: self.index.next(), + } + } + + /// This section's child at `index`. + pub(crate) fn child(&self, index: SectionIndex) -> SectionPos { + SectionPos { + slice: self.slice.child(self.index), + index, + } + } + + /// The section's sibling slice, resolved against `prompt`. + pub(crate) fn siblings<'p>(&self, prompt: &'p Prompt) -> &'p [Section] { + self.slice.resolve(prompt) + } + + /// The section, or `None` when the index is past its slice. + pub(crate) fn section<'p>(&self, prompt: &'p Prompt) -> Option<&'p Section> { + self.siblings(prompt).get(self.index.0) + } + + /// The section's name, or the prompt's title when the index is past + /// its slice: a chain's section name before its first entry. + pub(crate) fn name<'p>(&self, prompt: &'p Prompt) -> &'p str { + self.section(prompt).map_or(prompt.title(), Section::name) + } +} diff --git a/crates/runvil-internal/runtime/src/test_support/recording/forward-tests-variants.rs b/crates/runvil-internal/runtime/src/test_support/recording/forward-tests-variants.rs index 8d079fe87..1ebd73674 100644 --- a/crates/runvil-internal/runtime/src/test_support/recording/forward-tests-variants.rs +++ b/crates/runvil-internal/runtime/src/test_support/recording/forward-tests-variants.rs @@ -103,7 +103,7 @@ fn one_of_every_event_variant() -> Vec<(Event, Seam)> { Event::LuaLibraryLoadStarted { section: "A".to_owned(), provenance: provenance(), - library: "## lib".to_owned(), + library: "/lib".to_owned(), }, Seam::Observed, ), @@ -111,7 +111,7 @@ fn one_of_every_event_variant() -> Vec<(Event, Seam)> { Event::LuaLibraryLoadSucceeded { section: "A".to_owned(), provenance: provenance(), - library: "## lib".to_owned(), + library: "/lib".to_owned(), }, Seam::Observed, ), @@ -119,7 +119,7 @@ fn one_of_every_event_variant() -> Vec<(Event, Seam)> { Event::LuaLibraryLoadFailed { section: "A".to_owned(), provenance: provenance(), - library: "## lib".to_owned(), + library: "/lib".to_owned(), }, Seam::Observed, ), diff --git a/crates/runvil-internal/runtime/src/test_support/recording/forward-tests.rs b/crates/runvil-internal/runtime/src/test_support/recording/forward-tests.rs index 29667878a..f7c00107a 100644 --- a/crates/runvil-internal/runtime/src/test_support/recording/forward-tests.rs +++ b/crates/runvil-internal/runtime/src/test_support/recording/forward-tests.rs @@ -251,17 +251,17 @@ fn a_library_load_forwards_its_library_to_the_observer() { Event::LuaLibraryLoadStarted { section: "A".to_owned(), provenance: provenance(), - library: "## shared_stuff".to_owned(), + library: "/shared_stuff".to_owned(), }, Event::LuaLibraryLoadSucceeded { section: "A".to_owned(), provenance: provenance(), - library: "## shared_stuff".to_owned(), + library: "/shared_stuff".to_owned(), }, Event::LuaLibraryLoadFailed { section: "B".to_owned(), provenance: provenance(), - library: "## helpers".to_owned(), + library: "/helpers".to_owned(), }, ], &recorder, @@ -271,15 +271,15 @@ fn a_library_load_forwards_its_library_to_the_observer() { vec![ ( "A".to_owned(), - "Lua library load started: ## shared_stuff".to_owned() + "Lua library load started: /shared_stuff".to_owned() ), ( "A".to_owned(), - "Lua library load succeeded: ## shared_stuff".to_owned() + "Lua library load succeeded: /shared_stuff".to_owned() ), ( "B".to_owned(), - "Lua library load failed: ## helpers".to_owned() + "Lua library load failed: /helpers".to_owned() ), ], "each phase reaches its own observation with the library it named" diff --git a/crates/runvil-internal/runtime/src/test_support/recording/observation.rs b/crates/runvil-internal/runtime/src/test_support/recording/observation.rs index 4ecf6066b..9c2fb7882 100644 --- a/crates/runvil-internal/runtime/src/test_support/recording/observation.rs +++ b/crates/runvil-internal/runtime/src/test_support/recording/observation.rs @@ -103,17 +103,17 @@ pub(crate) enum Observation { ToolCallFailed, /// A section VM began loading a library section. LuaLibraryLoadStarted { - /// The library section's heading with its markers. + /// The library section's path from the top of the prompt. library: String, }, /// A section VM loaded a library section and ran its blocks. LuaLibraryLoadSucceeded { - /// The library section's heading with its markers. + /// The library section's path from the top of the prompt. library: String, }, /// A section VM failed to load a library section or run its blocks. LuaLibraryLoadFailed { - /// The library section's heading with its markers. + /// The library section's path from the top of the prompt. library: String, }, /// A section VM began executing a Lua chunk. diff --git a/crates/runvil-internal/runtime/tests/prompts/execution/fanout-arm-failure.md b/crates/runvil-internal/runtime/tests/prompts/execution/fanout-arm-failure.md index 5bbe203b1..5c30f2d36 100644 --- a/crates/runvil-internal/runtime/tests/prompts/execution/fanout-arm-failure.md +++ b/crates/runvil-internal/runtime/tests/prompts/execution/fanout-arm-failure.md @@ -9,7 +9,7 @@ runvil: 0 ## Research ```lua -local replies = tasks.fanout("### Worker", prompt.list("### Topics")) +local replies = tasks.fanout("Worker", prompt.section("Topics", "list").items) return table.concat(replies, "\n") ``` diff --git a/crates/runvil-internal/runtime/tests/prompts/execution/fanout-basic.md b/crates/runvil-internal/runtime/tests/prompts/execution/fanout-basic.md index 6b5a15ad0..de68d892c 100644 --- a/crates/runvil-internal/runtime/tests/prompts/execution/fanout-basic.md +++ b/crates/runvil-internal/runtime/tests/prompts/execution/fanout-basic.md @@ -9,7 +9,7 @@ runvil: 0 ## Research ```lua -local replies = tasks.fanout("### Worker", prompt.list("### Topics")) +local replies = tasks.fanout("Worker", prompt.section("Topics", "list").items) return table.concat(replies, "\n") ``` diff --git a/crates/runvil-internal/runtime/tests/prompts/execution/fanout-cross-arm-append.md b/crates/runvil-internal/runtime/tests/prompts/execution/fanout-cross-arm-append.md index 0cc3d1393..3ef7865bd 100644 --- a/crates/runvil-internal/runtime/tests/prompts/execution/fanout-cross-arm-append.md +++ b/crates/runvil-internal/runtime/tests/prompts/execution/fanout-cross-arm-append.md @@ -9,7 +9,7 @@ runvil: 0 ## Research ```lua -local replies = tasks.fanout("### Worker", {"alpha", "beta"}) +local replies = tasks.fanout("Worker", {"alpha", "beta"}) return table.concat(replies, ",") ``` diff --git a/crates/runvil-internal/runtime/tests/prompts/execution/fanout-epilog.md b/crates/runvil-internal/runtime/tests/prompts/execution/fanout-epilog.md index 7c001dfe3..ccd5bfe1f 100644 --- a/crates/runvil-internal/runtime/tests/prompts/execution/fanout-epilog.md +++ b/crates/runvil-internal/runtime/tests/prompts/execution/fanout-epilog.md @@ -12,7 +12,7 @@ runvil: 0 ``` ```lua -local replies = tasks.fanout("### Worker", prompt.list("### Items")) +local replies = tasks.fanout("Worker", prompt.section("Items", "list").items) return table.concat(replies, ",") ``` diff --git a/crates/runvil-internal/runtime/tests/prompts/execution/fanout-store-writes.md b/crates/runvil-internal/runtime/tests/prompts/execution/fanout-store-writes.md index 9b3223de9..13f34e9df 100644 --- a/crates/runvil-internal/runtime/tests/prompts/execution/fanout-store-writes.md +++ b/crates/runvil-internal/runtime/tests/prompts/execution/fanout-store-writes.md @@ -9,7 +9,7 @@ runvil: 0 ## Research ```lua -local replies = tasks.fanout("### Worker", prompt.list("### Topics")) +local replies = tasks.fanout("Worker", prompt.section("Topics", "list").items) local files = store.glob("arm-*.md") -- The ordered merge: the join delivers arm results in collection order, -- never finish order, so the parent's merge is deterministic by diff --git a/crates/runvil-internal/runtime/tests/prompts/valid/require-library.md b/crates/runvil-internal/runtime/tests/prompts/valid/require-library.md index 606033902..722153a60 100644 --- a/crates/runvil-internal/runtime/tests/prompts/valid/require-library.md +++ b/crates/runvil-internal/runtime/tests/prompts/valid/require-library.md @@ -13,7 +13,7 @@ The helper library is available to each section that requires it. Normalize the supplied subject. ```lua -require '## helpers' +require '/helpers' var.subject = normalize(args) ``` @@ -26,7 +26,7 @@ This nested prose remains attached to Prepare. Return the normalized subject. ```lua -require '## helpers' +require '/helpers' return normalize(var.subject) ``` diff --git a/crates/runvil-internal/types/src/emitter.rs b/crates/runvil-internal/types/src/emitter.rs index 0839dd9db..1d2f09271 100644 --- a/crates/runvil-internal/types/src/emitter.rs +++ b/crates/runvil-internal/types/src/emitter.rs @@ -190,7 +190,7 @@ impl Emitter { } /// Reports one boundary of loading `library`, the library section's - /// heading with its markers, into `section`'s environment. + /// path from the top of the prompt, into `section`'s environment. pub fn library_load(&self, section: &str, library: &str, phase: LibraryLoad) { let library = library.to_owned(); self.emit(section, |section, provenance| match phase { @@ -470,9 +470,9 @@ mod tests { fn a_library_load_report_stamps_the_coordinates_and_the_library() { let sink = EventSink::default(); let walk = emitter(&sink, root()); - walk.library_load("Main", "## shared_stuff", LibraryLoad::Started); - walk.library_load("Main", "## shared_stuff", LibraryLoad::Succeeded); - walk.library_load("Other", "## helpers", LibraryLoad::Failed); + walk.library_load("Main", "/shared_stuff", LibraryLoad::Started); + walk.library_load("Main", "/shared_stuff", LibraryLoad::Succeeded); + walk.library_load("Other", "/helpers", LibraryLoad::Failed); let stamp = |seq| Provenance::new(root(), seq); assert_eq!( sink.take(), @@ -480,17 +480,17 @@ mod tests { Event::LuaLibraryLoadStarted { section: "Main".to_owned(), provenance: stamp(0), - library: "## shared_stuff".to_owned(), + library: "/shared_stuff".to_owned(), }, Event::LuaLibraryLoadSucceeded { section: "Main".to_owned(), provenance: stamp(1), - library: "## shared_stuff".to_owned(), + library: "/shared_stuff".to_owned(), }, Event::LuaLibraryLoadFailed { section: "Other".to_owned(), provenance: stamp(2), - library: "## helpers".to_owned(), + library: "/helpers".to_owned(), }, ], "each phase builds its own variant under the loading section" diff --git a/crates/runvil-internal/types/src/error-tests.rs b/crates/runvil-internal/types/src/error-tests.rs index 01897e619..feaf4b5da 100644 --- a/crates/runvil-internal/types/src/error-tests.rs +++ b/crates/runvil-internal/types/src/error-tests.rs @@ -88,11 +88,10 @@ fn an_error_is_a_cancel_exactly_when_its_code_is_canceled() { } /// Every code, in declaration order. -const ALL: [ErrorCode; 69] = [ +const ALL: [ErrorCode; 68] = [ ErrorCode::FrontmatterInvalid, ErrorCode::StructureInvalid, ErrorCode::FenceInvalid, - ErrorCode::ListEmptyItem, ErrorCode::UnsupportedVersion, ErrorCode::InvalidValue, ErrorCode::LuaRuntime, @@ -167,7 +166,6 @@ fn family(code: ErrorCode) -> u16 { ErrorCode::FrontmatterInvalid | ErrorCode::StructureInvalid | ErrorCode::FenceInvalid - | ErrorCode::ListEmptyItem | ErrorCode::UnsupportedVersion | ErrorCode::InvalidValue => 1, ErrorCode::LuaRuntime diff --git a/crates/runvil-internal/types/src/error.rs b/crates/runvil-internal/types/src/error.rs index 7feb90fbf..68e8e3a05 100644 --- a/crates/runvil-internal/types/src/error.rs +++ b/crates/runvil-internal/types/src/error.rs @@ -114,10 +114,7 @@ impl Error { /// parts. fn location(error: &Error) -> Option { match error.code { - ErrorCode::FrontmatterInvalid - | ErrorCode::StructureInvalid - | ErrorCode::FenceInvalid - | ErrorCode::ListEmptyItem => { + ErrorCode::FrontmatterInvalid | ErrorCode::StructureInvalid | ErrorCode::FenceInvalid => { let parts: Vec = [ error .facts @@ -299,8 +296,6 @@ pub enum ErrorCode { StructureInvalid = 102, /// A `lua` fence is unclosed or breaks the strict fence rule. FenceInvalid = 103, - /// A list section has an empty item. - ListEmptyItem = 104, /// The prompt declares a `runvil:` major this build does not support. UnsupportedVersion = 105, /// A value is missing, or failed to parse or validate, such as an id, a @@ -462,7 +457,6 @@ impl ErrorCode { ErrorCode::FrontmatterInvalid => "frontmatter_invalid", ErrorCode::StructureInvalid => "structure_invalid", ErrorCode::FenceInvalid => "fence_invalid", - ErrorCode::ListEmptyItem => "list_empty_item", ErrorCode::UnsupportedVersion => "unsupported_version", ErrorCode::InvalidValue => "invalid_value", ErrorCode::LuaRuntime => "lua_runtime", @@ -539,7 +533,6 @@ impl ErrorCode { "frontmatter_invalid" => ErrorCode::FrontmatterInvalid, "structure_invalid" => ErrorCode::StructureInvalid, "fence_invalid" => ErrorCode::FenceInvalid, - "list_empty_item" => ErrorCode::ListEmptyItem, "unsupported_version" => ErrorCode::UnsupportedVersion, "invalid_value" => ErrorCode::InvalidValue, "lua_runtime" => ErrorCode::LuaRuntime, @@ -635,9 +628,6 @@ impl ErrorCode { "A Lua code fence is malformed. Open it with exactly ```lua and close it with \ exactly ```." } - ErrorCode::ListEmptyItem => { - "A list section has an empty item. Fill it in or remove it." - } ErrorCode::UnsupportedVersion => { "The prompt declares a `runvil:` version this build does not support. Declare a \ version it supports." diff --git a/crates/runvil-internal/types/src/event-tests-library.rs b/crates/runvil-internal/types/src/event-tests-library.rs index c2eb1aeb0..90ff306e5 100644 --- a/crates/runvil-internal/types/src/event-tests-library.rs +++ b/crates/runvil-internal/types/src/event-tests-library.rs @@ -1,15 +1,15 @@ //! Tests for the library-load variants' serialized form: each carries the -//! library's heading beside the two coordinates, on one line. +//! library's path beside the two coordinates, on one line. use super::*; -/// The coordinates and library of loading `## shared_stuff` into `Main`'s +/// The coordinates and library of loading `/shared_stuff` into `Main`'s /// environment, stamped `seq`. fn coordinates(seq: u32) -> (String, Provenance, String) { ( "Main".to_owned(), provenance("0", seq), - "## shared_stuff".to_owned(), + "/shared_stuff".to_owned(), ) } @@ -48,7 +48,7 @@ fn a_library_load_serializes_its_library_beside_the_coordinates() { assert_eq!( serde_json::to_string(event).expect("an event serializes"), format!( - r###"{{"kind":"{kind}","section":"Main","provenance":{{"task":"0","seq":{seq}}},"library":"## shared_stuff"}}"### + r#"{{"kind":"{kind}","section":"Main","provenance":{{"task":"0","seq":{seq}}},"library":"/shared_stuff"}}"# ) ); round_trips(event); diff --git a/crates/runvil-internal/types/src/event.rs b/crates/runvil-internal/types/src/event.rs index 5124d8121..937181fab 100644 --- a/crates/runvil-internal/types/src/event.rs +++ b/crates/runvil-internal/types/src/event.rs @@ -20,9 +20,9 @@ //! //! # Sensitivity //! Lifecycle variants have no payload beyond their coordinates and, for -//! the library loads, the library's heading, all author-controlled -//! (`section` and `library` are prompt-authored heading text); the -//! exception is +//! the library loads, the library's path, all author-controlled +//! (`section` is a heading's text and `library` a path made of them); +//! the exception is //! `ModelMetadataDegraded`, whose message may quote values from a //! backend's response. Content variants hold model-, tool-, or //! user-authored text; task variants hold the author's spawn seeds; debug @@ -181,19 +181,19 @@ events! { /// A section VM began loading a library section into the reporting /// section's environment. LuaLibraryLoadStarted { - /// The library section's heading with its markers, such as - /// `## shared_stuff`. + /// The library section's path from the top of the prompt, + /// such as `/shared_stuff`. library: String, }, /// A section VM loaded a library section and ran its blocks /// successfully. LuaLibraryLoadSucceeded { - /// The library section's heading with its markers. + /// The library section's path from the top of the prompt. library: String, }, /// A section VM failed to load a library section or run its blocks. LuaLibraryLoadFailed { - /// The library section's heading with its markers. + /// The library section's path from the top of the prompt. library: String, }, /// A section VM began executing a Lua chunk. diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index f877a8b58..ae4cd8587 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -676,7 +676,6 @@ pub runvil::ErrorCode::InvalidRange = 608 pub runvil::ErrorCode::InvalidValue = 106 pub runvil::ErrorCode::Io = 618 pub runvil::ErrorCode::IsADirectory = 604 -pub runvil::ErrorCode::ListEmptyItem = 104 pub runvil::ErrorCode::LuaCompile = 202 pub runvil::ErrorCode::LuaQuota = 203 pub runvil::ErrorCode::LuaRuntime = 201 diff --git a/crates/workshop/server/src/error.rs b/crates/workshop/server/src/error.rs index 54c854d06..2d324664a 100644 --- a/crates/workshop/server/src/error.rs +++ b/crates/workshop/server/src/error.rs @@ -79,7 +79,6 @@ impl AppError { ErrorCode::FrontmatterInvalid => "parse_frontmatter", ErrorCode::StructureInvalid => "parse_structure", ErrorCode::FenceInvalid => "parse_fence", - ErrorCode::ListEmptyItem => "parse_list", ErrorCode::LuaCompile => "parse_lua", ErrorCode::Internal => "parse_internal", // Any other code has no wire code of its own. diff --git a/plan/2026-11-12-1-exact-markdown-source.plan.md b/plan/2026-11-12-1-exact-markdown-source.plan.md new file mode 100644 index 000000000..c1092d012 --- /dev/null +++ b/plan/2026-11-12-1-exact-markdown-source.plan.md @@ -0,0 +1,167 @@ +--- +name: Exact Markdown Source +overview: Every parsed block keeps the exact text it came from, the blank lines after it included, so joining the blocks gives back the document. `markdown.render` becomes that join plus two spacing rules, the writer that rebuilt text in a fixed style is deleted, and the guide and design are brought in line. +todos: + - id: plan-copy + content: "runvil: on `master`, copy this plan to `plan/2026-11-12-1-exact-markdown-source.plan.md` before any other change" + status: completed + - id: markdown-crate + content: "runvil-markdown: `source` on every block, blank lines given to the block above, the `*` selector, the fixed-style writer replaced by a writer with two spacing rules; corpus, generated, edge, spacing, and regression tests" + status: completed + - id: lua-global + content: "runvil-lua: tables carry `source`, `markdown.render` feeds the crate's writer, `*` in the lookups and the prompt reads, the field reader deleted; tests updated and added" + status: completed + - id: runtime-tests + content: "runvil-runtime: a stored file parses and renders back equal, also through `var`" + status: completed + - id: verify + content: "runvil: run every check in AGENTS.md" + status: completed + - id: design-docs + content: "runvil-design: bring the design document and the language spec in line, one commit" + status: completed + - id: guide + content: "runvil-docs: bring the guide's markdown section, selector table, and quick reference in line with examples run against the build, one commit" + status: completed + - id: commit + content: "runvil: mark the plan copy's todos done, record in it what the build found, and make the one commit on `master` with the `Plan:` trailer" + status: completed +isProject: false +--- + +# Exact source for parsed Markdown + +## The design + +- **`source` on every block.** It holds the exact lines the block was parsed from, each with its line ending. +- **Blank lines belong to the block above them.** A block's `source` runs from its first line to the first line of the next block. So a section's `source` is its heading and the blank lines after it. Blank lines at the very top of the text belong to the first block. A blank line holds only spaces and tabs. +- **The mapping is total.** Joining every `source` in file order, a section before the blocks under it, gives back the text. So `markdown.render(markdown.parse(text)) == text`. The one exception is text that holds nothing but blank lines: it has no block, so it parses to an empty list, as today. +- **`markdown.render` writes each block's `source`** and rebuilds nothing. The writer that rebuilt text from `title`, `items`, and `text` is deleted. Renumbered lists and the review finding about fences go with it. +- **The flat view is `markdown.select(blocks, "*")`.** `*` matches every block. It returns a new array of the same block tables, in file order. +- **Plain, editable data, as today.** `kind`, `line`, `title`, `text`, `items`, `lang`, `info`, and `data` do not change, and no kind is added. + +````lua +-- "# Fruit\n\nSome words.\n\n- apples\n- pears\n\n## Citrus\n\n### Lemon\n" +{ kind = "section", title = "Fruit", source = "# Fruit\n\n", blocks = { + { kind = "prose", text = "Some words.", source = "Some words.\n\n" }, + { kind = "list", items = { "apples", "pears" }, source = "- apples\n- pears\n\n" }, + { kind = "section", title = "Citrus", source = "## Citrus\n\n", blocks = { + { kind = "section", title = "Lemon", source = "### Lemon\n", blocks = {} }, + } }, +} } +```` + +Decisions I made where you left room: + +- **Byte order mark and line endings stay as today:** the mark is dropped and `\r\n` and `\r` are read as `\n`. The join is the text with those two changes. This needs no new code and keeps every string in a parse `\n`-only. +- **`line` keeps its meaning:** the heading line, the opening fence line, the first marker, the first non-blank line of prose. That holds for a first block with blank lines above it too. + +## The spacing `render` adds + +`render` writes a line ending of its own in two cases. Neither can occur in an untouched parse, so `render(parse(text))` stays exact. + +1. **Every block starts on a new line.** When what is written so far does not end with a line ending, one is written before the next block. In a parse only the last block can lack one. +2. **A blank line sits between two prose blocks.** When a prose block follows a prose block and what is written so far does not end with a blank line, one is written. In a parse two prose blocks are never neighbors, because prose is a maximal run. + +These cover the last block of a text placed before another block, a `source` edited without its line ending, and two paragraphs that would otherwise read as one. + +`render` does not decide in general whether two blocks need a blank line, because that depends on their text. A paragraph directly above `- a` needs none. Directly above `2. b` it swallows the list, and directly above `---` it becomes a heading. Adding a blank line everywhere would change text written without one, such as a sentence directly above its list, which is common. So these stay the author's to get right: + +- An edited `source` that drops its blank line before a `---` rule, before a list that does not start at 1, or after a list. +- Two lists with the same marker, which read as one list with or without a blank line between them. + +A later version could ask the parser about each such pair. That would be a change to `render` alone, with no change to the data. + +## What this changes for an author + +- `markdown.render(section.blocks)` is the section's body from its first line of content, with the blank lines that followed it in the text, such as `"Short and plain.\n\n"`. A block's `text` is still trimmed. +- Editing `title`, `items`, or `text` changes nothing that is written. Editing `source` does. +- A block built by hand needs `source`. Without it, `render` raises `markdown.render: blocks[2].source must be a string, got nil`. +- A section's `source` holds the blank lines after its heading, so a replacement heading is written `"## New\n\n"`. +- Counting by position does not change, because no block is added for blank lines. +- `table.concat` over the sources of the flat view is the raw join. `render` differs from it only where it adds a line ending. +- `markdown.render(markdown.select(md, "*"))` writes nested blocks twice, because `render` goes into each section itself. `markdown.render(md)` is the one-call form. +- A parse holds each block's text twice, in `source` and in its fields. + +## The plan copy + +- First thing, on `master` of `runvil`, this plan is copied to `plan/2026-11-12-1-exact-markdown-source.plan.md`, named as that directory's other plans are. +- The copy is part of the one commit. A later commit could not update it, so before the commit its todos are marked done and it gains a section on what the build found, if anything differed from this plan. +- The commit message ends with `Plan: /plan/2026-11-12-1-exact-markdown-source.plan.md`, the trailer `runvil`'s commits use. + +## Code: one commit in `runvil`, on #210's branch + +`runvil-markdown`: + +- [block.rs](../crates/runvil-internal/markdown/src/block.rs): `source: String` on every variant, and `Block::source()`. +- [parse/flat.rs](../crates/runvil-internal/markdown/src/parse/flat.rs): the one real change in the parser. A block starts at the first line of its construct, or at the first non-blank line of the prose between two constructs, and its `source` runs to the start of the next block. The first block also takes the blank lines above it. The ranges are consecutive, so the mapping is total by construction. +- [parse/frontmatter.rs](../crates/runvil-internal/markdown/src/parse/frontmatter.rs), [parse/tree.rs](../crates/runvil-internal/markdown/src/parse/tree.rs), [parse.rs](../crates/runvil-internal/markdown/src/parse.rs): the frontmatter's range grows over the blank lines after it, and a heading's `source` is carried into its section. +- [selector.rs](../crates/runvil-internal/markdown/src/selector.rs): `Selector::Any` for `*`. The unknown-kind message names `*`. +- [render.rs](../crates/runvil-internal/markdown/src/render.rs): rewritten, from about 150 lines to about 40. It holds a small writer that takes one block's kind and `source` at a time and applies the two spacing rules, and `render` over blocks built on it. The Lua global feeds the same writer, so the rules live in one place. +- [lib.rs](../crates/runvil-internal/markdown/src/lib.rs) states the invariant: the sources of a parse join back into the text. + +`runvil-lua`: + +- [markdown/blocks.rs](../crates/runvil-internal/lua/src/markdown/blocks.rs): each table gets `source`. The reader that checked every field for the old writer is deleted, about 130 lines. +- [markdown/lookup.rs](../crates/runvil-internal/lua/src/markdown/lookup.rs) and [markdown.rs](../crates/runvil-internal/lua/src/markdown.rs): `render` walks the tables under the same depth, cycle, and size limits as the lookups, and feeds each block's `kind` and `source` to the crate's writer. `*` matches a block whatever its `kind`. +- [prompt_reads.rs](../crates/runvil-internal/lua/src/prompt_reads.rs): no logic change. The block `prompt.section(path, selector)` returns carries `source`, and `*` works there. + +## Tests + +`runvil-markdown`: + +- **The join, over the corpus.** For the guide sample and all 75 texts of `BODIES`, each also with `\r\n` endings and with a byte order mark, `render` of the parse is the text. Checked for `parse` and `parse_body`. Text of only blank lines is checked as the stated exception. +- **The join, over generated text.** 2,000 documents assembled from line fragments by a fixed seed that a failure prints: headings of both forms, blank runs with spaces and tabs, paragraphs, open and closed fences, lists of every marker and start number, rules, block quotes, HTML, and frontmatter look-alikes. +- **The shape rules, on both sets.** Every `source` is whole lines. Only the first block's `source` can start with a blank line. Every block but the last ends with a line ending, and no two prose blocks are neighbors. Those last two facts are what make the spacing rules safe. +- **One case per edge:** blank lines after each kind of block, after a heading, between two headings, at the top, at the end, only blank lines, no final line ending, and after frontmatter. +- **The spacing rules.** Each fires where it should: the last block of a text before another block, a `source` with no line ending, two prose blocks with no blank line between, and a section whose last block is prose followed by prose. Neither fires where it should not: two prose blocks that already have a blank line, prose directly above a list, a heading directly above prose, and two lists. +- **What the fields lose stays in `source`:** `3.` and `4.`, `5)`, `*` bullets, `~~~`, an underlined heading, `___`, frontmatter comments and key order, and `## See [API](x)`. +- **The review finding:** a `~~~` fence whose label is a tilde and a backtick, followed by a heading and a paragraph, joins back and the later blocks survive. +- **Selectors:** `*` and the messages. + +`runvil-lua` and `runvil-runtime`: + +- `markdown.render(markdown.parse(text)) == text` for each sample, and `table.concat` over the sources of `markdown.select(md, "*")` gives the same string. +- `render` reads `source`, and `kind` only for the spacing rules: an edit of `source` is written, an edit of `title` or `items` is not, a hand-built block with `source` is written, and a missing `source` raises and names the nested block. +- The spacing rules through the Lua global: a document with its sections reordered, a selection of prose blocks, and an edited `source`. +- The join survives `var` and a `json.encode` and `json.decode` round trip. +- The refusals that remain: not a list, a section given as a list, an item that is no table, a list that contains itself, too deep, too large. +- In a running prompt: a stored file is parsed and rendered back equal to the file, in the same section and in a later one through `var`. +- Existing tests that pin JSON shapes or the old writer are updated or removed with it. Tests that index by position keep their indices. + +## Documents: one commit each + +They are written once the code passes its checks, and committed in their own repositories before the `runvil` commit, so the plan copy can record them as done. They are pushed after it. + +- **`runvil-design`:** [the design](https://github.com/cppalliance/runvil-design/blob/master/design/2026-11-11-1-markdown-and-prompt-reads.md) (block model, parsing rules, worked example, edge cases, selectors, `render`, identity, bytes copied, tests to carry over) and [the language spec](https://github.com/cppalliance/runvil-design/blob/master/design/architect-runvil-prompting-language.md). The design is a dated record, so the fixed-style writer and a separate kind for blank lines go under rejected alternatives with their reasons, and the general spacing rule goes under open questions. The spec describes the language as it is. +- **`runvil-docs`:** the `markdown` section of [05-lua-environment.md](https://github.com/cppalliance/runvil-docs/blob/master/src/language/05-lua-environment.md), the selector table in [03-blocks-and-prose.md](https://github.com/cppalliance/runvil-docs/blob/master/src/language/03-blocks-and-prose.md), and two rows of [17-quick-reference.md](https://github.com/cppalliance/runvil-docs/blob/master/src/language/17-quick-reference.md). Every changed example value comes from a run of the built code. + +## Git and pull requests + +These steps follow the commit, in this order. They are not todos, because the plan copy is committed before they happen. + +1. The commit goes on `master` of your fork, which is the head of [#210](https://github.com/cppalliance/runvil/pull/210), and is pushed there. +2. `bash-pack` is rebased onto it, the Workshop checks run again, and it is pushed with `--force-with-lease`, which updates [#211](https://github.com/cppalliance/runvil/pull/211). Your checkout is left on `bash-pack`, as it is now. +3. `runvil-docs` and `runvil-design` are pushed to `master`. In `runvil-design` that also publishes your unpushed commit `cc16539`, "Add Why Runvil two pagers". +4. I watch CI on both pull requests, then reply on [the review thread](https://github.com/cppalliance/runvil/pull/211#discussion_r4549082619) with the commit and resolve it. + +If CI on #210 finds something, the fix is amended into the same commit and `master` is pushed with `--force-with-lease`, so #210 still gains one commit. `bash-pack` is then rebased and pushed again. + +## Verification + +The commands in `runvil`'s `AGENTS.md`, all of them, before the commit: format, both clippy sets, both nextest sets, docs, `cargo test -p build-xtask`, the wording check, `xtask api --check`, and dylint. + +## What the build found + +The design held. These points differ from the plan above, or add to it. + +- **A list ends with its last item.** `pulldown-cmark` ends a list where the next block starts. That is past any link definition that follows the list, and past the indentation of the next paragraph. It ends a list item where the item ends. Before this change a link definition after a list was in no field of any block, and with sources it made the prose after a list start in the middle of a line. The range of a list is now cut to the end of its last item, in `parse/events.rs`. So `- a`, a blank line, `[ref]: http://x`, and `text` parse to a list and one prose block that holds the last two lines, where the prose held only `text` before. This changes what the fields hold, and the plan did not name it. +- **Each source is checked alone.** The plan listed the shape rules. The tests check one more thing: a block's `source`, parsed alone, is that one block again. That check is what found the list ranges. Two cases are left out of it, and each has a test that states it. A title may use a link that the rest of the text defines. And an underlined heading right below a link definition continues a paragraph, so its lines alone can be another block. In that same place the library reads a line of spaces above `---` as the heading's text, so that one `source` starts with a blank line. The cases were found by running the checks once over generated texts by the million. The final checks passed over 1.5 million texts from three seeds, and the committed tests run 2,000 from one. +- **The second spacing rule looks at both sides.** A blank line is added between two prose blocks only when neither the text so far ends with one nor the block starts with one. The plan named only the first. +- **A block with an empty `source` writes nothing.** The blocks under a section with an empty `source` are still written, so such a section groups blocks. +- **`render` asks little of a block.** It needs `source` to be a string of valid UTF-8. A section with no `blocks` field holds nothing, as it does for a lookup, and a block with no `kind` is written like any other. +- **`*` matches a block with no `kind`.** A lookup used to skip a block whose `kind` is not a string. It now reads such a block as one of no kind, which only `*` matches, so the flat view holds every block `render` writes. +- **The unknown-kind message** now ends `kinds are frontmatter, section, prose, fence, list, rule, and * selects every block`. +- **Sizes.** The sample texts are the guide and 74 bodies, 75 in all, where the plan said 75 bodies. `render.rs` is 98 lines with its comments, where the plan said about 40. The field reader removed from `runvil-lua` was about 140 lines. +- **Verification.** Every check listed passed. One Workshop test, `gateway::tests::recovery::successful_publication_disarms_late_child_cleanup`, failed once in the first run of its set, then passed alone and in a second run of the set. It starts a child process, and this change touches nothing it uses. +- **Documents.** `runvil-design` commit `f1b91cf` and `runvil-docs` commit `8129065` hold the document changes.