diff --git a/.gitignore b/.gitignore index f7e5eb4..5089af9 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,13 @@ /M*-AUDIT.md /M*-API-SURVEY.md /M*-SHIP-GATE.md +/M*-FRAMING.md /V*-PREREQUISITES.md /MANUAL-TEST-CHECKLIST.md /tests/INDEX.md /.claude/ + +# F8b SSH-transport diagnostic probes (M10.11). Kept local for now — +# v0.2 breadth work (V0.2-PREREQUISITES.md SP-5-adjacent / F8b n=1) +# reuses these. Revisit relocating to a tracked tools/ dir at v0.2. +/f8b-*.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 44b3b60..6a92f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +> Scope note: this section currently records only release-affecting +> changes surfaced during M10 ship-gate work. The full M6–M10 v1.0 +> changelog body is a separate release-authoring task and is not yet +> written here. + +### Changed + +- **SSH remote attach now carries the wire protocol over the SSH + *stderr* channel by default** (previously stdout). On at least one + tested environment (`OpenSSH_10.3p1`), a non-PTY SSH session does + not forward a long-lived remote process's stdout until that process + exits, while stderr streams in real time; the `--daemon-attach` + bridge is exactly such a process, so a stdout-carried protocol hung + indefinitely (the daemon and bridge were correct throughout). The + default flip resolves this with no user action required. Override + per invocation with `PMACS_ATTACH_SSH_PROTOCOL=stdout|stderr` (the + legacy `PMACS_ATTACH_SSH_PROTOCOL_STDERR=0/1` is still honored). + Breadth is currently a single environment; the default is a + one-line internal switch so it can be revisited without downstream + impact. + +### Fixed + +- SSH remote attach no longer fails with a host-side + `send Hello failed: Broken pipe`: the daemon-liveness probe in the + `--daemon-attach` auto-start path was a disposable connect that + could consume the daemon's server-speaks-first `Hello`; the + established connection is now reused (regression-tested). + ## [0.1.0] --- 2026-05-03 First public preview. Solo development through 1.0; public contributions diff --git a/audit/audit-rules.scm b/audit/audit-rules.scm index ef10488..76239dc 100644 --- a/audit/audit-rules.scm +++ b/audit/audit-rules.scm @@ -196,3 +196,24 @@ (#eq? @fn "require") (#match? @arg "\\.") (#not-match? @arg "^pmacs(\\.|$)")) @violation + +;; --------------------------------------------------------------------------- +;; Rule 15: reach-around-require-field +;; +;; Info-level: detects field access directly on a required module +;; when the field name is private-looking (`_name`) or a loud +;; in-tree test seam (`__pmacs_*_DO_NOT_USE`). This catches +;; `require("pkg")._private` / `require("pkg").__pmacs_X_DO_NOT_USE`, +;; which the dotted-require rule above cannot see. +;; +;; The host `pmacs.X` namespaces are excluded for the same reason as +;; Rule 14. +;; --------------------------------------------------------------------------- +((dot_index_expression + (function_call + (identifier) @fn + (arguments (string (string_content) @arg))) + (identifier) @field) + (#eq? @fn "require") + (#not-match? @arg "^pmacs(\\.|$)") + (#match? @field "^_|^__pmacs_.*_DO_NOT_USE$")) @violation diff --git a/builtin/runtime/async.lua b/builtin/runtime/async.lua index 9f43ece..94555c7 100644 --- a/builtin/runtime/async.lua +++ b/builtin/runtime/async.lua @@ -37,6 +37,8 @@ assert(async_mod, "pmacs._async must be installed before the async builtin loads local parked_coroutines = {} -- handle id -> array of on_complete callbacks. local on_complete_callbacks = {} +-- coroutines waiting for the next async tick without dispatching a worker job. +local next_tick_coroutines = {} -- coroutine -> the handle the coroutine yielded with last (so we can -- detect a coroutine that yields a non-handle and surface it). local coroutine_waiting_on = setmetatable({}, { __mode = "k" }) @@ -199,6 +201,8 @@ local function step(co) if type(yielded) == "table" and yielded._is_pmacs_handle then parked_coroutines[yielded._id] = co coroutine_waiting_on[co] = yielded._id + elseif type(yielded) == "table" and yielded._is_pmacs_next_tick then + next_tick_coroutines[#next_tick_coroutines + 1] = co else if pmacs.error then pmacs.error("pmacs.async: coroutine yielded a non-Handle value (" .. @@ -209,7 +213,7 @@ local function step(co) end end -function pmacs.async(fn) +local function spawn_async(fn) if type(fn) ~= "function" then error("pmacs.async expects a function, got " .. type(fn)) end @@ -217,6 +221,20 @@ function pmacs.async(fn) step(co) end +local async_public = {} + +setmetatable(async_public, { + __call = function(_, fn) + return spawn_async(fn) + end, +}) + +function async_public.yield_to_next_tick() + coroutine.yield({ _is_pmacs_next_tick = true }) +end + +pmacs.async = async_public + -- --------------------------------------------------------------------------- -- pmacs.workers --- name-based dispatch surface. -- --------------------------------------------------------------------------- @@ -363,6 +381,12 @@ end -- --------------------------------------------------------------------------- function pmacs._async.tick() + local ready_next_tick = next_tick_coroutines + next_tick_coroutines = {} + for _, co in ipairs(ready_next_tick) do + step(co) + end + local settled = async_mod._tick() for _, id in ipairs(settled) do -- Fire on_complete callbacks before resuming the parked coroutine. diff --git a/builtin/runtime/fs.lua b/builtin/runtime/fs.lua index a568501..02ca064 100644 --- a/builtin/runtime/fs.lua +++ b/builtin/runtime/fs.lua @@ -32,6 +32,13 @@ -- exits with a structured `{ tag = "cancelled" }` error from -- :await() on cancel; callers either let it propagate (the typical -- behavior under supersede) or pcall around it. +-- +-- pmacs.fs.watch(path, callback [, opts]) +-- Polling file watcher. Calls callback({ kind = "changed" | +-- "created" | "removed", path = path, recursive = bool }) when +-- the snapshot changes. opts.interval_ms defaults to 250; +-- opts.recursive defaults to false. Returns a watcher with +-- :cancel() and :is_cancelled(). local async_mod = pmacs._async assert(async_mod, "pmacs._async must be installed before pmacs.fs loads") @@ -143,4 +150,138 @@ function fs.remove(path) return build_handle(async_mod._dispatch_fs_remove(path)) end +local Watch = {} +Watch.__index = Watch + +function Watch:cancel() + self.cancelled = true + if self._sleep_handle then + self._sleep_handle:cancel() + end +end + +function Watch:is_cancelled() + return self.cancelled +end + +local function join_path(base, name) + if base:sub(-1) == "/" then return base .. name end + return base .. "/" .. name +end + +local function error_string(err) + if type(err) == "table" then + return tostring(err.tag or "error") .. ":" .. tostring(err.message or "") + end + return tostring(err) +end + +local function entry_signature(path, entry) + return table.concat({ + path, + tostring(entry.kind), + tostring(entry.size), + tostring(entry.mtime), + tostring(entry.mtime_nsec), + tostring(entry.mode), + tostring(entry.symlink_target or ""), + }, "|") +end + +local function append_snapshot(parts, path, recursive) + local ok, entry = pcall(function() return fs.stat(path):await() end) + if not ok then + parts[#parts + 1] = path .. "|error|" .. error_string(entry) + return false + end + parts[#parts + 1] = entry_signature(path, entry) + if entry.kind ~= "dir" then + return true + end + + local ok_dir, entries = pcall(function() return fs.read_dir(path):await() end) + if not ok_dir then + parts[#parts + 1] = path .. "|read_dir_error|" .. error_string(entries) + return true + end + + table.sort(entries, function(a, b) return a.name < b.name end) + for _, child in ipairs(entries) do + local child_path = join_path(path, child.name) + parts[#parts + 1] = entry_signature(child_path, child) + if recursive and child.kind == "dir" then + append_snapshot(parts, child_path, true) + end + end + return true +end + +local function snapshot(path, recursive) + local parts = {} + local exists = append_snapshot(parts, path, recursive) + table.sort(parts) + return table.concat(parts, "\n"), exists +end + +function fs.watch(path, callback, opts) + if type(path) ~= "string" then + error("pmacs.fs.watch: path must be a string, got " .. type(path)) + end + if type(callback) ~= "function" then + error("pmacs.fs.watch: callback must be a function, got " .. type(callback)) + end + if opts ~= nil and type(opts) ~= "table" then + error("pmacs.fs.watch: opts must be a table or nil, got " .. type(opts)) + end + opts = opts or {} + local interval_ms = opts.interval_ms or 250 + if type(interval_ms) ~= "number" or interval_ms < 1 then + error("pmacs.fs.watch: opts.interval_ms must be a positive number") + end + interval_ms = math.floor(interval_ms) + local recursive = opts.recursive == true + + local watch = setmetatable({ + path = path, + recursive = recursive, + cancelled = false, + _sleep_handle = nil, + }, Watch) + + pmacs.async(function() + local previous, previous_exists = snapshot(path, recursive) + while not watch.cancelled do + local sleep_handle = pmacs.workers.sleep(interval_ms) + watch._sleep_handle = sleep_handle + pcall(function() sleep_handle:await() end) + watch._sleep_handle = nil + if watch.cancelled then break end + + local current, current_exists = snapshot(path, recursive) + if current ~= previous then + local kind = "changed" + if previous_exists and not current_exists then + kind = "removed" + elseif not previous_exists and current_exists then + kind = "created" + end + previous = current + previous_exists = current_exists + local ok, err = pcall(callback, { + kind = kind, + path = path, + recursive = recursive, + }) + if not ok and pmacs.error then + pmacs.error("pmacs.fs.watch callback failed: " .. tostring(err)) + elseif not ok then + error(err) + end + end + end + end) + + return watch +end + pmacs.fs = fs diff --git a/docs/package-author-guide.md b/docs/package-author-guide.md index 024cd7f..e43de11 100644 --- a/docs/package-author-guide.md +++ b/docs/package-author-guide.md @@ -100,6 +100,29 @@ Practical implication: a package can declare module-level state without worrying about colliding with another package's module-level state, even if both pick the same name. +### Runtime API availability + +Package entry chunks run during package load, including audit and +headless load paths. Keep top-level code limited to registration and +state setup. Surfaces installed by the base Lua host are available +there: `pmacs.buffer`, `pmacs.command`, `pmacs.keymap`, +`pmacs.hook`, `pmacs.describe`, `pmacs.help`, `pmacs.attach`, +`pmacs.now_ms`, and the standard Lua libraries. + +Editor-state surfaces are available once the editor bridge is +installed: command bodies invoked by pmacs, main-thread hooks fired by +the editor, edit intercepts, and `pmacs.async` callbacks resumed by +the editor loop can use `pmacs.editor`, `pmacs.window`, +`pmacs.frontend`, and `pmacs.minibuffer`. Top-level package load code +that also needs to run in audit/headless contexts should still guard +editor-only work or defer it to a command/hook. + +All of these callbacks run on the main Lua state. Intercepts have one +extra restriction: re-entering a mutation on the same buffer is +rejected by the buffer re-entry guard. Use +`{ bypass_intercept = true }` for package-owned redraws, not recursive +same-buffer edits from inside the intercept body. + --- ## 3. Address schemes (v1.0) @@ -172,9 +195,10 @@ emits findings at three severity levels: Currently always fires for fs / process operations; will gate on a future manifest `permissions` field. * **Info** --- patterns that need human classification (currently - cross-package dotted requires). + cross-package dotted requires and private-looking fields read + directly from `require(...)`). -### v1.0 rules (15 patterns across 7 spec classes) +### v1.0 rules (16 patterns across 7 spec classes) The full list lives at [`audit/audit-rules.scm`](../audit/audit-rules.scm) (the published @@ -188,7 +212,7 @@ contract) and `src/audit/rules.rs` (the metadata table). Summary: | Error | environment escape | `no-rawget-rawset-on-globals`, `no-setfenv-getfenv` | | Warning | filesystem mutation | `no-fs-mutation-io-open-write`, `no-fs-mutation-os` | | Warning | process spawning | `no-process-spawn-io`, `no-process-spawn-os`, `no-process-spawn-pmacs` | -| Info | reach-around | `reach-around-require` | +| Info | reach-around | `reach-around-require`, `reach-around-require-field` | ### Common Error rules and their fixes @@ -412,6 +436,7 @@ end) | `rename(from, to)` | nil on success | Atomic on the same filesystem; cross-fs returns EXDEV. | | `chmod(path, mode)` | nil on success | **Follows symlinks** (per `chmod(2)`); changes the target's mode, not the link's. Asymmetric with `read_dir`/`stat` which use lstat. | | `remove(path)` | nil on success | File or empty dir. Non-empty dirs fail; recurse at the package layer. Symlinks removed as symlinks (target survives). | +| `watch(path, callback [, opts])` | watcher handle | Polls for file/directory changes. Calls `callback({ kind = "changed" \| "created" \| "removed", path = path, recursive = bool })`. `opts.interval_ms` defaults to 250; `opts.recursive` defaults to false. | `opts.supersede = ""` chains read ops (`read_dir`, `stat`) into the M3 supersede semantics: a later op under the same key @@ -420,11 +445,90 @@ Mutating ops (`rename`/`chmod`/`remove`) intentionally don't accept `opts.supersede` — a "cancelled" syscall may have already mutated disk. +`watch` is intentionally polling-backed. The watcher handle exposes +`:cancel()` and `:is_cancelled()`. Use package-side coalescing if a +burst of filesystem writes should produce one refresh. + +Two consequences of the polling design are load-bearing for +callers. First, the baseline snapshot is taken asynchronously after +`watch` returns; a change that lands between the `watch` call and +the first completed snapshot is folded into the baseline and never +reported. Re-trigger the action if you need a guaranteed first +event, rather than assuming `watch` is armed synchronously. Second, +change detection compares a per-entry signature of size, mtime +(including nanoseconds), mode, and symlink target — a same-size +content rewrite within the filesystem's mtime granularity is not +observed. Both are acceptable for the derived-view refresh use case +`watch` targets; neither is suitable as a correctness-critical +change feed. + +`pmacs.async.yield_to_next_tick()` may be called inside +`pmacs.async(function() ... end)` when a package needs to resume on +the next editor async tick without dispatching a worker job. + +`pmacs-outline` also publishes `pmacs.outline.query(buffer, +predicate)` when the package is loaded. It returns parsed outline +entries whose fields match the package parser entries, and is the +public way for other packages to inspect outline structure without +requiring `pmacs-outline.parser` directly. + **UTF-8 constraint.** v0.1's `pmacs.fs` requires UTF-8 paths and entry names. A directory containing a non-UTF-8 entry surfaces a `failed` status from `:await()` with the parent path and offending raw bytes named. Byte-preserving paths are post-v0.1 work. +### `pmacs.buffer.*` — buffers, file loading, and cleanup + +Packages can create scratch buffers from bytes, load files through the +editor's file loader, and observe buffer removal for package-owned +state. + +| Function | Shape | Notes | +|----------|-------|-------| +| `create(name)` | buffer handle | Empty clean buffer. | +| `from_bytes(name, bytes)` | buffer handle | Byte-preserving buffer seeded from a Lua string. | +| `from_file(path)` | buffer handle | Loads the file using pmacs's normal file loader, creates a clean buffer named by `path`, switches the editor core to it when an editor core is present, and runs `buffer.after-load` if that hook is defined. | +| `remove(buf)` | nil on success | Removes a buffer and fires removal cleanup. | +| `on_removed(buf, callback)` | handle | Calls `callback(buf)` after `buf` is removed by `remove` or `kill`. The returned handle has `:remove()` for idempotent unsubscription. | + +Buffer-local keymaps are pruned automatically when a buffer is +removed. Package-local tables that hold per-buffer handles should use +`on_removed` to drop their own state: + +```lua +local cleanup = pmacs.buffer.on_removed(buf, function(dead) + handles[tostring(dead)] = nil +end) + +-- Later, if the package tears down before the buffer dies: +cleanup:remove() +``` + +For generated buffers with intercepts, package-owned writes can skip +the intercept chain explicitly: + +```lua +buf:replace(0, buf:len(), rendered, { bypass_intercept = true }) +``` + +`bypass_intercept` applies only to the intercept chain. It does not +disable the same-buffer re-entry guard, undo bookkeeping, dirty +tracking, view notifications, or CRDT broadcast queueing. + +### `pmacs.editor.*` — active-window editor state + +Editor primitives operate on the active window for the active +frontend. Cursor positions are byte offsets; line numbers are 0-based +to match `cursor_line()`. + +| Function | Shape | Notes | +|----------|-------|-------| +| `cursor()` | byte offset | Active window cursor. | +| `cursor_line()` | line index | 0-based line containing the cursor. | +| `cursor_col()` | byte column | 0-based byte column within the current line. | +| `move_to_line(line)` | nil | Moves to the start of `line`; out-of-range values clamp to the last line. | +| `set_status(message)` | nil | Replaces the status message. | + ### A complete dev-loop example ```lua @@ -481,6 +585,11 @@ Multiple intercepts may be attached to the same buffer; they run in attach order, threading the (possibly position-modified) op through the chain. +Package-owned redraws of derived buffers should prefer +`{ bypass_intercept = true }` on `insert`, `delete`, or `replace` +instead of maintaining a separate `painting` boolean around every +write. + --- ## 8. The bundled REPL as a worked example diff --git a/src/audit/mod.rs b/src/audit/mod.rs index e8fb8af..f166677 100644 --- a/src/audit/mod.rs +++ b/src/audit/mod.rs @@ -479,6 +479,34 @@ mod tests { assert!(f.iter().all(|x| x.rule != "reach-around-require")); } + #[test] + fn reach_around_field_access_is_info_level() { + let f = engine().audit_source( + "t.lua", + r#"local seam = require("otherpkg").__pmacs_outline_test_seam_DO_NOT_USE"#, + ); + let r = f + .iter() + .find(|x| x.rule == "reach-around-require-field") + .expect("expected reach-around field finding"); + assert_eq!(r.severity, Severity::Info); + } + + #[test] + fn reach_around_field_access_ignores_public_and_pmacs_fields() { + let f = engine().audit_source( + "t.lua", + r#" + local ok = require("otherpkg").query + local host = require("pmacs.foo")._private + "#, + ); + assert!( + f.iter().all(|x| x.rule != "reach-around-require-field"), + "expected no field reach-around findings, got {f:?}" + ); + } + #[test] fn bare_require_is_not_a_finding() { let f = engine().audit_source("t.lua", r#"require("magit")"#); diff --git a/src/audit/rules.rs b/src/audit/rules.rs index 00f967b..52caddc 100644 --- a/src/audit/rules.rs +++ b/src/audit/rules.rs @@ -145,4 +145,10 @@ pub const DEFAULT_RULES: &[AuditRule] = &[ severity: Severity::Info, message: "dotted require may target another package's non-exported submodule", }, + // 15 + AuditRule { + name: "reach-around-require-field", + severity: Severity::Info, + message: "private-looking field access on require() may reach another package's internals", + }, ]; diff --git a/src/editor.rs b/src/editor.rs index 4ceeb50..590a99c 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -3175,6 +3175,34 @@ mod tests { ); } + #[test] + fn buffer_kill_fires_on_removed_callbacks() { + let s = EditorState::new(); + let doomed = s + .lua_host + .registry() + .borrow_mut() + .create_from_bytes("doomed.txt", b"hello"); + let called: bool = s + .lua_host + .lua() + .load( + r" + local doomed = ... + local called = false + pmacs.buffer.on_removed(doomed, function(dead) + assert(dead == doomed) + called = true + end) + pmacs.buffer.kill(doomed) + return called + ", + ) + .call(crate::lua_bindings::BufferIdLua(doomed)) + .unwrap(); + assert!(called, "kill should fire buffer removal callbacks"); + } + /// `pmacs.buffer.kill` refuses to remove the last remaining /// buffer; the registry must never go empty. #[test] @@ -3245,6 +3273,26 @@ mod tests { assert_eq!(s.core.borrow().active_buffer_name(), "target.txt"); } + #[test] + fn editor_move_to_line_positions_cursor_by_zero_based_line() { + let s = fresh_with(b"alpha\nbeta\ngamma"); + s.lua_host + .lua() + .load("pmacs.editor.move_to_line(1)") + .exec() + .unwrap(); + assert_eq!(s.core.borrow().cursor_line(), 1); + assert_eq!(s.core.borrow().cursor(), 6); + + s.lua_host + .lua() + .load("pmacs.editor.move_to_line(99)") + .exec() + .unwrap(); + assert_eq!(s.core.borrow().cursor_line(), 2); + assert_eq!(s.core.borrow().cursor(), 11); + } + /// `editor.next-buffer` walks the active window through the /// buffer registry in order, wrapping past the end. Three buffers /// in registry order: walking next four times returns to the diff --git a/src/editor_core.rs b/src/editor_core.rs index e9d60a5..9ef8bea 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -355,6 +355,21 @@ impl EditorCore { aw.text_view.line_at_offset(aw.cursor) } + /// Move the active window's cursor to the start of a 0-based line. + /// Out-of-range line numbers clamp to the last line. + pub fn move_to_line(&mut self, line: usize) { + let line_count = self.active_window().text_view.line_count().max(1); + let target_line = line.min(line_count - 1); + let target = self + .active_window() + .text_view + .line_offset(target_line) + .unwrap_or_else(|| self.active_buffer_len()); + let aw = self.active_window_mut(); + aw.cursor = target; + aw.goal_col = None; + } + // ---- editing primitives ------------------------------------------------ /// Apply `op` to the active buffer; notify every window diff --git a/src/keymap_stack.rs b/src/keymap_stack.rs index 4bdf464..0f55555 100644 --- a/src/keymap_stack.rs +++ b/src/keymap_stack.rs @@ -197,6 +197,12 @@ impl KeymapStack { Ok(removed) } + /// Drop every buffer-local binding for a buffer that just left + /// the registry. + pub fn remove_buffer(&mut self, buffer: BufferId) -> bool { + self.buffers.remove(&buffer).is_some() + } + /// Unbind a sequence from a mode keymap. /// /// # Errors diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index 990b10a..5390349 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -97,6 +97,77 @@ pub type SharedCore = Rc>; /// rationale as the other `Rc>` aliases. pub type SharedHookRegistry = Rc>; +#[derive(Clone)] +struct BufferRemoveCallbacks(Rc>); + +struct BufferRemoveCallbackState { + next_id: u64, + callbacks: HashMap>, +} + +#[derive(Clone)] +struct BufferRemoveCallback { + id: u64, + body: Function, + source: SourceLocation, +} + +impl BufferRemoveCallbacks { + fn new() -> Self { + Self(Rc::new(RefCell::new(BufferRemoveCallbackState { + next_id: 1, + callbacks: HashMap::new(), + }))) + } + + fn add(&self, buffer: BufferId, body: Function, source: SourceLocation) -> u64 { + let mut state = self.0.borrow_mut(); + let id = state.next_id; + state.next_id = state.next_id.saturating_add(1); + state + .callbacks + .entry(buffer) + .or_default() + .push(BufferRemoveCallback { id, body, source }); + id + } + + fn remove(&self, buffer: BufferId, callback_id: u64) -> bool { + let mut state = self.0.borrow_mut(); + let Some(callbacks) = state.callbacks.get_mut(&buffer) else { + return false; + }; + let before = callbacks.len(); + callbacks.retain(|callback| callback.id != callback_id); + let removed = callbacks.len() != before; + if callbacks.is_empty() { + state.callbacks.remove(&buffer); + } + removed + } + + fn take(&self, buffer: BufferId) -> Vec { + self.0 + .borrow_mut() + .callbacks + .remove(&buffer) + .unwrap_or_default() + } +} + +struct BufferRemoveCallbackHandleLua { + buffer: BufferId, + callback_id: u64, +} + +impl UserData for BufferRemoveCallbackHandleLua { + fn add_methods>(methods: &mut M) { + methods.add_method("remove", |lua, this, ()| { + Ok(remove_buffer_removed_callback(lua, this)) + }); + } +} + /// Init-phase tracker. The user's `init.lua` runs while this is `false`; /// [`crate::editor::EditorState::new`] flips it to `true` after the /// init chunk returns. Lua bindings that gate on init phase @@ -1083,40 +1154,51 @@ fn add_query_methods>(methods: &mut M) { } fn add_mutation_methods>(methods: &mut M) { - methods.add_method("insert", |lua, this, (pos, bytes): (i64, mlua::String)| { - let pos = u64_from_lua(pos)?; - let payload = bytes.as_bytes(); - let edit = run_managed_edit( - lua, - this.0, - EditOp::Insert { - pos, - bytes: &payload, - }, - )?; - notify_buffer_edit_to_windows(lua, this.0, &edit); - Ok(()) - }); + methods.add_method( + "insert", + |lua, this, (pos, bytes, opts): (i64, mlua::String, Option)| { + let pos = u64_from_lua(pos)?; + let bypass_intercept = parse_bypass_intercept(opts.as_ref())?; + let payload = bytes.as_bytes(); + let edit = run_buffer_edit( + lua, + this.0, + EditOp::Insert { + pos, + bytes: &payload, + }, + bypass_intercept, + )?; + notify_buffer_edit_to_windows(lua, this.0, &edit); + Ok(()) + }, + ); - methods.add_method("delete", |lua, this, (start, end): (i64, i64)| { - let range = checked_range(start, end)?; - let edit = run_managed_edit(lua, this.0, EditOp::Delete { range })?; - notify_buffer_edit_to_windows(lua, this.0, &edit); - Ok(()) - }); + methods.add_method( + "delete", + |lua, this, (start, end, opts): (i64, i64, Option
)| { + let range = checked_range(start, end)?; + let bypass_intercept = parse_bypass_intercept(opts.as_ref())?; + let edit = run_buffer_edit(lua, this.0, EditOp::Delete { range }, bypass_intercept)?; + notify_buffer_edit_to_windows(lua, this.0, &edit); + Ok(()) + }, + ); methods.add_method( "replace", - |lua, this, (start, end, bytes): (i64, i64, mlua::String)| { + |lua, this, (start, end, bytes, opts): (i64, i64, mlua::String, Option
)| { let range = checked_range(start, end)?; + let bypass_intercept = parse_bypass_intercept(opts.as_ref())?; let payload = bytes.as_bytes(); - let edit = run_managed_edit( + let edit = run_buffer_edit( lua, this.0, EditOp::Replace { range, bytes: &payload, }, + bypass_intercept, )?; notify_buffer_edit_to_windows(lua, this.0, &edit); Ok(()) @@ -1124,6 +1206,40 @@ fn add_mutation_methods>(methods: &mut M) { ); } +fn parse_bypass_intercept(opts: Option<&Table>) -> mlua::Result { + Ok(match opts { + Some(opts) => opts + .get::>("bypass_intercept")? + .unwrap_or(false), + None => false, + }) +} + +fn run_buffer_edit( + lua: &Lua, + id: BufferId, + op: EditOp<'_>, + bypass_intercept: bool, +) -> mlua::Result { + if bypass_intercept { + run_bypass_edit(lua, id, op) + } else { + run_managed_edit(lua, id, op) + } +} + +fn run_bypass_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result { + with_registry_mut(lua, |r| { + let buf = resolve_mut(r, id)?; + buf.begin_edit().map_err(mlua::Error::external)?; + let result = buf + .apply_edit_skip_intercepts(op) + .map_err(mlua::Error::external); + buf.end_edit(); + result + }) +} + /// Three-phase edit flow that lets intercepts re-enter `pmacs.buffer.X` /// safely (T M7.4). /// @@ -1239,6 +1355,52 @@ fn notify_buffer_edit_to_windows(lua: &Lua, buffer_id: BufferId, edit: &crate::r core.queue_daemon_origin_crdt_op(buffer_id, edit); } +fn remove_buffer_removed_callback(lua: &Lua, handle: &BufferRemoveCallbackHandleLua) -> bool { + let Some(callbacks) = lua.app_data_ref::() else { + return false; + }; + callbacks.remove(handle.buffer, handle.callback_id) +} + +fn remove_buffer_and_fire(lua: &Lua, registry: &SharedRegistry, id: BufferId) -> mlua::Result<()> { + registry + .borrow_mut() + .remove(id) + .map(|_| ()) + .map_err(mlua::Error::external)?; + after_buffer_removed(lua, id); + Ok(()) +} + +fn after_buffer_removed(lua: &Lua, id: BufferId) { + if let Some(keymaps) = lua.app_data_ref::() { + keymaps.borrow_mut().remove_buffer(id); + } + let callbacks = match lua.app_data_ref::() { + Some(callbacks) => callbacks.take(id), + None => Vec::new(), + }; + for callback in callbacks { + if let Err(err) = callback.body.call::<()>(BufferIdLua(id)) { + log_buffer_removed_error(lua, &callback.source, &err); + } + } +} + +fn run_hook_if_defined(lua: &Lua, name: &str, args: mlua::MultiValue) { + let snapshot = match lua.app_data_ref::() { + Some(hooks) => hooks.borrow().snapshot(name), + None => None, + }; + let Some((kind, callbacks)) = snapshot else { + return; + }; + let outcome = crate::hook::run_snapshot(kind, &callbacks, args); + for err in &outcome.errors { + log_hook_error(lua, name, err); + } +} + /// Force any window showing `buffer_id` to rebuild its `TextView`. /// /// Called by `pmacs.help.*` after a render rewrites `*help*` end to @@ -1733,6 +1895,7 @@ pub fn install( lua.set_app_data(InstalledPackages::new()); lua.set_app_data(PackageUnloadHooks::new()); lua.set_app_data(CurrentlyLoadingPackage::new()); + lua.set_app_data(BufferRemoveCallbacks::new()); let pmacs = lua.create_table()?; pmacs.set("buffer", install_buffer_module(lua, registry)?)?; @@ -2146,6 +2309,32 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result mlua::Result { + let path_buf = std::path::PathBuf::from(&path); + let (bytes, meta) = crate::file_io::load_file(&path_buf).map_err(|source| { + mlua::Error::external(std::io::Error::new( + source.kind(), + format!("failed to load {path}: {source}"), + )) + })?; + let id = reg.borrow_mut().create_from_bytes(path.clone(), &bytes); + if let Some(core) = lua.app_data_ref::() { + let mut core = core.borrow_mut(); + core.switch_active_buffer(id) + .map_err(mlua::Error::external)?; + core.file_path = Some(path_buf); + core.file_meta = Some(meta); + } + run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new()); + Ok(BufferIdLua(id)) + })?, + )?; + } + { let reg = registry.clone(); buffer.set( @@ -2165,15 +2354,37 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result mlua::Result { + { + let r = reg.borrow(); + resolve(&r, id.0)?; + } + let callbacks = lua + .app_data_ref::() + .ok_or_else(|| mlua::Error::external(BindingError::NoRegistry))?; + let callback_id = callbacks.add(id.0, body, caller_source(lua, 2)); + Ok(BufferRemoveCallbackHandleLua { + buffer: id.0, + callback_id, + }) + }, + )?, + )?; + } + { let reg = registry.clone(); buffer.set( @@ -3953,10 +4164,12 @@ fn install_buffer_kill(lua: &Lua, core: &SharedCore) -> mlua::Result<()> { let cc = core.clone(); buffer.set( "kill", - lua.create_function(move |_, id: BufferIdLua| -> mlua::Result<()> { + lua.create_function(move |lua, id: BufferIdLua| -> mlua::Result<()> { cc.borrow_mut() .kill_buffer(id.0) - .map_err(mlua::Error::external) + .map_err(mlua::Error::external)?; + after_buffer_removed(lua, id.0); + Ok(()) })?, )?; Ok(()) @@ -4430,6 +4643,37 @@ fn log_package_load_error(lua: &Lua, package: &str, err: &mlua::Error) { } } +fn log_buffer_removed_error(lua: &Lua, source: &SourceLocation, err: &mlua::Error) { + let line = format!( + "[buffer.on_removed] callback at {} raised: {err}\n", + source.render() + ); + let result = { + let Some(app) = lua.app_data_ref::() else { + return; + }; + let mut reg = app.borrow_mut(); + let id = match reg.find_by_name(crate::lua::ERRORS_BUFFER_NAME) { + Some(id) => id, + None => reg.create(crate::lua::ERRORS_BUFFER_NAME), + }; + let Ok(buf) = reg.get_mut(id) else { + return; + }; + let pos = buf.len(); + let edit = buf + .apply_edit(EditOp::Insert { + pos, + bytes: line.as_bytes(), + }) + .ok(); + edit.map(|e| (id, e)) + }; + if let Some((id, edit)) = result { + notify_buffer_edit_to_windows(lua, id, &edit); + } +} + fn install_describe_module( lua: &Lua, registry: &SharedRegistry, @@ -9888,6 +10132,17 @@ fn install_motion(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result< "move_paragraph_down", EditorCore::move_paragraph_down, )?; + { + let cc = core.clone(); + editor.set( + "move_to_line", + lua.create_function(move |_, line: i64| { + let line = usize::try_from(line).map_err(mlua::Error::external)?; + cc.borrow_mut().move_to_line(line); + Ok(()) + })?, + )?; + } Ok(()) } @@ -10667,6 +10922,28 @@ mod tests { assert_eq!(len, 5); } + #[test] + fn from_file_loads_existing_file_as_clean_buffer() { + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("seed.txt"); + std::fs::write(&path, b"abcde").expect("write seed file"); + let loaded: BufferIdLua = lua + .load("return pmacs.buffer.from_file(...)") + .call(path.display().to_string()) + .unwrap(); + let content: String = lua + .load("local id = ...; return id:slice(0, id:len())") + .call(loaded) + .unwrap(); + let modified: bool = lua + .load("local id = ...; return id:is_modified()") + .call(loaded) + .unwrap(); + assert_eq!(content, "abcde"); + assert!(!modified, "loaded buffers should start clean"); + } + #[test] fn delete_replace_undo_redo() { let (lua, _reg, _cmds, _kms, _hks) = fresh(); @@ -11104,6 +11381,52 @@ mod tests { ); } + #[test] + fn bypass_intercept_option_skips_intercept_chain() { + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let content: String = lua + .load( + r#" + local id = pmacs.buffer.from_bytes("scratch", "abcdef") + pmacs.buffer.add_intercept(id, function() + error("blocked") + end) + local ok = pcall(function() id:replace(0, 1, "X") end) + assert(not ok, "plain replace must still run intercepts") + id:replace(0, 1, "Y", { bypass_intercept = true }) + return id:slice(0, id:len()) + "#, + ) + .eval() + .unwrap(); + assert_eq!(content, "Ybcdef"); + } + + #[test] + fn bypass_intercept_keeps_same_buffer_reentry_gate() { + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let msg: String = lua + .load( + r#" + local id = pmacs.buffer.from_bytes("scratch", "abc") + pmacs.buffer.add_intercept(id, function() + id:replace(0, 1, "X", { bypass_intercept = true }) + return nil + end) + local ok, err = pcall(function() id:replace(0, 1, "Y") end) + assert(not ok, "same-buffer re-entry must still be gated") + assert(id:slice(0, id:len()) == "abc") + return tostring(err) + "#, + ) + .eval() + .unwrap(); + assert!( + msg.contains("already being edited") || msg.contains("ConcurrentEdit"), + "expected ConcurrentEdit-style error; got: {msg}" + ); + } + #[test] fn cross_buffer_remove_from_intercept_succeeds() { // Cross-buffer remove from inside an intercept is allowed: @@ -11136,6 +11459,60 @@ mod tests { ); } + #[test] + fn on_removed_callback_fires_once_and_can_be_removed() { + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let called: bool = lua + .load( + r#" + local first = pmacs.buffer.create("first") + local second = pmacs.buffer.create("second") + local calls = 0 + pmacs.buffer.on_removed(first, function(dead) + assert(dead == first, "removed callback receives the removed buffer") + calls = calls + 1 + end) + local handle = pmacs.buffer.on_removed(second, function() + calls = calls + 100 + end) + assert(handle:remove() == true) + assert(handle:remove() == false) + pmacs.buffer.remove(first) + pmacs.buffer.remove(second) + return calls == 1 + "#, + ) + .eval() + .unwrap(); + assert!(called); + } + + #[test] + fn buffer_remove_prunes_buffer_local_keymaps() { + let (lua, _reg, _cmds, kms, _hks) = fresh(); + let id: BufferIdLua = lua + .load( + r#" + local id = pmacs.buffer.create("keyed") + pmacs.keymap.bind { + scope = "buffer", + buffer = id, + sequence = "C-c x", + command = "buffer.save", + } + return id + "#, + ) + .eval() + .unwrap(); + assert!(kms.borrow().buffers.contains_key(&id.0)); + lua.load("pmacs.buffer.remove(...)").call::<()>(id).unwrap(); + assert!( + !kms.borrow().buffers.contains_key(&id.0), + "buffer-local keymaps should be pruned on removal" + ); + } + #[test] fn m6_4_intercept_transform_overrides_position() { // An intercept may transform an insert by returning a table diff --git a/tests/fixtures/pmacs-outline/aggregate.lua b/tests/fixtures/pmacs-outline/aggregate.lua index 9ec246d..701971d 100644 --- a/tests/fixtures/pmacs-outline/aggregate.lua +++ b/tests/fixtures/pmacs-outline/aggregate.lua @@ -29,9 +29,9 @@ -- -- * Source-listener intercept on each source marks the aggregate -- dirty and schedules an async repaint via pmacs.async + --- workers.sleep(0):await(). The body yields once so the source --- edit's Phase-3 commit completes before repaint reads the --- source's text. +-- pmacs.async.yield_to_next_tick(). The body yields once so the +-- source edit's Phase-3 commit completes before repaint reads +-- the source's text. -- -- * Cross-block edits are rejected. A delete or replace whose -- range spans more than one block --- for example, deleting @@ -170,29 +170,19 @@ M.__pmacs_outline_test_repaint = repaint -- Source intercepts can't repaint synchronously: the source's -- Phase 3 hasn't committed yet, so reading source:slice would see -- the pre-edit text. We schedule a coroutine that yields once via --- pmacs.workers.sleep(0):await(); the next tick_async resumes it +-- pmacs.async.yield_to_next_tick(); the next tick_async resumes it -- after Phase 3 has applied. `repaint_scheduled` coalesces multiple -- source edits into one repaint per tick. -- --- Pass-6 finding 3 / timing semantics: `workers.sleep(0)` dispatches --- a sleep job to a worker thread which immediately replies to the --- main-thread bus; the next call to `state.tick_async` drains the --- bus and resumes our coroutine. This is "within one tick *after --- the worker reply settles*" --- not strictly one tick of the --- editor's main event loop. In practice the worker reply arrives --- in microseconds, so a single tick suffices once a small amount --- of real time has elapsed; the M8.10 acceptance test verifies --- bounded propagation via `pump_until` rather than a hard one-tick --- count. SP-7 in V0.2-PREREQUISITES.md tracks the v0.2 work to add --- a synchronous post-edit hook (or a worker-free coroutine yield --- primitive) so aggregate repaint can be pinned to exactly one --- tick of the main event loop. +-- SP-7 resolution: this uses a worker-free next-tick yield primitive, +-- so propagation is pinned to the editor's async tick rather than to +-- a worker reply round trip. local function schedule_repaint(handle) if handle.repaint_scheduled or not handle.alive then return end handle.repaint_scheduled = true pmacs.async(function() - pmacs.workers.sleep(0):await() + pmacs.async.yield_to_next_tick() handle.repaint_scheduled = false if handle.alive and handle.dirty then repaint(handle) diff --git a/tests/fixtures/pmacs-outline/init.lua b/tests/fixtures/pmacs-outline/init.lua index a850834..6ce291a 100644 --- a/tests/fixtures/pmacs-outline/init.lua +++ b/tests/fixtures/pmacs-outline/init.lua @@ -55,6 +55,9 @@ -- .parser_handle -- outline.close(handle) -- removes intercepts, -- drops the visible buffer +-- outline.query(source_buf, predicate) -- public structure query; +-- also installed as +-- pmacs.outline.query -- -- M-x pmacs-outline.next-headline -- in visible buffer -- M-x pmacs-outline.parent-headline @@ -320,6 +323,28 @@ function M.toggle_fold(handle, source_byte) repaint(handle) end +function M.query(source_buf, predicate) + if type(predicate) ~= "function" then + error("pmacs-outline.query: predicate must be a function") + end + + local h = find_handle_by("source", source_buf) + if h then + return parser.query(h.parser_handle, predicate) + end + + local ph = parser.attach(source_buf) + local ok, result = pcall(function() + return parser.query(ph, predicate) + end) + parser.detach(ph) + if not ok then error(result) end + return result +end + +pmacs.outline = pmacs.outline or {} +pmacs.outline.query = M.query + -- --------------------------------------------------------------------------- -- Commands -- --------------------------------------------------------------------------- @@ -389,6 +414,9 @@ pmacs.packages.on_unload(function() pmacs.command.unregister(name) end OWNED_COMMANDS = {} + if pmacs.outline and pmacs.outline.query == M.query then + pmacs.outline.query = nil + end end) -- --------------------------------------------------------------------------- diff --git a/tests/m10_11_acceptance.rs b/tests/m10_11_acceptance.rs index 9f9caf4..1938623 100644 --- a/tests/m10_11_acceptance.rs +++ b/tests/m10_11_acceptance.rs @@ -1164,7 +1164,7 @@ fn m10_11_q13_cat2_undo_across_delayed_ops() { /// re-run (framing Q8). /// /// **F2 correction.** Finding 5's first resolution ("(B): jitter-mode -/// delays both CellDelta and CrdtOp") was wrong — it widened the +/// delays both `CellDelta` and `CrdtOp`") was wrong — it widened the /// match in the render-message loop, which never carries broadcast /// `CrdtOp`s; the CRDT-convergence path was *not* exercised and this /// test silently asserted nothing about CRDT-under-jitter. F2 moved diff --git a/tests/m8_10_acceptance.rs b/tests/m8_10_acceptance.rs index e5b7ccb..74cbc20 100644 --- a/tests/m8_10_acceptance.rs +++ b/tests/m8_10_acceptance.rs @@ -67,20 +67,6 @@ fn editor_with_outline() -> (EditorState, TempDir, TempDir) { (state, cache, user_root) } -/// Pump the async runtime until `predicate` returns true or the -/// deadline elapses. Magit and dired tests use the same shape. -fn pump_until bool>(state: &mut EditorState, predicate: F) { - let deadline = Instant::now() + Duration::from_secs(2); - while !predicate(state) { - assert!( - Instant::now() < deadline, - "async pump deadline exceeded after 2s" - ); - state.tick_async(); - std::thread::sleep(Duration::from_millis(2)); - } -} - fn agg_text(state: &mut EditorState) -> String { state .lua_host @@ -251,18 +237,10 @@ fn outline_aggregate_source_change_propagates_within_one_tick() { ) .expect("source edit"); - // Pump the async runtime: the source-listener intercept scheduled - // a deferred repaint via pmacs.async(...sleep(0):await()...). One - // tick should be enough to dispatch it. - pump_until(&mut state, |state| { - let txt: String = state - .lua_host - .lua() - .load(r"return AGG.buffer:slice(0, AGG.buffer:len())") - .eval() - .expect("agg text"); - txt.contains("TODO modified") - }); + // SP-7: the source-listener intercept now schedules a deferred + // repaint via pmacs.async.yield_to_next_tick(), with no worker + // round trip. One async tick must be sufficient. + state.tick_async(); let after = agg_text(&mut state); assert!( @@ -1204,16 +1182,9 @@ fn outline_aggregate_package_reload_closes_aggregates() { #[test] fn outline_aggregate_source_change_propagates_with_tight_deadline() { - // Pass-6 finding 3. The bullet-3 acceptance test uses a 2s - // pump_until deadline, which validates eventual bounded - // propagation rather than a strict one-tick guarantee. This - // test pins the *bounded* claim explicitly with a tight 200ms - // deadline: under the v0.1 implementation (source-listener - // schedules via pmacs.async + workers.sleep(0):await()), the - // worker reply arrives in microseconds and a small number of - // ticks suffices. SP-7 in V0.2-PREREQUISITES.md tracks the - // v0.2 work to pin propagation to exactly one tick of the - // main event loop. + // SP-7 regression: the aggregate source-listener uses + // pmacs.async.yield_to_next_tick(), so the old worker-sleep + // timing path must not come back. let (mut state, _c, _u) = editor_with_outline(); state .lua_host diff --git a/tests/m8_1_acceptance.rs b/tests/m8_1_acceptance.rs index 60ad53f..9449202 100644 --- a/tests/m8_1_acceptance.rs +++ b/tests/m8_1_acceptance.rs @@ -549,3 +549,63 @@ fn read_dir_on_missing_path_returns_failed_status() { "error message must name the offending path; got {message}" ); } + +#[test] +fn fs_watch_reports_file_change_and_can_cancel() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("watched.txt"); + std::fs::write(&path, b"one").expect("write initial"); + + let mut state = fresh_editor(); + let path_str = path.display().to_string(); + let chunk = format!( + r#" + _G.WATCH_EVENTS = {{}} + _G.WATCH = pmacs.fs.watch("{path_str}", function(event) + WATCH_EVENTS[#WATCH_EVENTS + 1] = event.kind + end, {{ interval_ms = 5 }}) + "#, + ); + eval_sync::<()>(&mut state, &chunk); + + // The watcher establishes its baseline snapshot asynchronously (an + // in-flight fs.stat). `pmacs._async.pending_count() == 1` cannot + // distinguish that in-flight baseline read from the steady-state + // poll sleep, so a single post-gate write can race the baseline, + // be absorbed into it, and never produce an event (the flake this + // replaces). Instead, rewrite the file with distinct-length + // content on every pump iteration: whatever snapshot the watcher + // captured as its baseline, a subsequent distinct write + // necessarily differs from it (size differs, so it holds even on + // coarse-mtime filesystems) and fires a "changed" event. + let writes = std::cell::Cell::new(0u32); + pump_until(&mut state, |s| { + let n = writes.get() + 1; + writes.set(n); + std::fs::write(&path, "v".repeat(n as usize + 1)).expect("rewrite changed"); + let count: i64 = s + .lua_host + .lua() + .load("return #WATCH_EVENTS") + .eval() + .expect("event count"); + count >= 1 + }); + + let first: String = state + .lua_host + .lua() + .load("return WATCH_EVENTS[1]") + .eval() + .expect("first watch event"); + assert_eq!(first, "changed"); + + eval_sync::<()>(&mut state, "WATCH:cancel()"); + let cancelled: bool = state + .lua_host + .lua() + .load("return WATCH:is_cancelled()") + .eval() + .expect("cancelled"); + assert!(cancelled, "watch handle must report cancellation"); +} diff --git a/tests/m8_9_acceptance.rs b/tests/m8_9_acceptance.rs index baf32ff..999b519 100644 --- a/tests/m8_9_acceptance.rs +++ b/tests/m8_9_acceptance.rs @@ -67,6 +67,37 @@ fn editor_with_outline() -> (EditorState, TempDir, TempDir) { (state, cache, user_root) } +#[test] +fn public_pmacs_outline_query_returns_matching_entries() { + let (mut state, _cache, _user_root) = editor_with_outline(); + state + .lua_host + .eval( + Some("public-outline-query"), + r#" + _G.SRC = pmacs.buffer.create("*outline-query-src*") + _G.SRC:replace(0, 0, + "* TODO alpha :todo:\nbody\n" .. + "* DONE beta :done:\nbody\n" .. + "* TODO gamma :todo:\nbody\n") + local hits = pmacs.outline.query(_G.SRC, function(e) + return e.tagset and e.tagset.todo + end) + _G.HIT_COUNT = #hits + _G.FIRST_TITLE = hits[1].title + _G.SECOND_TITLE = hits[2].title + "#, + ) + .expect("public outline query"); + + let count: i64 = state.lua_host.lua().globals().get("HIT_COUNT").unwrap(); + assert_eq!(count, 2); + let first: String = state.lua_host.lua().globals().get("FIRST_TITLE").unwrap(); + let second: String = state.lua_host.lua().globals().get("SECOND_TITLE").unwrap(); + assert_eq!(first, "TODO alpha"); + assert_eq!(second, "TODO gamma"); +} + /// Boot an outline view over a fresh source buffer pre-populated /// with `text`. After this call the visible projection buffer is /// active in the window. Globals stashed for test access: