diff --git a/.github/workflows/terminal.yml b/.github/workflows/terminal.yml index 24dff43c..9001f611 100644 --- a/.github/workflows/terminal.yml +++ b/.github/workflows/terminal.yml @@ -55,6 +55,9 @@ jobs: - run: rustup toolchain install 1.98.0 --profile minimal - run: cargo test --locked -p ox-tui --lib - run: cargo test --locked -p ox-sys --lib + - name: Check the whole workspace for the host MSVC target + run: cargo check --locked --workspace --all-targets + - run: cargo test --locked -p ox-editor --test platform_startup - run: rustup target add i686-pc-windows-msvc - name: Preserve libuv uname behavior under WOW64 run: cargo test --locked -p ox-sys --lib --target i686-pc-windows-msvc diff --git a/.gitignore b/.gitignore index ecc2ebcf..acd958dd 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,4 @@ tmtags # copies the ABI-compatible reference build artifacts here so the # `parser/.*` runtime lookup resolves them). runtime/parser/ +target-*/ diff --git a/.outline/sdd/reports/2026-10-28-completion-campaign.md b/.outline/sdd/reports/2026-10-28-completion-campaign.md new file mode 100644 index 00000000..fc69e16f --- /dev/null +++ b/.outline/sdd/reports/2026-10-28-completion-campaign.md @@ -0,0 +1,1541 @@ +# Completion and stabilization campaign, 2026-10-28 + +Work landed on `main` after `c4520b0`. The .agent-tasks goal table is the contract: +build, workspace tests, api-info diff, functional suite, oldtest suite. + +## Gate results + +| # | Criterion | Before | After | +|---|---|---|---| +| 1 | `cargo build --workspace --release` | pass | pass | +| 2 | `cargo nextest run --workspace` | 3563/3564 | 3645/3645, one skipped | +| 3 | `just apidiff` | fail, one function | pass, zero semantic differences | +| 4 | `just functional` | never measured | 1830 pass, 4076 fail, 58 skip, 1 error over 484 files | +| 5 | `just oldtest` | produced no results at all | 3654 executed, 2365 failed, 686 skipped, 40 blocked, 5 timed out over 236 files | + +Criteria 1 to 3 are green. Criteria 4 and 5 are measured and still red. + +## What was wrong, and what fixed it + +1. **`nvim_mcursor` was invented.** Commit fc59163 added the API function and edited the canonical metadata + blob so `nvim_get_api_info` advertised it; upstream at the reference commit has no such function. The blob + was restored from the oracle's own `--api-info` output (32701 bytes), the inventory regenerated, and the + handler, registration, tests, and dead editor methods removed. +2. **`:scriptnames` was missing.** Upstream `runtime/plugin/rplugin.vim` calls it on every start, so each + session printed four error lines: `E117: Unknown function: scriptnames` twice and a bare + `not implemented: scriptnames`. It is implemented now, and an unimplemented Ex command reports + `E492: Not an editor command` instead of a function error. +3. **The PTY differential test hedged on encoding.** `edits_quits_and_restores_terminal_palette` waited for + `editx` in the printable byte stream, which only appears when a client skips blank cells; this client paints + them. It now parses the stream with `vt100` and asserts the rendered screen, and honours `OXVIM_BIN` so a + build in a private `CARGO_TARGET_DIR` is the binary under test. +4. **The oldtest gate could not run.** The upstream Makefile starts every file through + `jobstart(..., {'term': v:true})` and reads terminal-buffer lines back, and oxvim paints no terminal buffer, + so the gate reported an empty screen and no results. `tests/oldtest/run.sh` drives `runtest.vim` directly + with one scratch tree per file, and a full sweep takes about six minutes. + +## Defect clusters closed by the fix waves + +| Cluster | Effect | +|---|---| +| Ex modifier loop, `:put =`, `:tabdo`, `:vimgrep`, `:wNext`, function reload | `:unsilent :norm! n` parses; six `:put =` shapes match the oracle; `test_normal` E488 4 to 0 | +| Substitute text lock | a recursive `\=` replacement raised E565 instead of hanging forever | +| Script-local scope | `s:has_ffi` undefined 41 sites to 0; `g:Name()` calls 3 to 0 | +| screen queries | E5070 from `nr2char(screenchar(...))` 15 sites to 0 | +| Command-line builtins | `getcmdline getcmdpos setcmdpos getcmdtype getcmdprompt pumvisible wildmenumode`, plus the deferred `new_cmdpos` slot | +| Filetype | `:setfiletype` and the `did_filetype` transitions; the destructed-callback class at zero | +| Registers and key notation | E523 invalid-special-key class gone; `test_registers` 32 to 29 failed | +| Abbreviations | `:abbreviate` family on the mapping engine; `test_mapping` 46 to 36 failed | +| Fold and diff | model fixes; 24 of 24 diff tests pass | +| Highlight | startup table built from the oracle set: 388 visible, 11 hidden | +| Regex | a lone `[` is literal; E944, E769, and E54 arrive through `CompileError::code()` | + +## Defects found and not yet fixed + +1. Five oldtest files exceed the runner's five-minute cap before executing a test: `test_plugin_termdebug.vim`, + `test_tabpage.vim`, `test_functions.vim`, `test_put.vim`, and `test_regexp_latin.vim` (crash). The `:edit` + hang that used to head this list is fixed: `fnamemodify('foo', ':h')` answered an empty string instead of + ".", so `vim.fs.parents` walked forever inside a buffer-lifecycle autocmd. +2. `:terminal` and the upstream oldtest Makefile path need terminal output parsed into buffer cells. +3. `call()` and the `uniq()` comparator are implemented, and a Partial's dictionary now binds `self`, + which also fixed `function('Name', dict)()`. The Ex commands listed below remain unimplemented. +4. `:match`, `:compiler`, `:checktime`, `:doautocmd`, `:badd`, `recover`, `defer`, `win_execute`, and + `getfperm` still report `not implemented`. +5. Window creation copies the source window's local options; the oracle gives the new window global defaults. +6. `:new`/`:split` aside, functional-suite failures need their own census: the harness aborts early on most + files, and the group logs now carry per-test lines the counter can read. + +## Measurement traps met in this run + +- `$TEST_FILTER` is matched with `v:val =~ pattern`, so alternation is Vim's `\|`, not `|`. + A probe written `^Test_(a|b)$` selects nothing and reports success; every filter in this + campaign that produced a verdict used one anchored name or a character class. +- A census binary copied outside `target/release` cannot find the runtime tree and reports + every file as blocked with zero tests. `tests/oldtest/run.sh` uses the in-tree binary. +- `timeout -s TERM` does not stop a hung oxvim cleanly in the shared PTY; harness probes that + need a hard cap use `start_new_session` and a kill. + +## Slices cut for the next wave, 2026-10-28 + +Six read-only scouts grouped the remaining failures from the logs. Measured causes, with owners: + +| Suite | Cause | Tests | Owner | +|---|---|---|---| +| functional | assertion mismatch, no error code | 612 | diffuse; options, keymap, buffer, highlight | +| functional | harness module `test.functional.testnvim.exec_lua` not found | 418 | harness search path, not the editor | +| functional | `expect_msg_seq` got no messages | 187 | `ox-loop` notification path | +| functional | `not implemented: sockconnect` | 96 | channel builtins in `builtins/process.rs` | +| functional | server EOF or RPC failure at startup | 88 | `crates/oxvim/src/main.rs` | +| functional | `not implemented: rpcnotify` / `rpcrequest` / `chanclose` / `jobclose` | 74 + 52 + 38 + 31 | same channel owner | +| functional | `msgpackparse` / `msgpackdump` | 41 | `crates/ox-eval` typval dispatch | +| functional | `Invalid method: nvim__stats` | 29 | `ox-api` registry | +| oldtest | `E492: Not an editor command` | 380-450 | `excmd_exec.rs` dispatch table | +| oldtest | `E117: not implemented: ` | 280-340 | builtin families | +| oldtest | value mismatch, no error code | 700-900 | diffuse | + +Two more findings matter for the harness. Eight of the sixteen functional groups ended in `Process +terminated due to timeout`, so the 4076-failure figure is a floor and the gate needs a longer or +per-file run before its counts can be trusted. + +The terminal surface has the parts but not the wiring. `terminal_screen.rs` holds a VT emulator with +scrollback, `job.rs` pumps pty output, and `editor.rs` projects rows into the buffer, yet a probe of +`:terminal echo hello-from-terminal` followed by `getline(1, '$')` returns blank lines, so the child's +output never reaches the buffer in headless mode. The next terminal slice is to trace that path from +the pty read to the row projection, not to write a new emulator. + +## Parity fixes landed after the slice cut, 2026-10-28 + +| Commit | Surface | What changed | Proof | +|---|---|---|---| +| 8746163 | `package.path` | Relative loader entries are kept, as the oracle keeps them; `cpath` is left alone | `require('test.functional.testnvim.exec_lua')` answers true in a harness-shaped tree; the 418 module-not-found failures disappear from the api and core group logs | +| 3fa6889 | `:terminal` | Buffer gets `buftype=terminal` at attach; `:sleep` pumps the job reactor | `:terminal echo hello-from-terminal` + `:sleep 1` leaves the text and `[Process exited 0]`; with the pump reverted the new test fails on a blank buffer | +| 5e8dbcc | `win_findbuf`, `getcmdwintype` | Both names answered E117 before | Same script on both binaries: `t=` and `ids=[1001]` | +| 56f083b | normal-mode put | Count overflow answers E1240 before allocating | `test_put.vim` moves from a 300 s timeout, 0 executed, to 34 executed, 25 failed; both `assert_fails` cases pass | +| 7c0a007 | `vim.v.exiting` | Reads `vim.NIL` until shutdown, as upstream does | `vim.wait(200, fn, 10)` runs 20 iterations, matching the oracle; it ran 3 before | +| 8ebdd32 | Lua wait path | `loop_poll` flushes terminal jobs through the free executor slot | `crates/oxvim/tests/wait_pty_pump.rs`, 1 test, passes | + +`just apidiff` reports `schemas match`, and `oxvim --api-info` is +byte-identical to the oracle's document (32701 bytes each). + +## Gaps the parallel wave opened, with owners + +- `vim.wait` cannot pump terminal output while both executor slots are busy, + which is the case for a whole `-l` script; that needs the job reactor to be + reachable without the executor borrow. +- A `luafile`-hosted wait wakes about 4 times in 2 s where the oracle wakes + every interval; the `-l` path is correct, so the remaining difference is in + how the chunk is hosted. +- `:syntax` has no engine: 15 oldtest files and 8 functional files need it, + plus `synID`/`synIDattr`/`synIDtrans`/`synstack`/`synconcealed`. Scope + assessment recorded; not attempted. +- Confirmed absent from Neovim and correctly kept absent in oxvim after the + channel pass: `ch_status`, `chaninfo`, `term_start`, `term_getline`, + `term_getscrolled`. `termopen` exists upstream and is now implemented. +- Builtin inventory against upstream `eval.lua`: 462 upstream names, 312 + routed, 150 missing, 1 extra (`defer`). The table is in + `local://compat-builtin-gaps.md`. + +## Integration result + +The parallel wave landed in coherent commits: the five history tables +(`a76714b`), the put path plus the missing Ex command cluster (`aaa779d`), the +channel and terminal functions (`89f062b`), `vim._core.keyparse` with the Tab +termcode fix (`8ee39b8`), the abandon gate and bang modifiers (`eeae23f`), and +`cpfile`/`lpfile` with the forced-abandon unload (`68f8481`), then timers and +the seven missing queries (`6a0b5e2`), the listdo family with `:defer`, +`++once`/`++nested` and mid-sleep delivery (`c58b2ac`), closures running +against every live editor map (`f6c8a2e`), the census builtins (`cb4a034`), +and funcref delivery with `:checkhealth` and directory views (`6f62361`). +`cargo nextest run --workspace` was green at every landing; the last measured +count is 3769 passed, 1 skipped. + +Oldtest census, five files, before and after: + +| File | Before | After | +|---|---|---| +| test_put.vim | 25 failed | 7 failed | +| test_cdo.vim | 3 failed | 1 failed | +| test_winfixbuf.vim | 86 failed | 79 failed | +| test_user_func.vim | 23 failed | 19 failed | + +## Full functional gate + +`just functional` was run end to end and failed: the recipe exits 123 and group +logs end in `Process terminated due to timeout`. No group reported a clean +pass. The lua group was then re-measured alone on the binary that carries every +commit up to `42beb24`: 1460 tests ran in 876 s, 590 marked FAIL and 870 +pass-or-skip. The earlier census counted 611 failures for the same group with a +different parser, so treat the delta as approximate rather than exact. + +The raw log is `.outline/evidence/functional-lua-post.log`, and it carries ANSI +colour escapes, so a `^RUN` match sees nothing until they are stripped: + +```sh +sed -E 's/\x1b\[[0-9;]*m//g' .outline/evidence/functional-lua-post.log \ + | tr -d '\r' | awk '/^RUN /{ if ($0 ~ /FAIL[[:space:]]*$/) f++; else p++ } END { print f, p }' +``` + +This group result is a broad failure census — `luaeval` API errors, async +`_normalize_error`, syntax gaps and a timeout — and says nothing about +`vim._core.keyparse`, whose verification is the 19 oracle-identical chord +probes in `8ee39b8`. `oxvim --api-info` is byte-identical to the oracle and `just apidiff` +reports schemas match, so the remaining work is behavioural. + +## Defects the waves found and fixed + +- A Vimscript lambda body evaluated in its creation snapshot, so `let g:x` + inside it vanished and `g:`/`b:`/`w:`/`t:`/`v:`/`s:` reads were stale. +- A lambda timer or job callback redispatched by name where `N` is not + a user function, so it fired nothing and said nothing. +- The Lua callback shape (`vim.fn.jobstart` produces an empty name plus a + registry slot) that a prefix-only discriminator would have misrouted. +- A stopped timer removed synchronously, vanishing from `timer_info` before its + own callback ran (`42beb24`): it now stays visible until the delivery ends, + and `timer_stopall` from inside a callback follows the same rule (`79d9213`). +- ``/`` encoded as `K_SPECIAL` keys instead of their control bytes. +- A control byte naming itself `C-X`, which rendered `` as ``. + +## Runtime plugin loading, 2026-10-29 + +`:runtime` and `:packadd` sourced every plugin file as Vimscript, so +`runtime/pack/dist/opt/nvim.difftool/plugin/difftool.lua` was parsed as Ex +commands and failed on line 1 with `E488: Trailing characters: ~= nil then` — +six functional plugin specs. Both call sites now split on the extension like +upstream `source_callback` (`runtime.c:975-990`), and `v:vim_did_enter` is +seeded so the plugin's own `vim.v.vim_did_enter > 0` check compares a number. + +Re-running `test/functional/plugin/difftool_spec.lua`: the E488 failures are +gone (0 occurrences), and the six tests now fail deeper, at +`vim/_core/system.lua:240: attempt to call field 'pipe' (a nil value)`. + +`uv.pipe` and `pipe:open` now exist (`b58ba06`): the pair form answers +`{read = fd, write = fd}`, the no-argument form answers a pipe handle, and +`open` adopts a descriptor as a `ProcessPipe` with direction taken from its +access mode. The same commit makes `v:vim_did_enter` a transition — 0 through +startup, 1 from `VimEnter` — so a `-S` script sees 0 like the oracle while a +run-time `:packadd` sees 1. + +The difftool spec then stops at +`stdio entries must be pipe handles, false, nil, or descriptors 0, 1, or 2`, +raised in the `spawn` field and reached from +`runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua:121`: `uv.spawn` still +refuses an arbitrary descriptor number, which is what `system.lua` passes for +the child's stdout, so all seven difftool tests still fail (six of them on that +line). Adding the variant is not local: `StdioConfig` +is `Copy` and lives in `[StdioConfig; 3]`, and `SpawnOptions` derives `Clone` +and is destructured in two spawn paths, so an `OwnedFd` field breaks the +ownership model. A partial attempt was reverted rather than left half-wired. +The design is written up in `local://uv-spawn-stdio-brief.md`: a descriptor +registry in ox-lua (`uv.pipe` inserts both halves, `pipe:open` and `uv.spawn` +take them out, `uv.fs_close` retires them) feeding one new ox-uv entry point, +`spawn_with_owned_stdio`, that owns descriptors without touching the copyable +`StdioConfig` or the `Clone` spawn options. + +Update after `c9ebeca` and `fb3e2c4`: the registry exists and the stdio +rejection is gone. `TEST_FILE=test/functional/plugin/difftool_spec.lua` now runs +all seven tests in about 270 ms each instead of hanging for 240 s, and they fail +later: + +- T1 to T6: `difftool.lua:386` calls `cfirst`, which raises `E553: No more + items`, so the quickfix list is empty where upstream has entries. +- T7: `oxvim: Option not supported: "-d": requires a diff engine`, so the + diff-mode command line is not implemented. + +Evidence: `.outline/evidence/difftool-after-pipes.log`. + +Diagnosis of the remaining difftool failure, measured outside the test harness: + +- `vim.system({ 'diff', '-qrN', left, right }, { text = true, env = { LC_ALL = 'C' } }):wait()` + answers the right code and stdout, both with absolute and with relative + directory arguments, from a `-l` script. +- `vim.fn.executable('diff')` answers 1, so the plugin takes the `diffr` path. +- `vim.split`, the `Files 'a' and 'b' differ` pattern, `vim.fs.relpath`, + `vim.fs.abspath` and `setqflist` with `quickfixtextfunc` all answer what the + plugin expects, and a hand-built list of four entries survives `setqflist`. +- Resolved by `fix(quickfix): jump through a list whose entries are all + invalid` (`f0797e8`): the list was never empty. `set_items` held the entries, + but the port had no `qf_nonevalid` flag, so `:cfirst` searched for a valid + entry, found none (difftool items carry a filename but no line or pattern) and + answered `E553`. Upstream marks such a list and then ignores validity. With + that flag, `difftool_spec` answers T1, T3, T4 and T6 as passing, and T2, T5, + T7 fail on their own causes: split layout, autocmd cleanup, and `vim -d`. + Evidence: `.outline/evidence/difftool-nonevalid.log`. + +Update: the whole spec is green. `fix(layout): split the outermost frame for +botright windows` (`47625dd`) gives `:copen` the `WSP_BOT` split it needs, so +the list window spans the tabpage width and `getwininfo()` answers the columns +before the bottom row; T2 passes. `feat(startup): support -d and report +v:argf` (`3ca8241`) implements `-d`, seeds `v:argf`, and loads a directory +argument as the listing view `:edit ` shows; T7 passes. + +`test/functional/plugin/difftool_spec.lua`: seven of seven pass, in about +280 ms each. Evidence: `.outline/evidence/difftool-dashd.log`. +- Superseded hypothesis, not measured: the quickfix list is empty only when the plugin + runs from an Ex command. No probe yet records `vim.system` output or the + quickfix count inside a real Ex host, so executor starvation during + `:wait()` remains a candidate, in the same class as the `vim.wait` + terminal-pump gap, rather than a confirmed cause. +- `fix(uv): keep pipe descriptor reuse safe` (`f8da21d`) removes a stale-marker + hazard: registering a pair or taking a half now drops an awaiting-close + marker for the same number, and `close` clears both states. Two tests pin it, + `stale_marker_never_reports_a_second_close` and + `taking_a_number_clears_its_marker`, and both fail when the previous `close` + and `take` bodies are restored, so the tests bite on the defect rather than + passing by construction. +- `fix(uv): mark spawned descriptors only after the spawn starts` (`17a6c24`) + keeps a rejected `stdio` table from stranding a marker for a descriptor that + was released. + +Two defects surfaced while wiring the registry. `pipe:open` adopted the +descriptor without installing the reactor callback, so reads never reached Lua +and the child's output vanished. The readiness path then held the callback +state across the Lua call, so a callback that stopped its own pipe panicked with +`RefCell already borrowed`; the delivery path clones the callback and drops the +borrow first. The second has a regression test, +`pipe_read_callback_can_stop_its_own_pipe`. + +That is the next gap for this spec +and for anything else spawning with pipes through `vim.system()`. + +Plugin group re-measured on the current binary: the run did not finish inside +900 s (exit 124), and before it was killed 103 tests failed and 27 passed or +skipped. The earlier 117-failure figure came from a different parser on a +stale log, so neither number is a group total; treat this as a partial census. +The E488 parse cluster is gone (0 occurrences) and the failures now sit at +`uv.spawn` stdio, `E484` on directory edits outside the edit path, and +provider/host messages. + +## Still open + +- `just functional` fails (exit 123); no final full `just oldtest` run is + green. A 236-file baseline measured earlier stands at 3654 executed, 2365 + failed, 686 skipped, 40 blocked and 5 timed out, and the five-file table above + is a post-wave census, not that gate. +- Dict-watcher fire hooks are not attached to the lvalue and mutation sites, + so a watcher registers and validates but does not observe. +- `:checkhealth` cannot run a real provider re-entrantly and uses a native + fallback; `:menu`, `:syntax` and `:mkspell` remain absent. +- `vim.wait` cannot pump terminal jobs while both executor slots are busy. +- `cargo fmt --all --check` reports 199 diffs. A workspace-wide rustfmt sweep + was prepared and then dropped rather than published: its diff could not be + proven formatting-only, and the gate sweep belongs in its own reviewed + change. + +## Evidence + +All logs live under `.outline/evidence/` (gitignored): `gates/`, `oldtest-final/`, `functional-*.log`, +`nextest-final.log`, `final-apidiff.log`. Census and runner tools are in `.outline/evidence/tools/`: +`gates.sh`, `oldtest-census.sh`, `functional-census.sh`. + + +## Functional lua group re-measure (2026-10-29) + +Direct per-group run of `test/functional/lua` (the recipe's `TEST_PARALLEL_GROUP` +is only read by cmake, not by the justfile guard): + +- Result: `Process terminated due to timeout` at T840, + `vim.system (sync) handle input`. Partial counts before the stop: 412 FAIL, + 485 pass. The group has no summary until that test stops hanging. + Evidence: `.outline/evidence/functional-lua-post2.log`. +- Cause, found by probe: `vim.system({'cat'}, { stdin = 'hellocat' })` hung. + Three defects: `pipe_pair` created both ends without `O_CLOEXEC`, so the child + inherited a writer to its own stdin; a zero-length write never reported + completion, so the flush before shutdown stalled; and a pipe shutdown never + answered its callback, so the handle that ends the stream never closed. + Fixed in `b425af0`, with `pipe_pair_ends_are_close_on_exec` pinning the + descriptor flag. `/bin/cat` now exits, and the child's output arrives. +- `test/functional/lua/system_spec.lua` after the fixes: 13 of 16 pass, and the + file runs to completion instead of timing out. `44ba18e` gave `kill` signal + names, made a closed process's kill quiet, and matched upstream's + `cmd: expected non-empty list of strings` and `ENOENT ... (cmd|cwd)` text. + Remaining: `supports timeout` expects `wait(100)` to return the finished + result after `is_closing()` turns true, but the port reports `is_closing()` + at child exit, before the child's stdout pipe drains, so the short wait kills + a process that was still writing. Evidence: + `.outline/evidence/system-spec-post5.log`. + + +### Re-measure after the stdin fix + +`TEST_FILE=test/functional/lua timeout 1500 just functional` (the recipe only +honours the group switch through `TEST_FILE`), run to completion: + +| Result | Count | +|---|---| +| Tests run | 1460 | +| PASS | 852 | +| SKIP | 21 | +| FAIL | 574 | + +Before the fix the same group reported 590 FAIL and 870 pass-or-skip, so +sixteen tests moved to passing and the run no longer stops at a timeout. The +largest remaining clusters are `lua:` (99), `async` (73), plain `lua` (67), +`vim.snippet` (28), `luaeval()` (17), `vim.fs` (11), `vim.diagnostic` (11) and +`vim._watch` (11). Evidence: +`.outline/evidence/functional-lua-post3.log`. + + +### Control: `vim.system (sync) supports timeout` fails on the reference binary + +The one remaining failure in `test/functional/lua/system_spec.lua` is not a +port regression. Running the same test through the same harness, with +`NVIM_PRG` pointing at `.references/neovim/build/bin/nvim`: + +``` +RUN T1 vim.system (sync) supports timeout FAIL +Expected: code = 0 +Actual: code = 124 +RUN T2 vim.system (async) supports timeout OK +``` + +The `-l` probe agrees between the two binaries as well: both answer +`code = 124, stdout = "late"` for +`vim.system({'sh','-c','(sleep 0.3; printf late) & exit 0'})` when `wait(100)` +times out, so the port matches the reference here rather than diverging from +it. The sync expectation depends on the harness environment, not on the port. + + +### Correction: system_spec counts, and two remaining timeout failures + +The earlier `13 of 16` was wrong. `system_spec.lua` has 17 tests. Exact +outcomes after `ec0b16f` (`test/functional/lua/system_spec.lua`, +`.outline/evidence/system-spec-post6.log`): + +| Outcome | Count | +|---|---| +| PASS | 13 | +| SKIP | 1 (`always captures all content of stdout/stderr #30846`) | +| FAIL | 2 | + +The two failures, with reference-binary controls through the same harness: + +| Test | oxvim | reference `nvim` | Reading | +|---|---|---|---| +| `vim.system (sync) supports timeout` | FAIL, `code 124` where the test wants `0` | FAIL, same mismatch | harness expectation, not a port defect | +| `vim.system (async) supports timeout` | FAIL after 10.2 s | PASS in 1.15 s | open port defect | + +`ec0b16f` fixed a third one: `SystemObj:wait() does not process non-fast +events #27292` failed at 1261 ms before it and passes after, and the reference +passes it too. `vim._core.loop_poll` was draining the `vim.schedule` queue even +when `vim.wait` asked for fast events only. + +For the async timeout the cause is visible in `-l`: with +`vim.system({'sleep','10'}, {timeout=1000})`, `obj:wait()` returns at 1009 ms +with `code 124, signal 9`, while the expectation is `signal 15`. The object's +own timeout timer (`timer_oneshot`, `system.lua:352-360`) should fire first and +kill with SIGTERM; instead `wait` reaches its own deadline and kills with +SIGKILL. An extra zero-length poll after the deadline did not change that +(still `signal 9`), so it was reverted rather than left in the tree. + + +### Async timeout: timer precision at the deadline + +The remaining difference is sub-millisecond timer behaviour, measured on both +binaries with a timer due exactly at the wait deadline: + +```lua +local t = vim.uv.new_timer() +t:start(500, 0, function() fired = true end) +local ok = vim.wait(500, function() return fired end, nil, true) +``` + +| Binary | Timer fired | `vim.wait` | +|---|---|---| +| reference `nvim` | 499.95 ms | true | +| oxvim | 500.33 ms | false | + +The reference fires fractionally early, so `remaining` is still positive when +its poll returns and the predicate is checked again. The port fires about +0.3 ms late, so `vim.wait` reaches its post-poll deadline check first and +answers false even though the event has arrived. The same shift explains +`vim.system({'sleep','10'}, {timeout=1000})`: the object's own timeout timer +loses the race to `wait`'s deadline, and the kill therefore comes from +`wait`'s SIGKILL path (`signal 9`) instead of the object timer's SIGTERM +(`signal 15`). + +Two attempts were measured and reverted rather than left in the tree: an extra +zero-length poll after the deadline, and a two-millisecond margin on the wake +timer. Neither changed the outcome, and both touched the hot wait path without +proof. The fix belongs in timer expiry precision, not in `vim.wait`. + + +### keyparse coverage + +`vim._core.keyparse` (the Lua table `vim.keycode(keys, true)` builds on) now has +its own test, `keyparse_reports_modifiers_and_both_spellings` +(`crates/ox-lua/tests/uv_core.rs`), pinning the shape for a lowercase key, an +uppercase letter (which the oracle reports as `S`), and a control key. The +Rust-side oracle comparison is `keyparse_reports_the_oracle_chords` +(`crates/ox-eval/src/builtins_tests.rs`) with seven notations. There is no +upstream functional spec for `keyparse`; the reference test tree has none, so +the Lua group cannot carry this coverage. + + +### Dict watchers: why firing is not a two-line hook + +The registry is complete and already models upstream's in-flight rules +(`crates/ox-editor/src/userfunc.rs`: `busy`, `needs_free`, `WatchRound`, +`dictwatcher_begin`/`is_live`/`arguments`/`finish`). What is missing is the +notification calls. Address every upstream site, not just the obvious one: + +| Upstream site | Mutation | +|---|---| +| `eval/typval.c:2831`, `:2838`, `:2864` | `extend()` adds, replaces, and overwrites keys | +| `eval/typval.c:3447` | `remove()` deletes a key | +| `eval/vars.c:1694` | `unlet` and scope-dict removal | +| `buffer.c:4304` | a `b:` variable write | +| `lua/stdlib.c:395`, `:438` | the Lua table path that deletes and updates a key | + +`tv_dict_watcher_notify` (`eval/typval.c:2054`) passes three arguments: the +dictionary, the key, and a dictionary that carries `new` and/or `old`. A busy +watcher is skipped, so a callback that mutates the same dictionary cannot +re-enter that watcher, but other watchers in the same round still run. + +The port cannot call the hook from the mutation leaves as they stand. Firing a +Vimscript callback needs `&mut ExRuntime` plus `access`, `scope`, and `lua` +(`call_user_function_with_self`, `crates/ox-editor/src/excmd_exec.rs:5054`), +while the delete path is threaded as `&ExRuntime` from `remove_subscript_chain` +down to `dict_remove_key` (`crates/ox-editor/src/lvalue.rs:815-850`, `:911`), +and the set path (`dict_set_key`, `:880`) has `&mut` but none of the call +context. + +Two designs follow, and the faithful one costs more than it looks: + +1. Record events into a queue owned by `ExRuntime` and drain them after the Ex + command finishes. Small, keeps the borrow rule, but batches. One command + that writes two keys then fires both callbacks, where upstream fires each + callback at its own mutation and the callback can observe the intermediate + dictionary. +2. Thread the call context into the lvalue leaves and fire per mutation. This + matches upstream ordering, and it changes four signatures plus their + callers, in one coherent change with tests. + +Design 2 is the one to build; design 1 is cheaper and observably different, so +it is not a shortcut worth taking. The Lua-side `lua/stdlib.c` sites also need +the Lua-to-dict conversion path, which is separate work again. + + +### Plugin group: what the measurement now says + +Three specs were run alone, with the rebuilt binary: + +| Spec | Before | After | +|---|---|---| +| `plugin/cfilter_spec.lua` | 16 of 18 failed | 18 of 18 pass | +| `plugin/dir_spec.lua` | 32 of 38 failed | 31 of 38 failed | +| `plugin/editorconfig_spec.lua` | 11 of 12 failed | 11 of 12 failed | + +Two causes were found and fixed: + +- `` was left literal. `uc_check_code` (usercmd.c:1563) expands it to + `"!"` or `""`, and `pack/dist/opt/cfilter/plugin/cfilter.vim:61` builds a + `:call` expression from it, so every cfilter test died on E15. +- A URL was treated as a relative name. `vim_isAbsName` (path.c:1750) counts + one as absolute, so `nvim_buf_set_name` and `fnamemodify(':p')` must not + prepend the working directory. `dir_spec` T2 passes now. + +The remaining `dir_spec` cluster has one structural cause: `:edit ` is +implemented in Rust (`crates/ox-editor/src/excmd_exec.rs:13625-13740`, a port of +`runtime/lua/nvim/dir.lua`), while upstream calls the Lua module. So +`package.loaded['nvim.dir']` stays nil ("does not load the module until opening +a directory"), and the module's own nested autocmd behaviour around listings +never runs. Patching the Rust copy to imitate those three cases would add a +second owner for the same behaviour. The move that removes the divergence is to +delete the Rust listing and call `nvim.dir.try_open` (and +`nvim.dir.handle_startup_dirs` for a startup directory) through the existing Lua +host, which is what upstream does. + +`editorconfig_spec` fails for a different reason and is unmeasured beyond its +first assertions: `indent_size = 3` leaves `'shiftwidth'` at 8, so +`runtime/lua/editorconfig.lua` never applies. These specs run under the +functional harness, not `-u NONE`, so the probe has to go through the harness. + + +### Buffer names: API regression + +`buf_set_name_keeps_a_url_out_of_the_working_directory` (`crates/ox-api/src/tests.rs`) +now pins the boundary the `dir_spec` URI failure crosses: a URL survives +`nvim_buf_set_name` unchanged, and a relative name still becomes absolute. The +test fails with the guard removed and passes with it. + +### OptionSet: the event is declared but never fired + +`dir_spec` "triggers nested autocmds when opening directory buffers" and +"handles nested autocmds deleting the directory buffer" register +`autocmd OptionSet buftype` and read `v:option_new`. In this port the event name +exists (`crates/ox-editor/src/autocmd.rs:146`) but no code path fires it, so +those tests cannot pass however the listing is implemented. + +The contract to port is `apply_optionset_autocmd_now` (`option.c:2099-2134`): + +- it fires only when not starting up and not already inside an OptionSet + dispatch (`*get_vim_var_str(VV_OPTION_TYPE) != NUL` gates recursion); +- the pattern is the option's full name; +- it sets `v:option_new`, `v:option_old`, `v:option_oldlocal`, + `v:option_oldglobal`, `v:option_type` (`local` or `global`), and + `v:option_command` (`set`, `setlocal`, `setglobal`, or `modeline`), then + resets those variables afterwards. + +The port has three option setters (`Options::set_global`, `set_buffer`, +`set_window`, `crates/ox-editor/src/options.rs:333-420`) and no single place that +knows a change happened, which scope it took, and the old value. Firing needs +that knowledge, so the hook belongs above the setters, where the editor also +holds the autocmd executor. + + +### OptionSet: fired from the option API, still short of the spec + +`4f56421` adds the dispatch: `OptionSet` fires from `nvim_set_option_value` +with the option's full name as the pattern, the six `v:option_*` values +published for the callbacks and restored afterwards, a startup gate, and the +no-nesting gate from `option.c:2102`. The test +`set_option_value_fires_optionset_with_the_new_value` reads the values while the +callback runs and fails when the dispatch is removed. + +It does not move `dir_spec`: still 7 of 38 pass, and T3/T4 report no events. +The reason is the earlier finding, not this gap. Our `:edit ` renders the +listing in Rust, so `nvim.dir` never runs, `buftype` is never written through +the option API, and there is nothing to announce. Once the plugin drives the +listing, this dispatch is what makes its `buftype` write visible. + +`:set` and its relatives are not wired yet: `OptionAssignment` +(`excmd_exec.rs:5702-5712`) carries the committed value and whether it changed, +but not the old effective, local, and global values the event publishes. That +change has to widen the record first. + + +### OptionSet: all three setters now publish it + +| Path | Where | Test | +|---|---|---| +| `nvim_set_option_value` | `ox-api/src/global.rs` | `set_option_value_fires_optionset_with_the_new_value` | +| `nvim_set_option` (deprecated) | `ox-api/src/global.rs` | covered by the same seam | +| `:set`, `:setlocal`, `:setglobal` | `excmd_exec.rs` `fire_optionset_autocmd` | `set_fires_optionset_with_the_new_value` | + +Two details the implementation had to get right, both found by the tests: + +- the handlers read `v:` through the scope mirror, so the published values are + synced into the scope before the dispatch runs. Without that sync the handler + saw the previous, empty values; +- the assignment record had to carry the layer and both old values, because + `v:option_command`, `v:option_type`, `v:option_oldlocal`, and + `v:option_oldglobal` differ per scope (`option.c:2115-2129`). + +`dir_spec` is unchanged by this: 7 of 38 pass. Its T3 and T4 tests wait for +`buftype` to be written through the option API by `nvim.dir`, and this port +still renders the listing in Rust, so that write never happens. The listing +cutover is the change that makes those two tests reachable. + + +### The three open gaps, with the constraint that blocks each + +**Terminal output during `vim.wait` when both executors are busy.** The pump +already drains PTY output and deferred job events from whichever executor slot +it can borrow (`crates/oxvim/src/server.rs:6318-6360`). When both slots are +held by the enclosing frames, nothing can be drained, because the bytes live in +`ExRuntime.jobs` and `flush_pty_output` (`excmd_exec.rs:1733`) needs `&mut +ExExecutor`. The editor itself is reachable from the session at that point, so +the blocker is ownership, not access. The bytes live in a private +`Job.pty_output: Vec` (`job.rs:309`) and leave it only through +`take_all_pty_output`, which needs `&mut JobManager` (`job.rs:1230`). A second +drain would fork the queue, so this state has to move out of the executors into +session-owned state before the pump can serve a fully busy editor, and that move +needs its own regression test. + +**Dict watchers.** The registry and the firing round are complete +(`userfunc.rs`), and nothing calls them. The delete path is threaded as +`&ExRuntime` from `remove_subscript_chain` down to `dict_remove_key` +(`lvalue.rs:815-850`, `:911`), while firing needs `&mut ExRuntime` plus +`access`, `scope`, and `lua` (`excmd_exec.rs:5054`). The Ex-side `:set` +dispatch added for `OptionSet` is the shape that fits here: a command-level +hook that reads the record and dispatches with the full context. + +**`:checkhealth` providers.** `:checkhealth` routes to +`require('vim.health')._check` when a Lua host answers +(`excmd_exec.rs:17071-17076`) and falls back to the native report when the host +cannot run the module re-entrantly. The functional `health_spec` failures report +`E5108: Lua callbacks are not installed` at `testnvim.lua:144`, which is a +client-side call, so the provider call itself is not proven to be the failing +one. Two mechanisms fit and a measurement separates them: run the same +`:checkhealth` through the embedded server with only one executor slot occupied, +and see whether the providers run. Until then the cause stays unconfirmed. + +Host availability is ruled out for one evidence set only, and the chain was +checked end to end: the oldtest runner invokes `oxvim ... --headless` +(`tests/oldtest/run.sh:75`), which reaches `server::run_stdio` +(`main.rs:141-143`), and that path installs a Lua host (`server.rs:636`). So the +99 E5108 hits in that census are real Lua errors. `run_batch` (`main.rs:134`, +selected by `-e`/`-s`) and `run_lua` (`main.rs:119-121`) do lack a host, and +those are the paths where a probe answers "no Ex-command host is installed". +The two evidence sets stay separate. + + +### Full `just oldtest`, measured + +One run over all 236 files, 340 seconds, exit 1: + +| Outcome | Count | +|---|---| +| executed | 3764 | +| failed | 2364 | +| skipped | 693 | +| clean files | 11 | +| blocked | 39 | +| timeout or crash | 3 | + +The gate is measured, not closed. The failures cluster by error code rather +than by file, so one cause carries many files: + +| Error | Count | Sample | +|---|---|---| +| E605 (wrapped inner error) | 243 | `Vim(argdelete):E480: No match: x` | +| E492 not an editor command | 221 | `:syntax`, `:diffsplit`, `:match`, `:recover`, `:compiler`, `:helptags`, `:doautocmd`, `:filter`, `:tabs`, `:saveas`, `:checkpath`, `:breakadd` | +| E117 unknown function | 157 | `s:LoadFTPlugin` from `runtime/ftplugin.vim`, `g:foo#bar.echo` | +| E488 trailing characters | 114 | command forms that take arguments we reject | +| E474 invalid argument | 107 | option values | +| E5108 Lua call failed | 99 | see below; three different causes in one code | +| E806 variable scope errors | 97 | | +| E518 unknown option | 95 | options the metadata lacks | +| E121 undefined variable | 90 | | + +Worst files by failed count: `test_cmdline.vim` (364), `test_winfixbuf.vim` +(79), `test_ins_complete.vim` (104 executed, 22 failed of 141), +`test_normal.vim` (91), `test_options.vim` (63), `test_edit.vim` (59), +`test_diffmode.vim` (45), `test_search.vim` (52), `test_filetype.vim` (70), +`test_window_cmd.vim` (49). + +E5108 is one code with several causes, so it is not a single cluster. Three +samples from the same run: + +- `luaeval()` calling a nil value (`Test_cmdmod_completion`); +- `s:LoadFTPlugin` unknown, raised through `runtime/filetype.lua:27` + (`Test_compiler_spotbugs_properties`), so this one shares the E117 cause; +- `test_disable_char_avail` undefined symbol in the binary + (`Test_cmdcomplete_info`), which the test harness itself overrides. + +The E492 count also over-reports missing built-ins: its sample includes +user-defined names such as `xxx`, `one`, and `foo2`, which tests register +themselves and which are expected to fail. Any repair batch has to compare each +name against the command registry and the reference test first. + +A minimal reproduction of one E117 sample (`s:Hello()` called from an `au +FileType` defined in the same script) answers `yes` on both binaries, so that +cluster comes from a different script-context path than the simple case and +needs its own reproducer before any fix. + + +### `s:` in autocmd bodies: measured effect + +`a20c5e0` makes an autocmd body run under the script that defined it. The +`runtime/ftplugin.vim` failure (`au FileType * call s:LoadFTPlugin()`) no longer +appears in the oldtest messages at all, and one file moved a long way. + +| Measure | Before | After | +|---|---|---| +| executed | 3764 | 3285 | +| failed | 2364 | 2055 | +| skipped | 693 | 571 | +| blocked | 39 | 45 | +| clean files | 11 | 11 | +| `test_cmdline.vim` failed | 364 | 121 | + +Both runs are full `just oldtest` passes over the same 236 files. The failure +count drops by 309; the block count rises by 6, so part of the change is tests +that now reach a later, unsupported feature instead of failing at the handler. +`test_cmdline.vim` accounts for 243 of the drop. + + +### `:doautocmd` + +`49a2ebc` adds `:doautocmd [group] {event}[,{event}] [fname]` +(`do_doautocmd`, autocmd.c:1141), which the port lacked entirely. It rejects +`*` with E217, fails before firing on an unknown name (E216), accepts +`` without running modelines, and reports +"No matching autocommands: {arg}" when nothing matched. The regression covers a +firing event and a miss. Red proof, run in both states: with the dispatch +replaced by `Flow::Normal`, the regression fails; with the command restored it +passes. + +`45d2c27` corrected three details against the reference after review: +`` is a prefix (`autocmd.c:1244`), a run that matches nothing is +silent because `ex_doautocmd` passes `do_msg = false` (`ex_docmd.c:4527`), and +E216 names the group only when one was given (`autocmd.c:2467`). The regression +now covers an explicit file name, a name the pattern rejects, the +`` prefix, group restriction, E217, and both E216 texts. Modeline +execution remains out of scope: this port does not run modelines. + +This is the first item from the E492 census. The census itself over-counts +missing built-ins because it also contains user-defined names such as `xxx`, +`one`, and `foo2`, so each remaining candidate still needs a registry check +before it is implemented. + + +### `ex_cmds` functional group, partial measurement + +This is a timed-out partial measurement, not a completed group. One run, +1200 seconds, exit 1: the runner killed the group while `:write appends FIFO +file` (T435, `write_spec.lua`) was still running, so no group summary exists +and both counts below are lower bounds. + +| Outcome | Count | +|---|---| +| run | 405 | +| OK | 54 | +| FAIL | 343 | + +Worst specs, failures over tests: + +| Spec | Failing | +|---|---| +| `ex_cmds/dict_notifications_spec.lua` | 64 of 70 | +| `ex_cmds/echo_spec.lua` | 39 of 41 | +| `ex_cmds/cd_spec.lua` | 32 of 36 | +| `ex_cmds/mksession_spec.lua` | 18 of 18 | +| `ex_cmds/swapfile_preserve_recover_spec.lua` | 17 of 17 | +| `ex_cmds/help_spec.lua` | 15 of 16 | +| `ex_cmds/pack_spec.lua` | 15 of 15 | +| `ex_cmds/cmd_map_spec.lua` | 15 of 15 | + +The last test reached is T435 `:write appends FIFO file`; the log has no +per-group total line because the process died at the timeout. + +`dict_notifications_spec` is the largest single cluster here, and it is exactly +the dict-watcher gap: the registry and its firing round exist, and nothing calls +them. That makes the watcher hook the highest-value next change in this group, +not a small one: a watcher callback dispatches through four live forms +(`Name`, `Funcref`, `Partial`, and a removed watcher, `userfunc.rs:82-92`), and +the round must mark, check, and finish around user code that can reenter. + + +### Dict watchers: the design that fits the port + +More is known now than when the gap was first recorded: + +- `WatchCallback` has no Lua form (`userfunc.rs:82-92`): a name, a `Funcref`, a + `Partial`, or `None`. So firing needs no Lua host for the callback itself. +- The dispatch helpers still take `Option<&Rc>` as a parameter + (`call_user_function_with_self`, `excmd_exec.rs:5054`; the timer path's + `EvalHost`, `excmd_exec.rs:1385-1400`), and lvalue has no access to it. +- `dict_write_key` holds `try_borrow_mut` until it returns + (`lvalue.rs:886-908`), and upstream passes the replaced value as `old` while + omitting `old` for a newly added key (`tv_dict_watcher_notify`, + typval.c:2054). + +That fixes the shape: the leaf records `(dict, key, old, new)` into a queue on +`ExRuntime`, and `ExExecutor` drains it at the end of the command, where +`self.lua`, `access`, and `scope` all exist. The round then runs +`dictwatcher_begin` → `is_live` → invoke → `dictwatcher_arguments` → +`dictwatcher_finish` exactly as `userfunc.rs:475-535` expects, so a callback +that removes a watcher or mutates the dictionary mid-round stays consistent. + +Ordering is per command rather than per mutation. For `:let d.a = 1 | let +d.b = 2` that is still one fire per write, because the `|` separator runs two +commands; the divergence appears only if a single command writes one dictionary +twice, and that case needs a test before this lands. + + +### Scope-dict subscripts: the first failure in `dict_notifications_spec` + +Reproduced outside the harness: + +```vim +let g:["probe"] = 42 +call writefile([string(get(g:, "probe", "missing"))], "/tmp/scope.txt") +``` + +The reference writes `42`. This port aborts line 1 with +`E121: Undefined variable: g:`, so none of the spec's 70 tests can reach its +assertions, and the watcher hook is not what fixes that cluster first. + +One attempt was made and reverted rather than left half-wired: teaching +`bind_lvalue` (`lvalue.rs:531`) to accept `g:`/`b:`/`w:`/`t:` with subscripts by +resolving the scope dictionary. The binary still reported E121, so the binder at +`lvalue.rs:531` is not the one the `:let` path uses for this spelling. The +remaining candidates are the `:let` target resolution in `command_let` +(`excmd_exec.rs:5402`) and its `split_assignment` (`excmd_exec.rs:22412`), and +the E121 text renders the name as `g` rather than `g:`, which points at the +name-handling step before either. + + +### Scope-dict subscripts: fixed and verified + +`g:['k'] = v` and `unlet g:['k']` address the scope dictionary itself +(`get_lval`), and only a bare prefix is E461. Both failed: the lvalue bound as a +plain variable called `g:` (E121), and the removal went through the snapshot +`scope_dict` returns. + +| Probe | Before | After | Reference | +|---|---|---|---| +| `let g:["probe"] = 42`, then `get(g:, 'probe')` | no value | `42` | `42` | +| `unlet g:["probe"]`, then `exists("g:probe")` | `1` | `0` | `0` | + +| Test | Red proof | +|---|---| +| `a_scope_dict_subscript_assignment_reaches_the_scope` | failed with E121 before `f359610` | +| `a_scope_dict_subscript_unlet_removes_the_variable` | failed with the `remove_pair` arm disabled, passed after restoring it | + +Both fixes are single-key forms. A multi-key path such as +`let g:['a']['b'] = 1` still resolves through the snapshot and is untested; +`dict_notifications_spec.lua` writes only single keys. + + +### `dict_notifications_spec` after the scope-dict fixes + +Partial run, 300 seconds, exit 124 (killed during T34): + +| Outcome | Count | +|---|---| +| run | 34 | +| OK | 0 | +| FAIL | 33 | + +Setup now succeeds where it used to abort on the first line, so the spec reaches +its assertions. The first failure is not the watcher: T1 reports +`Vim(call):E475: Invalid argument: Channel doesn't exist` from the spec's own +`rpcnotify`, and its `after_each` then reports +`Couldn't find a watcher matching key and callback`. T2 and later compare the +expected notification payload and get nothing, which is the watcher gap. + +So two causes remain, in this order: + +1. `rpcnotify` cannot reach the client channel. `process.rs:919` resolves ids + through the job list, while the client channel is registered in the session + (`session.rs:113`), so the two id spaces never meet. Every `rpcnotify` from + Vimscript fails with E475 until one lookup covers both. +2. The watchers never fire, which is the round described above. + +The run is a lower bound for the same timeout reason as the group measurement. + + +### `rpcnotify` to the client channel: the seam it needs + +`JobManager::rpc_notify` sends to a job, else to a socket, else errors +(`job.rs:982-988`); the client channel is neither, so `rpcnotify(1, ...)` from +Vimscript always reports E475. `ServerHost` is not the answer either: it +exposes only `start`, `stop`, and `list` (`server.rs:21-41`) and knows nothing +about transports. + +The shape that fits is the one `set_lua_exec` already uses: the manager gains an +embedder-installed sink, and the server installs it with a handle that writes a +`[2, event, params]` frame into its `ChannelOutput` (`server.rs:51`). No +`JobManager` fallback should pretend a transport channel is a job. + + +### `:checkhealth`: cause identified, not a missing host + +Measured from the captured plugin-group run plus code citations, without edits: + +- The group's first failure is **T76**, an assertion (`:checkhealth detects invalid + $VIMRUNTIME`: expected false, actual true), not an error. +- The first `E5108` is **T82** `:checkhealth vim.g.health`, raised at + `testnvim.lua:144` from `Vim(checkhealth)` in `health_spec.lua:82-88`, and it + repeats through T97. +- That string comes only from a `lua: None` path + (`excmd_exec.rs:18861-18865`, `run_lua_autocmd_callback`; same shape at + `20477-20479`). A busy executor pair prints a different text + (`"no free Ex executor for …"`, `server.rs:5189-5192`), so re-entrancy is not + this failure. Both executors are configured with hosts + (`server.rs:636-637`, `4756-4770`). +- `require('vim.health')` loads and its table works: T80 passes + (`health_spec.lua:68-76`). The gap is callback delivery into `:checkhealth`, + not a missing health API. +- Confirmation step, if wanted: at the `None` branch record `lua.is_some()` + with both `try_borrow` results; `None` confirms this caller, and only a + both-borrows-failed result plus the `"no free Ex executor…"` string would + revive the re-entrancy theory. + + +### Ex command inventory: upstream against the dispatch table + +Method: every name in `.references/neovim/src/nvim/ex_cmds.lua` (44-3459) against +the dispatch in `excmd_exec.rs:3734-4139`, exact-string rows, alias families kept +separate and multi-name arms expanded. + +| Set | Count | +|---|---| +| upstream names this port does not dispatch | 201 | +| names both accept | 363 | +| names this port accepts that upstream does not list as rows | 7 | + +The 7 are `b`, `bprev`, `cprev`, `lcexpr`, `lprev`, `tabc`, `tabn`. They read as +an artifact of the exact-row method rather than a defect: upstream reaches those +spellings through prefix matching, not as table rows, so this line is a +method note, not a finding. + +The 201 missing names carry oldtest evidence where the census has it: +`recover` 8, `diffsplit` 6, `helptags` 6, `match` 5, `checkpath` 4, `tabs` 4, +`>`, `~`, and `@` 3 each, `tabprevious` 3, then `breakadd`, `earlier`, `goto`, +`pedit`, `psearch`, `rundo`, `wundo`, `arglocal`, `ascii`, `ball`, `balt`, +`changes`, `diffpatch`, `folddoopen`, `grep`, `left`, `make`, `packloadall`, +`pbuffer`, `saveas`, `sargument`, `sball`, `sfind`, `tabmove`, `tags`, +`undolist`, `=` with 1 or 2 each. + +Cheapest five, by behaviour rather than popularity: `pwd`, `list`, `number`, +`uptime`, `tabs` — each reuses an existing path (`command_cd`, `command_print`, +`command_version`, the tab iteration) and adds no state. + + +### Round summary after the watcher, command, and sink work + +Landed commits in this round: `cef2725` (dict watchers, dict targets), +`bb7df47` (scope targets + registration through the evaluator identity), +`127e70a` and `d898b6b` (two mutation-proved test deletions), `72425a2` +(`:pwd`, `:list`, `:number`, `:uptime`, `:tabs`), `acbc643` (client channel +sink for `rpcnotify`). + +| Measure | Before round | After | +|---|---|---| +| workspace suite | 3793 | 3811 | +| `plugin/cfilter_spec` | 18 of 18 | 18 of 18 | +| `plugin/dir_spec` | 7 of 38 | 8 of 38 | +| `ex_cmds` group (partial, 1200 s timeout) | 54 OK / 343 FAIL of 405 | 58 OK / 339 FAIL of 405 | +| `dict_notifications_spec` (partial) | 0 OK / 33 FAIL of 34 | not yet re-measured with the sink built in | + +The `ex_cmds` numbers predate the client sink, because the run used the binary +built before `acbc643`; the `dict_notifications_spec` re-measure with the sink +is the next gate. + + +### `dict_notifications_spec` after the sink and the watcher round + +| Measure | Before round | After | +|---|---|---| +| tests run | 34 (killed at 300 s) | 70 (finished, exit 1) | +| OK | 0 | 26 | +| FAIL | 33 | 44 | + +The spec now runs to completion instead of dying at the harness timeout, and 26 +of its 70 tests pass. Two causes carried the earlier zero: `rpcnotify(1, ...)` +failed with E475 until the client sink landed (`acbc643`), and no watcher fired +until `cef2725`/`bb7df47`. + +The remaining 44 cluster on mutation routes this round did not cover: + +- `remove()` on a dictionary (T2, and the same test through `nvim_*_var` and + `vim.g.` in T3 and T4): `ox-eval`'s `remove()` builtin mutates a dictionary + outside the lvalue paths the watcher producer watches. +- `extend()` (T5): same reason, different builtin. +- key patterns, empty keys, and the change-sequence tests: pattern matching and + ordering once the above routes notify. +- `b:` remove: the scope variant of the first item. + +Each is a producer gap in `builtins.rs` (dictionary-builtin mutations), not a +round defect: the round itself is exercised by the tests that pass. + + +### Builtin producers: measured, and what remains + +`36779d3` made `remove()`, `extend()`, and `filter()` notify dictionary watchers. + +| Measure | Before | After | +|---|---|---| +| `dict_notifications_spec` OK | 26 | 28 | +| `ex_cmds` group OK (of 405) | 58 | 76 | + +The spec moves by only two tests, and the remaining failures still name +`remove()` and `extend()` on `g:`. That is the diagnosis: the spec mutates the +scope mapping itself (`remove(g:, 'k')`, `extend(g:, {...})`), not a dictionary +variable, so the new producers record a `WatchTarget::Dict` event while the +watcher is registered as `WatchTarget::Scope(Global)`. The builtin routes need +the same scope-identity mapping that `dictwatcheradd` already uses. + + +### `:checkhealth` after the host seam + +`a32f365` removed the E5108 failure mode: the health spec no longer contains a +single `E5108` line (it had 16 before), and the Lua callbacks registered on +`FileType/checkhealth` now run. + +Before, from the plugin-group run that covered this file: 41 failures and 16 +`E5108` lines. After, measured alone: 44 run, 4 OK, 40 FAIL, zero `E5108` +lines. The OK count before was not measured alone, so it is not claimed here. + +The remaining failures are assertions about behaviour the port has not matched, +starting with T1 `:checkhealth detects invalid $VIMRUNTIME` (expected false, +actual true) and T2 for `'runtimepath'`, then `getcompletion()` and cmdline +completion. Those are separate defects from the callback path. + + +### Why the remaining 42 stay red: the dict argument's identity + +The spec's callback refuses to notify unless the dictionary it receives is the +scope mapping itself: + +```vim +function! g:Changed(dict, key, value) + if a:dict isnot g: | throw 'invalid dict' | endif + call rpcnotify(g:channel, 'values', a:key, a:value) +endfunction +``` + +Probe on the real binary: a watcher on `g:` with a Vimscript callback that only +appends to a list fires correctly for `let g:['watched'] = 'test'` on both +oxvim and the reference, so the producer works. What fails is the identity: the +drain builds the dict argument with `scope.scope_dict(kind)`, which is a fresh +per-read snapshot, so `a:dict isnot g:` is true inside the callback and it +throws before notifying. + +The fix is structural: a scope namespace needs one stable dictionary identity +per session (upstream's `vimvardict`), with `set_scoped` keeping it current, +instead of a snapshot rebuilt on every evaluation. + + +### Scope identity: the fix that moved the spec + +`646eecc` caches one `DictRef` per scope kind for the session, so the dictionary +a watch callback receives is the object `g:` evaluates to. The probe logs +`identity-mismatch` before and `identity-ok` after; the reference logs +`identity-ok`. + +| `dict_notifications_spec` | Before | After | +|---|---|---| +| OK | 28 | 64 | +| FAIL | 42 | 6 | + +The six remaining failures, each its own cause: + +| Test | What it needs | +|---|---| +| `remove()` when updated with `nvim_*_var` | the scope producer for `nvim_set_var`/`nvim_del_var` writes, which go through the API rather than the Ex lvalue path | +| `remove()` when updated with `vim.g` | the same, through the Lua `vim.g` assignment path | +| `errors out when adding to v:_null_dict` | the null-dict guard raising the reference's error | +| `for b:changedtick` | `b:changedtick` as a synthetic scope key that notifies | +| `calls watcher deleted in callback` | a watcher removed by an earlier callback in the same round must not run | +| `tabpagebuflist() with dict watcher during buffer close/wipe` | watcher firing during window/tab teardown without a live executor | + + +### API variable writes now notify + +`99b92a0` routes `nvim_set_var`, `nvim_del_var`, `vim.g.x = ...`, and +`vim.g.x = nil` through `ExExecutor::set_global_var`/`del_global_var`, which +record a scope event and drain after the editor borrow is released. + +| `dict_notifications_spec` | Before | After | +|---|---|---| +| OK | 64 | 66 | +| FAIL | 6 | 4 | + +The four remaining failures: `v:_null_dict` write guard, `b:changedtick`, +a watcher deleted inside an earlier callback, and the `tabpagebuflist()` +teardown case. + +Recorded gap from the same work: watcher registrations are per executor, and a +`g:` write that lands on the nested executor drains against an empty watcher +map, so it notifies nobody where upstream notifies. One shared registry across +the pair is the fix, and it is a design change rather than a patch. + + +### Sanitizer gate: Miri on the foundation crate + +`cargo +nightly miri test -p ox-types`, nightly 2026-10-27, Miri 0.1.0: + +| Run | Result | +|---|---| +| default flags | 20 passed, 0 failed, then `error: memory leaked` for two allocations | +| `MIRIFLAGS=-Zmiri-ignore-leaks` | 20 passed, 0 failed, exit 0, no undefined behavior reported | + +The leak is real and expected: `clone_shares_container_and_equality_handles_cycles` +builds a self-referential `Rc>` cycle on purpose +(`typval.rs:495-500`), and `Rc` cannot free a cycle. It is a property of the data +model, not of a change in this round, and no `Undefined Behavior` diagnostic +appears in either run. + +Scope: `ox-types` only. A Miri run over `ox-editor` was started earlier and +killed after 15 minutes with an empty log, so it is not evidence; the wider +suite has not been checked under Miri. + + +### Watcher completion: 67 of 70 + +`e791900` shared the watcher registry across the executor pair, repaired +`v:_null_dict` through the editor-to-scope rebuild, and recorded API scoped +writes for `b:`/`w:`/`t:`. + +| `dict_notifications_spec` | Before | After | +|---|---|---| +| OK | 66 | 67 | +| FAIL | 4 | 3 | + +The null-dict case passes. Three remain, each with a measured cause: + +- `for b:changedtick`: the unit test passes through buffer-family builtins, but + the spec mutates with `insert('t')`, an input-path mutation the changedtick + recorder does not cover. +- `calls watcher deleted in callback`: still red after the shared registry. +- `tabpagebuflist()` teardown: still red; it needs watcher delivery during + window/tab teardown, not only during a command. + + +### `ex_cmds` group after the watcher completion + +Same command, same 2400 s cap, run against the binary built from `e791900`: + +| Measure | Before watcher completion | After | +|---|---|---| +| OK | 76 | 125 | +| FAIL | 321 | 272 | +| run | 405 | 405 | + +The 49-test jump comes from the shared watcher registry and the API scoped +writes, not from a new command: `dict_notifications_spec` alone accounts for one +of them, so the rest are group tests whose setup or teardown needed a watcher +notification to arrive. + + +### `b:changedtick` for insert-mode text + +`81e7e73` moved tick recording to command boundaries: per dispatch in +`run_instructions`, around the typeahead drain in `run_typeahead` (which is what +`insert('t')` needs), and in `call_builtin`. The old `EvalHost::call` wrapper was +deleted because it ran inside dispatch and would have double-fired. + +| `dict_notifications_spec` | Before | After | +|---|---|---| +| OK | 67 | 68 | +| FAIL | 3 | 2 | + +The two remaining failures both fail identically at baseline and are unrelated +to the tick: `calls watcher deleted in callback` and the `tabpagebuflist()` +teardown case. + + +### Mid-round deletion: 69 of 70 + +`644dba0` keeps a watcher that was deleted by a callback inside the current +round, matching `tv_dict_watcher_remove` (`eval/typval.c:1991-2024`) which only +marks `needs_free` while busy, and `tv_dict_watcher_notify` (`:2090`) which skips +only a busy watcher. The sweep now runs at round end. + +| `dict_notifications_spec` | Before | After | +|---|---|---| +| OK | 68 | 69 | +| FAIL | 2 | 1 | + +The last failure is `tabpagebuflist() with dict watcher during buffer close/wipe` +(grow a watcher during window/tab teardown, `E37` on `close`). It fails the same +way on the pristine baseline, so it is a separate teardown defect rather than a +regression from the watcher work. + + +### `dict_notifications_spec`: 70 of 70 + +`e85d433` fixed the last case: `:close` from a watcher fired during buffer +teardown refused with E37 because the port ignored 'hidden'. `:close` now +mirrors `ex_win_close`'s `need_hide` decision, and `:quit` leaving the last +window keeps `check_changed_any`, so a modified buffer still blocks `:q`. + +| `dict_notifications_spec` | Round start | Now | +|---|---|---| +| OK | 0 (34 run, killed at 300 s) | 70 | +| FAIL | 33 | 0 | + + +### Functional gate progress: `cd_spec` and `echo_spec` + +| Spec | Before | After | Commit | +|---|---|---|---| +| `ex_cmds/cd_spec.lua` | 4 of 36 | 31 of 36 | `5625bfb` | +| `ex_cmds/echo_spec.lua` | 2 of 41 | 19 of 41 | `b550a51` | + +`cd_spec` needed the four directory scopes modelled separately (buffer < window < +tabpage < global); the port had collapsed them into two. Five remain: `:bcd` +stickiness, two temp-context cases, and `getcwd()` with `'autochdir'`. + +`echo_spec` had two dominant causes: a cyclic value stack-overflowed the daemon +during encoding, which also produced 16 spurious `RPC request after Nvim EOF` +failures, and `:echon` continuations gained a spurious newline. The remaining 22 +are grouped in the commit body: float rendering, funcref/partial display and +index counts, cycle-marker shape, control-character translation, and the `` +numbering offset. + + +### Functional gate: what each remaining cluster is blocked on + +| Spec | Status | Blocked on | +|---|---|---| +| `ex_cmds/cd_spec.lua` | 31 of 36 | `:bcd` stickiness, two temp-context cases, `getcwd()` with `'autochdir'` | +| `ex_cmds/echo_spec.lua` | 19 of 41 | float rendering, funcref/partial display and index counts, cycle-marker shape, control-character translation, `` offset | +| `ex_cmds/swapfile_preserve_recover_spec.lua` | 2 of 16 | `vim.uv.kill` (ox-lua), TUI exit rendering and grid truncation (ox-ui), the modal ATTENTION dialog | +| `plugin/dir_spec.lua` | 8 of 38 | `:edit ` is a Rust renderer where upstream drives `nvim.dir` from Lua | +| `ex_cmds/mksession_spec.lua` | 0 of 18 | no session writer | +| `ex_cmds/pack_spec.lua`, `menu_spec.lua`, `lsp_spec.lua` | 0 of 15, 0 of 14, 0 of 13 | `:packadd`/`:packloadall` state, the `:menu` tree, the LSP client | + +`8a99bd9` fixed a real defect on the swapfile path (`SwapFile::read` dropped the +block-zero metadata, so a live owner looked stale and W325 could not fire), which +moved one test; the rest of that spec needs the dialog and display layers. + + +### `dir_spec` and float rendering + +| Change | Commit | Measure | +|---|---|---| +| delete the Rust directory renderer; the `nvim.dir` plugin drives | `c9cd14d` | `dir_spec` 8 of 38 → 18 of 38 | +| render floats per `TYPVAL_ENCODE_CONV_FLOAT` | `b5edebc` | `echo_spec` float cases fixed in ox-types; the editor call sites still need to delegate | + +The 20 remaining `dir_spec` failures, each with its observed cause: + +| # | Failure | Cause | +|---|---|---| +| 1 | custom provider errors | the `notify_error` message never lands in `:messages` | +| 2 | failed open repeats the error | `vim.uv.getuid` is unimplemented | +| 3 | replaces listing providers | `maparg` dict lacks `buffer` | +| 4 | `[count]-` opens directories | the count loop runs past the parent | +| 5 | `(nvim-dir-up)` | path normalization keeps a `/./` segment | +| 6 | preserves modified unnamed buffer | the current buffer is not switched | +| 7 | does not shadow startup mappings | `vim.fs.mkdir` hits E745 | +| 8 | preserves alternate buffer | `bufnr('#')` returns -1 | +| 9, 13, 14 | open an entry from the listing | the window buffer does not switch | +| 10 | follows global `'hidden'` | the buffer stays loaded under `nohidden` | +| 11 | reloads directory buffers | `:syntax` is not implemented | +| 12 | reload of a removed directory | `ENOENT` message not captured | +| 15 | coexists with netrw | netrw's autoload hits E488 | +| 16-20 | `:browse edit/split/vsplit/tabedit/tabnew` | `:browse` is unsupported | + + +### `dir_spec` after the browse and maparg work + +| Change | Commit | `dir_spec` | +|---|---|---| +| cutover measured | `c9cd14d` | 18 of 38 | +| `:browse` rewrite plus `.`-component normalization plus `:tabnew` BufEnter | `ee9e5d6` | 23 of 38 | +| `maparg` callback dict and `buffer` flag | `0a6f41c` | 24 of 38 (the provider-replacement case passes) | + +The 14 remaining, grouped by cause: + +| Group | Cases | +|---|---| +| listing entry activation does not switch the window buffer | navigates entries, displays filenames as text and opens them, encodes special characters, preserves a modified unnamed buffer, preserves the alternate buffer, `(nvim-dir-up)` parent navigation | +| buffer lifecycle | follows global `'hidden'` when abandoned | +| messages capture | provider errors, reload of a removed directory | +| missing subsystems | `:syntax` (`reloads directory buffers`), netrw coexistence (`E488` inside netrw's autoload), `vim.fs.mkdir` (`E745`), `[count]-` overshoot | + + +### `dir_spec` after the activation attempt: 25 of 38 + +`23754bb` fixed two cases and named the rest. Verified on the rebased tree with a +fresh release build: 25 OK, 13 FAIL. + +One row per failing test in that run: + +| Case | Cause | +|---|---| +| `reports custom listing provider errors` | the plugin's `notify_error` never reaches `:messages` | +| `failed directory open does not repeat the error on every BufEnter` | fails at setup for this case before the activation path | +| `maps [count]- to open directories` | the global working directory resolves to `/` | +| `preserves a modified unnamed buffer when opening the current directory` | past the guard now; fails at `bufnr('#')`, which is -1 | +| `does not shadow startup plugin mappings in directory buffers` | startup mapping precedence in a directory buffer | +| `preserves alternate buffer when opening a parent directory` | no alternate-buffer tracking | +| `navigates entries and refreshes the listing` | activating an entry leaves the listing buffer in the window | +| `follows global 'hidden' when abandoned` | the buffer stays loaded under `nohidden` | +| `reloads directory buffers` | `:syntax` is unimplemented | +| `reports an error and keeps the buffer when reloading a removed directory` | `ENOENT` message not captured | +| `displays filenames as buffer text and opens them from the buffer` | same activation failure | +| `encodes special filename characters in directory buffers` | same activation failure | +| `coexists with netrw and can be disabled` | netrw's autoload hits E488 | + +A previous attempt on the same task failed on an infrastructure 403 after 45 +minutes and was reverted; its residue was not merged. + + +### Alternate buffer, and a change reverted as a no-op + +`51b9a47` resolves `#` to the window's alternate buffer: `bufnr('#')`, `@#`, and +`:buffer #` now read the window state instead of matching a buffer literally +named `#`, a wiped handle is ignored, `%`/`#` refresh after a switch even when +the register bank did not change, and `:buffer #` without a live alternate +reports E94. Four tests cover it and `-p ox-editor` is green. + +It does not fix `dir_spec` T19 or T21: after a rebuild both still fail, so the +missing piece is producing the alternate state on the directory switch +(`:keepalt` is not honoured there), not reading it. + +A second change, `nvim_cmd`'s `magic.file`, was committed and then reverted. The +lane's own follow-up showed the old builder already passed file arguments through +unchanged, so the patch changed only argument validation and a bar refactor: no +runtime behaviour moved, and the commit message claiming a reinterpretation fix +was wrong. It is out of the tree; the honest finding is that `magic.file=false` +already behaved correctly and T25/T30/T31 fail on the activation path instead. + + +### `dir_spec`: 30 of 38 + +| Commit | Change | Measure | +|---|---|---| +| `00f9a7d` | `:keepalt` honoured on the switch path (`buffer.c:1717-1727`) | 25 → 27 | +| `ea7b9ce` | third executor tier for depth-three reentrant builtins; newline sentinel so `line\nbreak.txt` survives the command string | 27 → 30 | + +The activation failures were not a buffer-switch bug: opening a file fires +`BufReadPost`, the bundled editorconfig plugin calls `vim.fs.parents` → +`vim.fn.fnamemodify`, and with both pooled executors borrowed the builtin raised +E5108, which rolled the edit back to the directory buffer. + +The newline sentinel is a stopgap: `build_nvim_cmd` writes the command as a +string and the parser splits on newlines, so an argument with an embedded newline +cannot survive in one piece. The real fix is to carry arguments without +re-parsing a command string. + +Eight remain: message capture (2), `[count]-` overshoot, startup mapping +precedence, `'hidden'` policy, netrw coexistence, and `:syntax` — the last is a +subsystem, not a command: it needs a syntax loader, a `Syntax` autocmd, a group +registry, and `:syntax list`, since the failing case asserts a group defined by +`runtime/syntax/directory.vim`. + + +### `dir_spec`: 32 of 38 + +| Commit | Change | Cases | +|---|---|---| +| `51b596e` | message content read through `Message::text_lossy()`, so a notify message renders in `:messages` | T7, T28 pass | +| `c48d09a` | `:call` closes at its own parenthesis via the existing `find_closing_paren`; a trailing comment with parentheses no longer truncates arguments | netrw coexistence unblocked | +| `1f3b65b` | `uv` fs errors format as `{name}: {message}`, matching luv | ENOENT captured | + +Verified after the three commits: 32 OK, 6 FAIL, workspace 3879 green. + +The message change has **no unit test**: two attempts to write one at the API +level missed the chunk shape and were removed rather than kept red. Its evidence +is the functional T7 and T28 cases, which fail without it and pass with it. + +Six remain: `[count]-` overshoot, startup mapping precedence, `'hidden'` +policy, two reload cases (T8, T27), and `:syntax`, which is a subsystem. + + +### `dir_spec`: 35 of 38 + +| Commit | Change | Cases | +|---|---|---| +| `249eae9` | `buffer_release_for_edit` decides release from `'bufhidden'`, `'hidden'`, `:hide` (`buffer.c:4120-4133`) | hidden abandonment | +| `fa458ac` | editor filesystem builtins use the one number converter | `vim.fs.mkdir` | +| `d2ba915` | unrecognized `printf` specifier kept literally (`strings.c:2205-2220`) | netrw suffix format | +| `0b74ade`, `8792cba` | typed count cleared only for mappings that consume it | `[count]-` | +| `b7e25ae` | normal-mode `CTRL-^` queues `:buffer #` | failed open keeps its listing | +| `deed8f3` | five quote scanners share `skip_quoted` (`eval.c:3728-3735`) | netrw `:call` | +| `690d3ba` | lexer coverage for single-quoted trailing backslash | — | + +Suite: 3904 passed, 1 skipped. Verified on the release binary at `690d3ba`. + +Three remain: + +- `does not shadow startup plugin mappings` expects zero and gets one. +- `reloads directory buffers` needs `:syntax list` and a group a syntax file defines; the port has no syntax loader, so this is a subsystem. +- `coexists with netrw` now gets past the autoload's init section and dies in `netrw#Explore` at `E171: Missing :endif`. + + +### `dir_spec`: 36 of 38 + +| Commit | Change | Cases | +|---|---|---| +| `8978060` | `:unlet` ends at a bar, so `if ... \| unlet ... \| endif` closes | netrw `E171` | +| `708dbb7` | `mapcheck` answers a callback mapping's function reference (`mapping.c:2233-2245`); `runtime/lua/nvim/dir.lua` back to upstream text | startup mapping precedence | + +Two remain: + +- `reloads directory buffers` calls `syntax list directoryDirectory`, so it needs a syntax registry populated by `runtime/syntax/directory.vim`, plus a loader that sources syntax files. The port has none; `:syntax` accepts only `on`/`off`. +- `coexists with netrw and can be disabled` now reaches its second assertion: after `:Explore .`, the buffer reports `filetype=directory` where the reference reports `netrw`, so a second setter overrides netrw's. + + +### `dir_spec`: 37 of 38 + +The netrw case took five more defects out of the interpreter, each found by +instrumenting the bundled autoload and reading where it stopped: + +| Commit | Change | +|---|---| +| `435bd8d` | a trailing `"` comment is stripped from an `:if` condition | +| `9d79f27` | `@@=` is not the expression register, so `let @@= x` parses | +| `1a15c66` | `@@` names the unnamed register `"` | +| `3992e5d` | a function-local `const` is keyed by call frame, so a second call does not raise E46 | +| `8007299` | `readdir()` | +| `fd4ccc6` | a script's map survives nested `:source`, user commands, and Ex-string autocommands | +| `d4d8130` | `buflisted()` | + +Before these, `:Explore .` left `filetype` empty and one line; it now lists the +directory and sets `filetype=netrw`, as the reference does. + +One measurement trap, recorded because it cost time: a release binary built +before these commits reported a stale result, and the functional count was wrong +until it was rebuilt. + +The last case, `reloads directory buffers`, needs `:syntax list ` to +answer for a group that `runtime/syntax/directory.vim` defines. The port has no +syntax loader, no group registry, and `:syntax` accepts only `on`/`off`, so this +is a subsystem, not a command. + + +### `dir_spec`: 38 of 38 + +`:syntax` now has a real core: dispatch for `on`, `off`, `enable`, `clear`, +`list`, `match`, `keyword`, and `region`; a per-buffer group registry; the +reference's `list` layout; and loading of `$VIMRUNTIME/syntax/.vim` when a +buffer's `'syntax'` is set. Highlight attributes are not implemented, and nothing +is coloured. + +One change was made and reverted with evidence: the loader's +`filetype=directory` assignment is not in upstream (verified with `-u NONE`, +where the reference leaves the option empty and the port now matched), but +removing it cost three `dir_spec` cases because the bundled plugin needs the +option set before its own `FileType` handling runs. The revert is `1f92945`'s +inverse; the divergence and its cause are recorded here rather than dropped. + + +### Functional gate after the syntax round + +| Spec | Result | Change | +|---|---|---| +| `dir_spec` | 38 of 38 | syntax core | +| `cd_spec` | 35 of 36 | getcwd shortcut removed, `:edit!` reload, context-switch snapshot, 'autochdir' | +| `echo_spec` | 29 of 41 | `%g` floats, funcref and partial display | +| `fs_spec` | 47 of 56 | libuv error text | + +`cd_spec` was 13 of 36 at the start of this round. The cause was a live-mode +`getcwd` shortcut that answered every form with either the global or the focused +window's directory, ignoring the scope argument; removing it restored 18 cases. +The remaining case needs `shorten_fnames()`, which the port does not have. + +`echo_spec`'s remaining 12 are mostly the `String()` path: the shared formatter +now matches the reference for `:echo`, but several cases assert +`eval('String(...)')`, which still renders through the evaluator's own code. + + +### Functional gate at `ae7d5d3` + +| Spec | Result | +|---|---| +| `dir_spec` | 38 of 38 | +| `cd_spec` | 35 of 36 | +| `echo_spec` | 34 of 41 | +| `fs_spec` | 47 of 56 | + +`echo_spec` went 19 → 34 in this round: float `%g` formatting, funcref and +partial display, message spelling, and one formatter shared by `:echo` and +`string()`. + +Six of the seven remaining `echo_spec` cases are blocked by script numbering, not +by display: the spec asserts `1_Test2` and the port's user script is 2. The +port sources a different set of startup scripts than the reference, so the index +differs; matching it is startup-script parity work. + +The seventh is a value-model defect: `add(l, l)` stores a null rather than the +list, so `String(l)` reads `[v:null]` where the reference reads `[[...@0]]`. + +### Gate at `e691945` + +| Spec | Result | Remaining | +|---|---|---| +| `dir_spec` | 38 of 38 | none | +| `cd_spec` | 35 of 36 | buffer-local stickiness | +| `echo_spec` | 39 of 41 | T23, T26 | +| `fs_spec` | 47 of 56 | write/error paths | + +`echo_spec` moved 34 to 39 in three steps: one formatter for `string()`, a +self-reference kept through the API mirror, and a dictionary bound as `self` +when its member is callable. T18 also needed one anonymous string source per +session, which the reference gets from the `SID_STR` pseudo id. + +T23 and T26 now differ from the reference in dictionary iteration order alone. +The reference walks its hash table, so `{'z': 1, 'a': 2, 'm': 3}` prints as +`{'a': 2, 'z': 1, 'm': 3}` regardless of insertion order; this port prints +insertion order, and `keys()` agrees with it. Matching the reference means +storing dictionaries in a table with the same order, which reaches every +`echo`, `keys`, `items`, `values` and `for` loop over a dictionary. + +Measured defect class, not a formatter difference: dictionary iteration order. diff --git a/Cargo.lock b/Cargo.lock index 07d738bd..3b69d4d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,6 +213,7 @@ dependencies = [ "sha2", "similar", "sonic-rs", + "vt100", ] [[package]] @@ -601,6 +602,7 @@ dependencies = [ "ox-regex", "ox-sys", "ox-types", + "rmpv", "serde_json", "sha2", "stacker", @@ -637,6 +639,7 @@ dependencies = [ "mlua", "ox-api", "ox-editor", + "ox-eval", "ox-regex", "ox-rpc", "ox-types", diff --git a/crates/ox-api/src/api_function_names.rs b/crates/ox-api/src/api_function_names.rs index 4512ffc4..d4ae3f1c 100644 --- a/crates/ox-api/src/api_function_names.rs +++ b/crates/ox-api/src/api_function_names.rs @@ -1849,19 +1849,6 @@ pub(crate) const API_FUNCTIONS: &[FunctionMetadata] = &[ ("follow", TypeRef::Boolean, false), ], }, - FunctionMetadata { - name: "nvim_mcursor", - since: 15, - deprecated_since: None, - method: false, - fast: false, - textlock: false, - returns: TypeRef::Integer, - params: &[ - ("buf", TypeRef::Buffer, false), - ("pos", TypeRef::Named("ArrayOf(Integer, 2)"), false), - ], - }, FunctionMetadata { name: "nvim_get_color_by_name", since: 1, diff --git a/crates/ox-api/src/autocmd.rs b/crates/ox-api/src/autocmd.rs index 508f082c..ab1446dc 100644 --- a/crates/ox-api/src/autocmd.rs +++ b/crates/ox-api/src/autocmd.rs @@ -258,6 +258,61 @@ pub(crate) fn fire_filetype( result } +/// Plans and executes `Event::OptionSet` for one committed option change +/// (`apply_optionset_autocmd_now`, option.c:2099-2134). +/// +/// The option's full name is the match text. The six `v:option_*` values are +/// published before the plan runs and put back afterwards, whatever the +/// callbacks do, so a nested dispatch restores its own values on the way out +/// and never leaks them into the outer dispatch. +pub(crate) fn fire_option_set( + session: &ApiSession, + name: &str, + scope: ox_editor::OptionSetScope, + old: &Object, + old_local: Option<&Object>, + old_global: Option<&Object>, + new: &Object, +) -> Result<(), ApiError> { + // Upstream skips the event while startup runs (`starting`, + // option.c:2147) and while a dispatch is already in flight + // (`*get_vim_var_str(VV_OPTION_TYPE) != NUL`, option.c:2102). Startup in + // this port has finished exactly when `v:vim_did_enter` is 1, which + // upstream sets at the same point it clears `starting`. + let started = session.with_editor(|editor| { + matches!( + editor.vvars().get(&OxStr::from("vim_did_enter")), + Some(Object::Integer(1)) + ) + }); + let firing = session.with_state(|state| state.option_set_in_flight); + if !started || firing { + return Ok(()); + } + let saved = session.with_editor_mut(|editor| { + editor.publish_option_set(scope, old, old_local, old_global, new) + }); + session.with_state_mut(|state| state.option_set_in_flight = true); + let match_name = OxStr::from(name); + let buffer = session.with_editor(|editor| editor.current_buffer()); + let plan = session.with_editor_mut(|editor| { + editor.autocmds_mut().plan( + Event::OptionSet, + AutocmdContext { + buffer, + file_name: None, + match_name: Some(match_name.as_bytes()), + nested: true, + data: None, + }, + ) + }); + let result = execute_firing_plan(session, plan); + session.with_state_mut(|state| state.option_set_in_flight = false); + session.with_editor_mut(|editor| editor.restore_option_set(saved)); + result +} + fn text(value: &OxStr, what: &str) -> Result { String::from_utf8(value.0.clone()) .map_err(|_| ApiError::validation(format!("{what} must be valid UTF-8"))) @@ -525,7 +580,7 @@ fn autocmd_kind(opts: &Dict) -> Result { let callback = opts.get(&OxStr::from("callback")); match (command, callback) { (Some(Object::String(command)), None) => { - Ok(AutocmdKind::ExString(text(command, "command")?)) + Ok(AutocmdKind::ExString { source: text(command, "command")?, script: None }) } (None, Some(Object::LuaRef(callback))) => Ok(AutocmdKind::LuaCallback( u64::try_from(*callback).map_err(|_| ApiError::validation("Invalid 'callback'"))?, @@ -826,7 +881,7 @@ fn autocmd_definition(item: &AutocmdDefinition) -> Dict { entries.push(( OxStr::from("command"), match &item.kind { - AutocmdKind::ExString(command) => Object::String(OxStr::from(command.as_str())), + AutocmdKind::ExString { source: command, .. } => Object::String(OxStr::from(command.as_str())), AutocmdKind::VimscriptFunction(_) | AutocmdKind::LuaCallback(_) => { Object::String(OxStr::from("")) } @@ -845,7 +900,7 @@ fn autocmd_definition(item: &AutocmdDefinition) -> Dict { Object::LuaRef(i32::try_from(*callback).unwrap_or(i32::MAX)), )); } - AutocmdKind::ExString(_) => {} + AutocmdKind::ExString { .. } => {} } entries.push(( OxStr::from("pattern"), diff --git a/crates/ox-api/src/buffer.rs b/crates/ox-api/src/buffer.rs index 11304c8d..90395f55 100644 --- a/crates/ox-api/src/buffer.rs +++ b/crates/ox-api/src/buffer.rs @@ -867,6 +867,11 @@ pub fn nvim_buf_set_var( value: Object, ) -> Result<(), ApiError> { let buffer = resolve_buffer(session, buffer)?; + if crate::global::host_present(session) { + return crate::runtime::with_command_executor(session, |session, executor| { + executor.set_buffer_var(session, buffer, name, value) + }); + } session.with_editor_mut(|editor| { if name.as_bytes() == b"changedtick" { return Err(ApiError::validation(format!( @@ -899,6 +904,11 @@ pub fn nvim_buf_del_var( name: OxStr, ) -> Result<(), ApiError> { let buffer = resolve_buffer(session, buffer)?; + if crate::global::host_present(session) { + return crate::runtime::with_command_executor(session, |session, executor| { + executor.del_buffer_var(session, buffer, name) + }); + } session.with_editor_mut(|editor| { if name.as_bytes() == b"changedtick" { return Err(ApiError::validation(format!( @@ -1518,7 +1528,6 @@ mod tests { use std::cell::RefCell; use std::rc::Rc; - use super::*; #[test] @@ -1527,16 +1536,15 @@ mod tests { let buffer = editor.borrow_mut().create_buffer(true).unwrap(); let session = ApiSession::new(editor); - assert!(nvim_buf_attach( - &session, - buffer, - false, - Dict(vec![( - OxStr::from("on_bytes"), - Object::LuaRef(41), - )]), - ) - .unwrap()); + assert!( + nvim_buf_attach( + &session, + buffer, + false, + Dict(vec![(OxStr::from("on_bytes"), Object::LuaRef(41),)]), + ) + .unwrap() + ); assert!(nvim_buf_detach(&session, buffer).unwrap()); session.with_editor_mut(|editor| { diff --git a/crates/ox-api/src/context.rs b/crates/ox-api/src/context.rs index 95472656..6edb16a4 100644 --- a/crates/ox-api/src/context.rs +++ b/crates/ox-api/src/context.rs @@ -1,209 +1,238 @@ //! Editor context save/load API. -use ox_editor::{MarkTarget, RegisterContent, RegisterKind}; +use std::time::{SystemTime, UNIX_EPOCH}; use crate::session::ApiSession; use crate::{ApiError, Dict, Object, OxStr, Registry, RegistryError, api}; -const TYPES: [&str; 5] = ["regs", "jumps", "bufs", "gvars", "funcs"]; +#[path = "shada_codec.rs"] +mod shada_codec; -fn selected(opts: &Dict) -> Result, ApiError> { +#[cfg(test)] +pub(crate) use shada_codec::bytes_to_readfile_lines; + +const ALL_TYPES: [&str; 5] = ["regs", "jumps", "bufs", "gvars", "funcs"]; +const VALID_REQUEST_TYPES: [&str; 6] = ["regs", "jumps", "bufs", "gvars", "funcs", "sfuncs"]; + +fn object_type_name(value: &Object) -> &'static str { + match value { + Object::Nil => "nil", + Object::Boolean(_) => "Boolean", + Object::Integer(_) => "Integer", + Object::Float(_) => "Float", + Object::String(_) => "String", + Object::Array(_) => "Array", + Object::Dict(_) => "Dict", + Object::LuaRef(_) => "Function", + Object::Buffer(_) => "Buffer", + Object::Window(_) => "Window", + Object::Tabpage(_) => "Tabpage", + } +} + +fn selected_types(opts: &Dict) -> Result, ApiError> { + for (key, _) in opts.iter() { + if key.as_bytes() != b"types" { + return Err(ApiError::validation(format!( + "Invalid key: '{}'", + key.to_string_lossy() + ))); + } + } let Some(value) = opts.get(&OxStr::from("types")) else { - return Ok(TYPES.iter().map(ToString::to_string).collect()); + return Ok(ALL_TYPES.iter().map(ToString::to_string).collect()); }; let Object::Array(values) = value else { - return Err(ApiError::validation("types must be an Array")); + return Err(ApiError::validation(format!( + "Invalid 'types': expected Array, got {}", + object_type_name(value) + ))); }; - values - .iter() - .map(|value| { - let Object::String(value) = value else { - return Err(ApiError::validation("context type must be a String")); - }; - let name = String::from_utf8(value.0.clone()) - .map_err(|_| ApiError::validation("context type must be UTF-8"))?; - if !TYPES.contains(&name.as_str()) { - return Err(ApiError::validation(format!( - "Invalid context type: {name}" - ))); - } - Ok(name) - }) - .collect() -} - -fn register_type(kind: RegisterKind) -> OxStr { - match kind { - RegisterKind::CharacterWise => OxStr::from("v"), - RegisterKind::LineWise => OxStr::from("V"), - RegisterKind::BlockWise { .. } => OxStr::from("\u{16}"), + if values.is_empty() { + return Ok(ALL_TYPES.iter().map(ToString::to_string).collect()); + } + let mut requested = Vec::with_capacity(values.len()); + for item in values { + let Object::String(s) = item else { + return Err(ApiError::validation(format!( + "Invalid 'type': expected String, got {}", + object_type_name(item) + ))); + }; + let name = s.to_string_lossy().into_owned(); + if !VALID_REQUEST_TYPES.contains(&name.as_str()) { + return Err(ApiError::validation(format!("Invalid 'type': '{name}'"))); + } + requested.push(name); } + Ok(requested) } -fn save_registers(session: &ApiSession) -> Vec { - session.with_editor(|editor| { - ('0'..='9') - .chain('a'..='z') - .chain(['"', '-']) - .filter_map(|name| { - editor.registers().get(name).ok().flatten().map(|content| { - Object::Dict(Dict(vec![ - ( - OxStr::from("name"), - Object::String(OxStr::from(name.to_string().as_str())), - ), - ( - OxStr::from("type"), - Object::String(register_type(content.kind())), - ), - ( - OxStr::from("lines"), - Object::Array( - content - .lines() - .iter() - .map(|line| Object::String(OxStr::from(line.as_slice()))) - .collect(), - ), - ), - ])) - }) - }) - .collect() - }) -} +pub(crate) fn display_cells(line: &[u8]) -> usize { + use unicode_width::UnicodeWidthChar; -fn save_jumps(session: &ApiSession) -> Vec { - session.with_editor(|editor| { - editor - .jumplist() - .entries() - .iter() - .map(|jump| { - let mut values = vec![ - ( - OxStr::from("lnum"), - Object::Integer(i64::try_from(jump.position.lnum).unwrap_or(i64::MAX)), - ), - ( - OxStr::from("col"), - Object::Integer(i64::try_from(jump.position.col).unwrap_or(i64::MAX)), - ), - ]; - match &jump.target { - MarkTarget::Buffer(buffer) => { - values.push((OxStr::from("bufnr"), Object::Integer(i64::from(*buffer)))); - } - MarkTarget::File(path) => values.push(( - OxStr::from("filename"), - Object::String(OxStr::from(path.to_string_lossy().as_bytes())), - )), - } - Object::Dict(Dict(values)) - }) - .collect() - }) + let mut cells = 0; + let mut index = 0; + while index < line.len() { + let first = line[index]; + if first < 0x80 { + cells += match first { + 0x00..=0x08 | 0x0a..=0x1f | 0x7f => 2, + _ => 1, + }; + index += 1; + continue; + } + let width = match first { + 0xc2..=0xdf => 2, + 0xe0..=0xef => 3, + 0xf0..=0xf4 => 4, + _ => 1, + }; + if width > 1 + && index + width <= line.len() + && let Ok(text) = std::str::from_utf8(&line[index..index + width]) + { + for ch in text.chars() { + cells += ch.width().unwrap_or(1); + } + index += width; + continue; + } + cells += 4; + index += 1; + } + cells } + #[api(since = 6)] #[expect( clippy::needless_pass_by_value, reason = "the RPC dispatcher owns decoded context options" )] pub fn nvim_get_context(session: &ApiSession, opts: Dict) -> Result { - let selected = selected(&opts)?; - let mut values = Vec::new(); - for kind in selected { - let value = - match kind.as_str() { - "regs" => Object::Array(save_registers(session)), - "jumps" => Object::Array(save_jumps(session)), - "bufs" => Object::Array(session.with_editor(|editor| { - editor.buffers().into_iter().map(Object::Buffer).collect() - })), - "gvars" => Object::Dict(session.with_editor(|editor| editor.gvars().clone())), - "funcs" => Object::Array(Vec::new()), - _ => continue, - }; - values.push((OxStr::from(kind.as_str()), value)); - } - let context = Dict(values); - session.with_state_mut(|state| state.saved_context = Some(context.clone())); - Ok(context) -} + let requested = selected_types(&opts)?; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); -fn load_registers(session: &ApiSession, values: &[Object]) -> Result<(), ApiError> { - for value in values { - let Object::Dict(entry) = value else { - return Err(ApiError::validation( - "context register must be a Dictionary", - )); - }; - let Some(Object::String(name)) = entry.get(&OxStr::from("name")) else { - return Err(ApiError::validation("context register requires name")); - }; - let name = std::str::from_utf8(name.as_bytes()) - .ok() - .and_then(|name| name.chars().next()) - .ok_or_else(|| ApiError::validation("invalid context register name"))?; - let kind = match entry.get(&OxStr::from("type")) { - Some(Object::String(value)) if value.as_bytes() == b"V" => RegisterKind::LineWise, - Some(Object::String(value)) if value.as_bytes() == [0x16] => { - RegisterKind::BlockWise { width: 1 } + let mut values = Vec::with_capacity(ALL_TYPES.len()); + for &kind in &ALL_TYPES { + let is_requested = requested.iter().any(|item| item == kind); + let lines = if is_requested { + match kind { + "regs" => { + let entries = session.with_editor(|ed| { + ox_editor::shada::register_entries(ed, -1, timestamp) + }); + let bytes = shada_codec::write_shada_entries(&entries)?; + shada_codec::bytes_to_readfile_lines(&bytes) + } + "jumps" => { + let entries = + session.with_editor(|ed| ox_editor::shada::jump_entries(ed, timestamp)); + let bytes = shada_codec::write_shada_entries(&entries)?; + shada_codec::bytes_to_readfile_lines(&bytes) + } + "bufs" => { + let entry = session + .with_editor(|ed| ox_editor::shada::buffer_list_entry(ed, timestamp)); + let bytes = shada_codec::write_shada_entries(&[entry])?; + shada_codec::bytes_to_readfile_lines(&bytes) + } + "gvars" => { + let entries = + session.with_editor(|ed| ox_editor::shada::gvar_entries(ed, timestamp)); + let bytes = shada_codec::write_shada_entries(&entries)?; + shada_codec::bytes_to_readfile_lines(&bytes) + } + "funcs" => Vec::new(), + _ => Vec::new(), } - _ => RegisterKind::CharacterWise, - }; - let Some(Object::Array(lines)) = entry.get(&OxStr::from("lines")) else { - return Err(ApiError::validation("context register requires lines")); + } else { + Vec::new() }; - let lines = lines - .iter() - .map(|line| match line { - Object::String(line) => Ok(line.0.clone()), - _ => Err(ApiError::validation("register line must be a String")), - }) - .collect::, _>>()?; - let kind = match kind { - RegisterKind::BlockWise { .. } => RegisterKind::BlockWise { - width: lines.iter().map(Vec::len).max().unwrap_or(1).max(1), - }, - other => other, - }; - let content = RegisterContent::new(kind, lines) - .map_err(|error| ApiError::validation(error.to_string()))?; - session.with_editor_mut(|editor| { - editor - .registers_mut() - .set(name, content) - .map_err(|error| ApiError::validation(error.to_string())) - })?; + values.push((OxStr::from(kind), Object::Array(lines))); } - Ok(()) + let context = Dict(values); + session.with_state_mut(|state| state.saved_context = Some(context.clone())); + Ok(context) } #[api(since = 6)] pub fn nvim_load_context(session: &ApiSession, dict: Dict) -> Result { + let mut regs_bytes: Option> = None; + let mut jumps_bytes: Option> = None; + let mut bufs_bytes: Option> = None; + let mut gvars_bytes: Option> = None; + let mut funcs_cmds: Vec = Vec::new(); + for (key, value) in &dict.0 { - match (key.as_bytes(), value) { - (b"regs", Object::Array(values)) => load_registers(session, values)?, - (b"gvars", Object::Dict(values)) => { - session.with_editor_mut(|editor| *editor.gvars_mut() = values.clone()); + let Object::Array(lines) = value else { + continue; + }; + match key.as_bytes() { + b"regs" => { + regs_bytes = Some(shada_codec::readfile_lines_to_bytes(lines)?); + } + b"jumps" => { + jumps_bytes = Some(shada_codec::readfile_lines_to_bytes(lines)?); } - (b"jumps" | b"bufs" | b"funcs", Object::Array(_)) => {} - (b"regs" | b"gvars" | b"jumps" | b"bufs" | b"funcs", _) => { - return Err(ApiError::validation(format!( - "invalid context value for {}", - key.to_string_lossy() - ))); + b"bufs" => { + bufs_bytes = Some(shada_codec::readfile_lines_to_bytes(lines)?); } - _ => { - return Err(ApiError::validation(format!( - "Invalid context type: {}", - key.to_string_lossy() - ))); + b"gvars" => { + gvars_bytes = Some(shada_codec::readfile_lines_to_bytes(lines)?); } + b"funcs" => { + for item in lines { + if let Object::String(body) = item { + funcs_cmds.push(body.clone()); + } + } + } + _ => {} } } + + session.with_editor_mut(|editor| { + *editor.registers_mut() = ox_editor::Registers::new(); + }); + + for (bytes, type_id) in [ + (regs_bytes, 5_u64), + (jumps_bytes, 8_u64), + (bufs_bytes, 9_u64), + (gvars_bytes, 6_u64), + ] { + let Some(bytes) = bytes else { + continue; + }; + let Ok(entries) = shada_codec::read_shada_entries(&bytes) else { + continue; + }; + session.with_editor_mut(|editor| { + for entry in entries { + if entry.type_id != type_id { + continue; + } + match type_id { + 5 => ox_editor::shada::apply_register_entry(editor, &entry.data), + 8 => ox_editor::shada::apply_jump_entry(editor, &entry.data), + 9 => ox_editor::shada::apply_buffer_list_entry(editor, &entry.data), + 6 => ox_editor::shada::apply_gvar_entry(editor, &entry.data), + _ => {} + } + } + }); + } + + for cmd in funcs_cmds { + let _ = crate::global::nvim_command(session, cmd); + } + session.with_state_mut(|state| state.saved_context = Some(dict)); Ok(Object::Nil) } diff --git a/crates/ox-api/src/global.rs b/crates/ox-api/src/global.rs index 4149617e..4942f07c 100644 --- a/crates/ox-api/src/global.rs +++ b/crates/ox-api/src/global.rs @@ -4,8 +4,8 @@ use ox_editor::{ AutocmdContext, BufferRelease, Editor, EditorError, Event, FocusContainer, K_SPECIAL, KE_FILLER, KS_EXTRA, KS_MODIFIER, KS_SPECIAL, KS_ZERO, Keys, MOD_MASK_ALT, MOD_MASK_CTRL, MOD_MASK_META, MOD_MASK_SHIFT, Message, MessageKind, OptionError, OptionListKind, - OptionMetadata, OptionScope, OptionType, OptionValue, TypeaheadFlags, UserCommand, - focus_transition, + OptionMetadata, OptionScope, OptionSetScope, OptionType, OptionValue, TypeaheadFlags, + UserCommand, focus_transition, }; use ox_excmd::ExCommand; use ox_types::{Special, Typval}; @@ -135,6 +135,263 @@ pub trait CommandExecutor { args: Vec, ) -> Result; + /// Writes one `g:` variable through the live Ex evaluator, so a scope + /// watcher registered on `g:` fires with the change. + /// + /// The default keeps the legacy silent editor-map write for hosts + /// without Ex semantics; Ex-backed hosts override this to record a + /// `WatchTarget::Scope(Global)` event and drain it after the editor + /// borrow is released. + /// + /// # Errors + /// + /// Returns an API error when the host cannot perform the write. + fn set_global_var( + &mut self, + session: &ApiSession, + name: OxStr, + value: Object, + ) -> Result<(), ApiError> { + session.with_editor_mut(|editor| { + editor.gvars_mut().insert(name, value); + }); + Ok(()) + } + + /// Deletes one `g:` variable through the live Ex evaluator, so a scope + /// watcher registered on `g:` fires with the removed value. + /// + /// The default keeps the legacy silent editor-map removal for hosts + /// without Ex semantics; Ex-backed hosts override this to record a + /// `WatchTarget::Scope(Global)` event and drain it after the editor + /// borrow is released. + /// + /// # Errors + /// + /// Returns an API error when the key does not exist or the host cannot + /// perform the removal. + fn del_global_var(&mut self, session: &ApiSession, name: OxStr) -> Result<(), ApiError> { + session.with_editor_mut(|editor| { + let variables = editor.gvars_mut(); + let Some(index) = variables.iter().position(|(key, _)| key == &name) else { + return Err(ApiError::validation(format!( + "Key not found: {}", + name.to_string_lossy() + ))); + }; + variables.0.remove(index); + Ok(()) + }) + } + /// Writes one `b:` variable through the live Ex evaluator, so a scope + /// watcher registered on `b:` fires with the change. + /// + /// The default keeps the legacy silent editor-map write for hosts + /// without Ex semantics; Ex-backed hosts override this to record a + /// `WatchTarget::Scope(Buffer)` event and drain it after the editor + /// borrow is released. `buffer` is already resolved to a live buffer. + /// + /// # Errors + /// + /// Returns an API error when the key is read-only or locked, or the host + /// cannot perform the write. + fn set_buffer_var( + &mut self, + session: &ApiSession, + buffer: BufHandle, + name: OxStr, + value: Object, + ) -> Result<(), ApiError> { + session.with_editor_mut(|editor| { + if name.as_bytes() == b"changedtick" { + return Err(ApiError::validation(format!( + "Key is read-only: {}", + name.to_string_lossy() + ))); + } + let state = editor + .buffer_mut(buffer) + .map_err(|error| ApiError::validation(error.to_string()))?; + if state.is_var_locked(&name) { + return Err(ApiError::validation(format!( + "Key is locked: {}", + name.to_string_lossy() + ))); + } + state.variables_mut().insert(name, value); + Ok(()) + }) + } + + /// Deletes one `b:` variable through the live Ex evaluator, so a scope + /// watcher registered on `b:` fires with the removed value. + /// + /// The default keeps the legacy silent editor-map removal for hosts + /// without Ex semantics; Ex-backed hosts override this to record a + /// `WatchTarget::Scope(Buffer)` event and drain it after the editor + /// borrow is released. `buffer` is already resolved to a live buffer. + /// + /// # Errors + /// + /// Returns an API error when the key does not exist or the host cannot + /// perform the removal. + fn del_buffer_var( + &mut self, + session: &ApiSession, + buffer: BufHandle, + name: OxStr, + ) -> Result<(), ApiError> { + session.with_editor_mut(|editor| { + if name.as_bytes() == b"changedtick" { + return Err(ApiError::validation(format!( + "Key is read-only: {}", + name.to_string_lossy() + ))); + } + let state = editor + .buffer_mut(buffer) + .map_err(|error| ApiError::validation(error.to_string()))?; + if state.is_var_locked(&name) { + return Err(ApiError::validation(format!( + "Key is locked: {}", + name.to_string_lossy() + ))); + } + let variables = state.variables_mut(); + let Some(index) = variables.iter().position(|(key, _)| key == &name) else { + return Err(ApiError::validation(format!( + "Key not found: {}", + name.to_string_lossy() + ))); + }; + variables.0.remove(index); + Ok(()) + }) + } + + /// Writes one `w:` variable through the live Ex evaluator, so a scope + /// watcher registered on `w:` fires with the change. + /// + /// The default keeps the legacy silent editor-map write for hosts + /// without Ex semantics; Ex-backed hosts override this to record a + /// `WatchTarget::Scope(Window)` event and drain it after the editor + /// borrow is released. `win` is already resolved to a live window. + /// + /// # Errors + /// + /// Returns an API error when the host cannot perform the write. + fn set_window_var( + &mut self, + session: &ApiSession, + win: WinHandle, + name: OxStr, + value: Object, + ) -> Result<(), ApiError> { + session.with_editor_mut(|editor| { + editor + .window_variables_mut(win) + .map_err(|error| ApiError::exception(error.to_string()))? + .insert(name, value); + Ok(()) + }) + } + + /// Deletes one `w:` variable through the live Ex evaluator, so a scope + /// watcher registered on `w:` fires with the removed value. + /// + /// The default keeps the legacy silent editor-map removal for hosts + /// without Ex semantics; Ex-backed hosts override this to record a + /// `WatchTarget::Scope(Window)` event and drain it after the editor + /// borrow is released. `win` is already resolved to a live window. + /// + /// # Errors + /// + /// Returns an API error when the key does not exist or the host cannot + /// perform the removal. + fn del_window_var( + &mut self, + session: &ApiSession, + win: WinHandle, + name: OxStr, + ) -> Result<(), ApiError> { + session.with_editor_mut(|editor| { + let variables = editor + .window_variables_mut(win) + .map_err(|error| ApiError::exception(error.to_string()))?; + let Some(index) = variables + .iter() + .position(|(candidate, _)| candidate == &name) + else { + return Err(ApiError::exception(format!( + "Key not found: {}", + name.to_string_lossy() + ))); + }; + variables.0.remove(index); + Ok(()) + }) + } + + /// Writes one `t:` variable through the live Ex evaluator, so a scope + /// watcher registered on `t:` fires with the change. + /// + /// The default keeps the legacy silent editor-map write for hosts + /// without Ex semantics; Ex-backed hosts override this to record a + /// `WatchTarget::Scope(Tab)` event and drain it after the editor + /// borrow is released. `tabpage` is already resolved to a live tabpage. + /// + /// # Errors + /// + /// Returns an API error when the host cannot perform the write. + fn set_tabpage_var( + &mut self, + session: &ApiSession, + tabpage: TabHandle, + name: OxStr, + value: Object, + ) -> Result<(), ApiError> { + session.with_editor_mut(|editor| { + editor + .tabpage_variables_mut(tabpage) + .map_err(|error| ApiError::exception(error.to_string()))? + .insert(name, value); + Ok(()) + }) + } + + /// Deletes one `t:` variable through the live Ex evaluator, so a scope + /// watcher registered on `t:` fires with the removed value. + /// + /// The default keeps the legacy silent editor-map removal for hosts + /// without Ex semantics; Ex-backed hosts override this to record a + /// `WatchTarget::Scope(Tab)` event and drain it after the editor + /// borrow is released. `tabpage` is already resolved to a live tabpage. + /// + /// # Errors + /// + /// Returns an API error when the key does not exist or the host cannot + /// perform the removal. + fn del_tabpage_var( + &mut self, + session: &ApiSession, + tabpage: TabHandle, + name: OxStr, + ) -> Result<(), ApiError> { + session.with_editor_mut(|editor| { + let variables = editor + .tabpage_variables_mut(tabpage) + .map_err(|error| ApiError::exception(error.to_string()))?; + let Some(index) = variables.iter().position(|(key, _)| key == &name) else { + return Err(ApiError::validation(format!( + "Key not found: {}", + name.to_string_lossy() + ))); + }; + variables.0.remove(index); + Ok(()) + }) + } + /// Changes the process working directory globally. /// /// `path` is valid UTF-8. The host retains the previous directory for @@ -210,7 +467,12 @@ pub fn execute_nvim_cmd( ) -> Result { let (command, output) = build_nvim_cmd(cmd, opts)?; let message_start = session.with_editor(|editor| editor.messages().len()); + let sctx = session + .requesting_channel() + .map(|c| ox_editor::OptionLastSet::api_client(c.get())); + let prev = session.with_editor_mut(|editor| editor.options_mut().set_current_sctx(sctx)); let result = execute_command(session, &OxStr(command.into_bytes()), executor); + session.with_editor_mut(|editor| editor.options_mut().set_current_sctx(prev)); if !output { result?; return Ok(OxStr::from("")); @@ -301,6 +563,7 @@ fn build_nvim_cmd(cmd: &Dict, opts: &Dict) -> Result<(String, bool), ApiError> { )); } }; + let text = text.replace('\n', "\0"); line.push(' '); if magic_bar { line.push_str(&text); @@ -1036,6 +1299,15 @@ pub fn nvim_set_vvar(session: &ApiSession, name: OxStr, value: Object) -> Result Ok(()) } +/// Whether an Ex-command host is installed (or forkable from the prototype), +/// so `g:` writes go through the evaluator and notify scope watchers instead +/// of mutating the editor map silently. +pub(crate) fn host_present(session: &ApiSession) -> bool { + session.with_state(|state| { + state.command_depth < state.command_pool.len() || state.command_prototype.is_some() + }) +} + #[api(since = 1)] #[expect( clippy::needless_pass_by_value, @@ -1049,10 +1321,15 @@ pub fn nvim_get_var(session: &ApiSession, name: OxStr) -> Result Result<(), ApiError> { + if host_present(session) { + return with_command_executor(session, |session, executor| { + executor.set_global_var(session, name, value) + }); + } session.with_editor_mut(|editor| { editor.gvars_mut().insert(name, value); }); @@ -1065,6 +1342,11 @@ pub fn nvim_set_var(session: &ApiSession, name: OxStr, value: Object) -> Result< reason = "the generated dispatcher binds and moves owned RPC arguments" )] pub fn nvim_del_var(session: &ApiSession, name: OxStr) -> Result<(), ApiError> { + if host_present(session) { + return with_command_executor(session, |session, executor| { + executor.del_global_var(session, name) + }); + } session.with_editor_mut(|editor| { let variables = editor.gvars_mut(); let Some(index) = variables.iter().position(|(key, _)| key == &name) else { @@ -1104,9 +1386,39 @@ pub fn nvim_set_option(session: &ApiSession, name: OxStr, value: Object) -> Resu let name = option_name(&name)?; let metadata = ox_editor::OptionStore::metadata(name).map_err(exception)?; let value = object_to_legacy_option_value(metadata, name, value)?; - session - .with_editor_mut(|editor| editor.options_mut().set_global(name, value)) - .map_err(exception) + let target = if metadata.scopes.contains(&OptionScope::Global) { + OptionTarget::Global + } else if metadata.scopes.contains(&OptionScope::Window) { + OptionTarget::Window(current_window(session)?) + } else if metadata.scopes.contains(&OptionScope::Buffer) { + OptionTarget::Buffer(current_buffer(session)?) + } else { + OptionTarget::Global + }; + let previous = current_option_value(session, name, target)?; + let previous_object = structured_option_value(metadata, &previous); + let sctx = session + .requesting_channel() + .map(|c| ox_editor::OptionLastSet::api_client(c.get())); + let prev_sctx = session.with_editor_mut(|editor| editor.options_mut().set_current_sctx(sctx)); + let set_result = set_option_at(session, name, value.clone(), target); + session.with_editor_mut(|editor| editor.options_mut().set_current_sctx(prev_sctx)); + set_result?; + let assigned = structured_option_value(metadata, &value); + let opt_scope = match target { + OptionTarget::Global => OptionSetScope::Global, + _ => OptionSetScope::Local, + }; + crate::autocmd::fire_option_set( + session, + metadata.name, + opt_scope, + &previous_object, + None, + Some(&previous_object), + &assigned, + )?; + Ok(()) } #[api(since = 9)] @@ -1157,18 +1469,57 @@ pub fn nvim_set_option_value( )), other => other, }; - let value = crate::option_merge::merge( - metadata, - ¤t_option_value(session, name, target)?, - value, - operation, - )?; + let previous = current_option_value(session, name, target)?; + let previous_object = structured_option_value(metadata, &previous); + let value = crate::option_merge::merge(metadata, &previous, value, operation)?; if dict_bool(&opts, "dry_run")? == Some(true) { validate_option_at(session, name, &value, target)?; return Ok(structured_option_value(metadata, &value)); } let assigned = structured_option_value(metadata, &value); - set_option_at(session, name, value, target)?; + set_option_at(session, name, value.clone(), target)?; + // `apply_optionset_autocmd_now` (option.c:2099) reports the flags the + // *request* carried, not the value target: `opt_flags` starts at zero and + // only a `scope` key, a `buf`, or a `win` sets it (`api/options.c:62-95`). + // Zero flags is upstream's plain `set`, which publishes both old values. + let requested = match dict_string(&opts, "scope")? { + Some(text) if text.as_bytes() == b"global" => OptionSetScope::Global, + Some(_) => OptionSetScope::Local, + None => { + if opts.get(&OxStr::from("buf")).is_some() || opts.get(&OxStr::from("win")).is_some() { + OptionSetScope::Local + } else { + OptionSetScope::Set + } + } + }; + let (old_local, old_global) = match requested { + OptionSetScope::Set => { + // A buffer-only option has no global value to read; upstream's + // `oldval_g` is then the value the write replaced. + let global = if metadata.scopes.contains(&OptionScope::Global) { + structured_option_value( + metadata, + ¤t_option_value(session, name, OptionTarget::Global)?, + ) + } else { + previous_object.clone() + }; + (Some(previous_object.clone()), Some(global)) + } + OptionSetScope::Global => (None, Some(previous_object.clone())), + OptionSetScope::Local | OptionSetScope::Modeline => (None, None), + }; + let scope = requested; + crate::autocmd::fire_option_set( + session, + metadata.name, + scope, + &previous_object, + old_local.as_ref(), + old_global.as_ref(), + &assigned, + )?; if metadata.name == "modified" && let OptionTarget::Buffer(buffer) | OptionTarget::GlobalAndBuffer(buffer) = target && let Object::Boolean(modified) = &assigned @@ -1863,7 +2214,11 @@ fn set_option_at( let metadata = ox_editor::OptionStore::metadata(name).map_err(option_value_error)?; let buffer_only = metadata.scopes.contains(&OptionScope::Buffer) && !metadata.scopes.contains(&OptionScope::Global); - session.with_editor_mut(|editor| match target { + let sctx = session + .requesting_channel() + .map(|c| ox_editor::OptionLastSet::api_client(c.get())); + let prev = session.with_editor_mut(|editor| editor.options_mut().set_current_sctx(sctx)); + let result = session.with_editor_mut(|editor| match target { OptionTarget::Global => { if buffer_only { editor @@ -1912,7 +2267,9 @@ fn set_option_at( .set_window(window, name, value) .map_err(option_value_error) } - }) + }); + session.with_editor_mut(|editor| editor.options_mut().set_current_sctx(prev)); + result } fn resolve_buffer(session: &ApiSession, buffer: BufHandle) -> Result { @@ -2208,11 +2565,7 @@ pub(crate) fn structured_option_value( } fn option_value_to_object(value: &OptionValue) -> Object { - match value { - OptionValue::Boolean(value) => Object::Boolean(*value), - OptionValue::Number(value) => Object::Integer(*value), - OptionValue::String(value) => Object::String(OxStr::from(value.as_str())), - } + ox_editor::option_value_object(value) } fn comma_items(text: &str) -> impl Iterator { @@ -2311,7 +2664,6 @@ fn typval_to_object_inner( let result = values .try_borrow() .map_err(|_| ApiError::exception("Cannot convert mutably borrowed Dictionary"))? - .entries .iter() .map(|entry| { Ok(( @@ -2392,7 +2744,6 @@ const SPECIAL_KEY_PAIRS: &[(&[u8], (u8, u8))] = &[ (b"delete", (b'k', b'D')), (b"bs", (b'k', b'b')), (b"backspace", (b'k', b'b')), - (b"tab", (KS_EXTRA, 54)), // KE_TAB (b"ignore", (KS_EXTRA, 53)), // KE_IGNORE (b"nop", (KS_EXTRA, 97)), // KE_NOP (b"insert", (b'k', b'I')), @@ -2555,6 +2906,11 @@ fn simple_termcode(notation: &[u8], do_lt: bool) -> Option { return do_lt.then_some(b'<'); } let named = [ + // Upstream's `string_escape` table maps these to their control bytes + // (`` is Ctrl-I, `` is Ctrl-J), not to `K_SPECIAL` keys. + (&b"tab"[..], b'\t'), + (&b"nl"[..], b'\n'), + (&b"newline"[..], b'\n'), (&b"cr"[..], b'\r'), (&b"enter"[..], b'\r'), (&b"esc"[..], 0x1b), @@ -2837,7 +3193,7 @@ pub fn nvim_call_dict_function( } // option.c:7180-7233 is the return schema; metadata comes from options.lua. -fn option_info(metadata: &OptionMetadata) -> Dict { +fn option_info(metadata: &OptionMetadata, last_set: Option<&ox_editor::OptionLastSet>) -> Dict { let scope = if metadata.scopes.contains(&OptionScope::Buffer) { "buf" } else if metadata.scopes.contains(&OptionScope::Window) { @@ -2849,6 +3205,15 @@ fn option_info(metadata: &OptionMetadata) -> Dict { .default .value .map_or(Object::Nil, |value| option_value_to_object(&value.into())); + let (was_set, sid, lnum, chan) = match last_set { + Some(ls) => ( + true, + ls.sid, + i64::try_from(ls.line).unwrap_or(0), + i64::try_from(ls.channel_id).unwrap_or(0), + ), + None => (false, 0, 0, 0), + }; Dict(vec![ ( OxStr::from("name"), @@ -2887,10 +3252,10 @@ fn option_info(metadata: &OptionMetadata) -> Dict { OxStr::from("allows_duplicates"), Object::Boolean(!metadata.deny_duplicates), ), - (OxStr::from("was_set"), Object::Boolean(false)), - (OxStr::from("last_set_sid"), Object::Integer(0)), - (OxStr::from("last_set_linenr"), Object::Integer(0)), - (OxStr::from("last_set_chan"), Object::Integer(0)), + (OxStr::from("was_set"), Object::Boolean(was_set)), + (OxStr::from("last_set_sid"), Object::Integer(sid)), + (OxStr::from("last_set_linenr"), Object::Integer(lnum)), + (OxStr::from("last_set_chan"), Object::Integer(chan)), ]) } @@ -2899,14 +3264,17 @@ fn option_info(metadata: &OptionMetadata) -> Dict { clippy::unnecessary_wraps, reason = "`#[api]` requires owned arguments and a `Result` return" )] -pub fn nvim_get_all_options_info(_session: &ApiSession) -> Result { +pub fn nvim_get_all_options_info(session: &ApiSession) -> Result { Ok(Dict( ox_editor::OPTION_METADATA .iter() .map(|metadata| { + let last_set = session.with_editor(|editor| { + editor.options().get_last_set_global(metadata.name).cloned() + }); ( OxStr::from(metadata.name), - Object::Dict(option_info(metadata)), + Object::Dict(option_info(metadata, last_set.as_ref())), ) }) .collect::>(), @@ -2927,7 +3295,17 @@ pub fn nvim_get_option_info2( let metadata = ox_editor::OptionStore::metadata(name).map_err(option_value_error)?; let target = option_target(session, name, &opts)?; get_option_at(session, name, target)?; - Ok(option_info(metadata)) + let last_set = session.with_editor(|editor| { + let (buf, win, local_only) = match target { + OptionTarget::Global => (None, None, false), + OptionTarget::Buffer(b) => (Some(b), None, true), + OptionTarget::Window(w) => (None, Some(w), true), + OptionTarget::GlobalAndBuffer(b) => (Some(b), None, false), + OptionTarget::GlobalAndWindow(w) => (None, Some(w), false), + }; + editor.options().get_last_set(metadata.name, buf, win, local_only).cloned() + }); + Ok(option_info(metadata, last_set.as_ref())) } // api/vimscript.c:360-499. This parses syntax; no evaluator is invoked. @@ -3259,6 +3637,23 @@ fn check_stl_option(statusline: &[u8]) -> Result<(), ApiError> { Ok(()) } +fn eval_statusline_expression( + session: &ApiSession, + source: &str, +) -> Result { + if let Some(lua_source) = source.strip_prefix("v:lua.") { + let code = format!("return {lua_source}"); + let value = with_lua_executor(session, |session, lua| { + lua.exec(session, &code, Vec::new()) + .map_err(ApiError::exception) + })?; + return object_to_typval(&value, 0); + } + with_command_executor(session, |session, executor| { + executor.evaluate(session, source) + }) +} + fn cell_width(text: &[u8]) -> usize { String::from_utf8_lossy(text).width() } @@ -3394,8 +3789,17 @@ pub fn nvim_eval_statusline( }; let modified = buffer.flags.contains(ox_editor::BufferFlags::MODIFIED); let modifiable = !buffer.flags.contains(ox_editor::BufferFlags::READONLY); + let readonly = buffer.flags.contains(ox_editor::BufferFlags::READONLY); let name = buffer.name().clone(); let handle = state.buffer; + let help = matches!( + editor.options().get_buffer(handle, "buftype"), + Ok(OptionValue::String(value)) if value == "help" + ); + let preview = matches!( + editor.options().get_window(window, "previewwindow"), + Ok(OptionValue::Boolean(true)) + ); Ok(StatuslineContext { cursor, line_count, @@ -3404,7 +3808,9 @@ pub fn nvim_eval_statusline( modifiable, name, handle, - readonly: false, + help, + preview, + readonly, filetype: String::new(), arglist: (0, 0), }) @@ -3476,9 +3882,7 @@ pub fn nvim_eval_statusline( } let source = std::str::from_utf8(format.get(offset..end).unwrap_or_default()) .map_err(|_| ApiError::validation("Expression must be valid UTF-8"))?; - let value = with_command_executor(session, |_, executor| { - executor.evaluate(session, source) - })?; + let value = eval_statusline_expression(session, source)?; let rendered = match &value { Typval::Number(number) => number.to_string().into_bytes(), Typval::String(text) => text.as_bytes().to_vec(), @@ -3499,9 +3903,7 @@ pub fn nvim_eval_statusline( b'!' => { let source = std::str::from_utf8(format.get(offset..).unwrap_or_default()) .map_err(|_| ApiError::validation("Expression must be valid UTF-8"))?; - let value = with_command_executor(session, |_, executor| { - executor.evaluate(session, source) - })?; + let value = eval_statusline_expression(session, source)?; let rendered = match &value { Typval::Number(number) => number.to_string().into_bytes(), Typval::String(text) => text.as_bytes().to_vec(), @@ -3670,6 +4072,26 @@ pub fn nvim_eval_statusline( }; } } + b'h' | b'H' => { + fillable = false; + if context.help { + piece = if target == b'H' { + b",HLP".to_vec() + } else { + b"[Help]".to_vec() + }; + } + } + b'w' | b'W' => { + fillable = false; + if context.preview { + piece = if target == b'W' { + b",PRV".to_vec() + } else { + b"[Preview]".to_vec() + }; + } + } b'y' | b'Y' => { fillable = false; if !context.filetype.is_empty() { @@ -3859,8 +4281,9 @@ fn highlight_entry(start: usize, groups: &[OxStr]) -> Object { fn fillchar_width(fillchar: &[u8]) -> usize { String::from_utf8_lossy(fillchar) .chars() - .map(char::width_usize) - .sum() + .next() + .map(CharWidth::width_usize) + .unwrap_or(1) } fn truncate_cells(text: &mut Vec, max_cells: usize) { @@ -3896,6 +4319,8 @@ struct StatuslineContext { modifiable: bool, name: OxStr, handle: BufHandle, + help: bool, + preview: bool, readonly: bool, filetype: String, arglist: (usize, usize), @@ -3951,61 +4376,6 @@ impl StlBuilder { } } -/// Adds a multicursor in the given buffer (`nvim_mcursor`, -/// src/nvim/api/vim.c:1462): the `[row, col]` position is (1,0)-indexed, -/// the column clamps to the line length like `nvim_win_set_cursor`, and the -/// reply is the total extra-cursor count (`mc_count`). -#[api(since = 15)] -#[expect( - clippy::needless_pass_by_value, - reason = "the generated dispatcher binds and moves owned RPC arguments" -)] -pub fn nvim_mcursor(session: &ApiSession, buf: BufHandle, pos: Vec) -> Result { - let buffer = crate::buffer::resolve_buffer(session, buf)?; - if pos.len() != 2 { - return Err(ApiError::validation( - "Invalid 'pos': expected [row, col] array", - )); - } - let position = session.with_editor(|editor| { - let text = editor - .buffer(buffer) - .map_err(exception)? - .text() - .map_err(exception)?; - let row = usize::try_from(pos[0]) - .ok() - .filter(|row| (1..=text.line_count()).contains(row)) - .ok_or_else(|| ApiError::validation("Invalid cursor line: out of range"))?; - // VALIDATE_RANGE's column bound precedes the silent end-of-line - // clamp (api/vim.c:1477-1482; check_cursor_col). - if pos[1] < 0 || pos[1] > MAXCOL { - return Err(ApiError::validation("Invalid cursor column: out of range")); - } - let col = usize::try_from(pos[1]) - .map_err(|_| ApiError::exception("Cursor column exceeds addressable range"))? - .min(text.line(row).map_err(exception)?.len()); - Ok::<_, ApiError>(ox_text::Position { lnum: row, col }) - })?; - // `mc_ns` (mcursor.c:137): one lazily allocated global namespace shared - // by every buffer, surfaced in `nvim_get_namespaces` by its upstream - // name. - let namespace = - crate::extmark::nvim_create_namespace(session, OxStr::from("nvim.multicursor"))?; - let namespace = ox_editor::NamespaceId::new( - u32::try_from(namespace) - .map_err(|_| ApiError::exception("mcursor namespace id out of range"))?, - ) - .map_err(|error| ApiError::exception(error.to_string()))?; - let count = session.with_editor_mut(|editor| { - editor - .add_mcursor(buffer, position, namespace) - .map_err(exception)?; - Ok::<_, ApiError>(editor.mcursor_count(namespace)) - })?; - i64::try_from(count).map_err(|_| ApiError::exception("mcursor count exceeds API integer range")) -} - pub(crate) fn register(registry: &mut Registry) -> Result<(), RegistryError> { registry.register( nvim_get_current_buf__API_META(), @@ -4015,7 +4385,6 @@ pub(crate) fn register(registry: &mut Registry) -> Result<(), RegistryError> { nvim_set_current_buf__API_META(), nvim_set_current_buf__API_DISPATCH, )?; - registry.register(nvim_mcursor__API_META(), nvim_mcursor__API_DISPATCH)?; registry.register( nvim_get_current_win__API_META(), nvim_get_current_win__API_DISPATCH, @@ -4107,3 +4476,518 @@ pub(crate) fn register(registry: &mut Registry) -> Result<(), RegistryError> { registry.register(nvim_input_mouse__API_META(), nvim_input_mouse__API_DISPATCH)?; Ok(()) } + +#[cfg(test)] +#[expect( + clippy::unwrap_used, + reason = "test scaffolding unwraps proven setup values" +)] +mod api_scope_watch_tests { + use super::*; + use std::cell::RefCell; + use std::rc::Rc; + + /// Test host delegating every exercised entry point to the live + /// [`ox_editor::ExExecutor`], the same owner the production hosts wrap. + /// Only the `g:` write pair matters here; unrelated trait methods report + /// themselves as unimplemented. + struct LiveHost { + executor: Rc>, + } + + impl LiveHost { + fn api_error(error: ox_editor::ExecError) -> ApiError { + ApiError::exception(error.to_string()) + } + } + + fn dict(entries: &[(&str, Object)]) -> Dict { + Dict( + entries + .iter() + .map(|(key, value)| (OxStr::from(*key), value.clone())) + .collect(), + ) + } + + impl CommandExecutor for LiveHost { + fn execute( + &mut self, + session: &ApiSession, + commands: &[ExCommand], + ) -> Result<(), ApiError> { + self.executor + .borrow_mut() + .execute_commands(session, commands) + .map(|_| ()) + .map_err(Self::api_error) + } + + fn define_user_command( + &mut self, + _session: &ApiSession, + _buffer: Option, + _command: UserCommand, + _force: bool, + ) -> Result<(), ApiError> { + Err(ApiError::exception("not implemented in test host")) + } + + fn delete_user_command( + &mut self, + _session: &ApiSession, + _buffer: Option, + _name: &str, + ) -> Result<(), ApiError> { + Err(ApiError::exception("not implemented in test host")) + } + + fn list_user_commands( + &mut self, + _session: &ApiSession, + _buffer: Option, + ) -> Result, ApiError> { + Err(ApiError::exception("not implemented in test host")) + } + + fn parse_cmdline( + &mut self, + session: &ApiSession, + line: &str, + ) -> Result, ApiError> { + let executor = self.executor.borrow(); + session + .with_editor(|editor| executor.parse_commands(editor, line)) + .map_err(Self::api_error) + } + + fn remove_buffer(&mut self, buffer: BufHandle) -> Result<(), ApiError> { + self.executor.borrow_mut().remove_buffer(buffer); + Ok(()) + } + + fn evaluate(&mut self, session: &ApiSession, expression: &str) -> Result { + self.executor + .borrow_mut() + .evaluate_expression(session, expression) + .map_err(Self::api_error) + } + + fn call_builtin( + &mut self, + session: &ApiSession, + name: &OxStr, + args: Vec, + ) -> Result { + self.executor + .borrow_mut() + .call_builtin(session, name, args) + .map_err(Self::api_error) + } + + fn change_directory(&mut self, session: &ApiSession, path: &str) -> Result<(), ApiError> { + self.executor + .borrow_mut() + .change_directory(session, path) + .map_err(Self::api_error) + } + + fn set_global_var( + &mut self, + session: &ApiSession, + name: OxStr, + value: Object, + ) -> Result<(), ApiError> { + self.executor + .borrow_mut() + .set_global_var(session, &name, value) + .map_err(Self::api_error) + } + + fn del_global_var(&mut self, session: &ApiSession, name: OxStr) -> Result<(), ApiError> { + self.executor + .borrow_mut() + .remove_global_var(session, &name) + .map_err(Self::api_error) + } + + fn set_buffer_var( + &mut self, + session: &ApiSession, + buffer: BufHandle, + name: OxStr, + value: Object, + ) -> Result<(), ApiError> { + self.executor + .borrow_mut() + .set_buffer_var(session, buffer, &name, value) + .map_err(Self::api_error) + } + + fn del_buffer_var( + &mut self, + session: &ApiSession, + buffer: BufHandle, + name: OxStr, + ) -> Result<(), ApiError> { + self.executor + .borrow_mut() + .remove_buffer_var(session, buffer, &name) + .map_err(Self::api_error) + } + + fn set_window_var( + &mut self, + session: &ApiSession, + window: WinHandle, + name: OxStr, + value: Object, + ) -> Result<(), ApiError> { + self.executor + .borrow_mut() + .set_window_var(session, window, &name, value) + .map_err(Self::api_error) + } + + fn del_window_var( + &mut self, + session: &ApiSession, + window: WinHandle, + name: OxStr, + ) -> Result<(), ApiError> { + self.executor + .borrow_mut() + .remove_window_var(session, window, &name) + .map_err(Self::api_error) + } + + fn set_tabpage_var( + &mut self, + session: &ApiSession, + tabpage: TabHandle, + name: OxStr, + value: Object, + ) -> Result<(), ApiError> { + self.executor + .borrow_mut() + .set_tabpage_var(session, tabpage, &name, value) + .map_err(Self::api_error) + } + + fn del_tabpage_var( + &mut self, + session: &ApiSession, + tabpage: TabHandle, + name: OxStr, + ) -> Result<(), ApiError> { + self.executor + .borrow_mut() + .remove_tabpage_var(session, tabpage, &name) + .map_err(Self::api_error) + } + } + + fn session_with_live_buffer_host() -> (ApiSession, Rc>, BufHandle) + { + let mut editor = Editor::new(); + let buffer = editor.create_buffer(true).unwrap(); + let session = ApiSession::new(Rc::new(RefCell::new(editor))); + let executor = Rc::new(RefCell::new(ox_editor::ExExecutor::new())); + crate::set_command_executor( + &session, + Box::new(LiveHost { + executor: Rc::clone(&executor), + }), + Box::new(LiveHost { + executor: Rc::clone(&executor), + }), + ); + (session, executor, buffer) + } + + fn session_with_live_host() -> (ApiSession, Rc>) { + let mut editor = Editor::new(); + let buffer = editor.create_buffer(true).unwrap(); + editor + .create_tabpage(buffer, ox_editor::Geometry::new(0, 0, 80, 24).unwrap()) + .unwrap(); + let session = ApiSession::new(Rc::new(RefCell::new(editor))); + let executor = Rc::new(RefCell::new(ox_editor::ExExecutor::new())); + crate::set_command_executor( + &session, + Box::new(LiveHost { + executor: Rc::clone(&executor), + }), + Box::new(LiveHost { + executor: Rc::clone(&executor), + }), + ); + (session, executor) + } + + fn install_scope_watcher(session: &ApiSession, executor: &RefCell) { + // The callback records `[key, has_new, has_old]` per notification, the + // same shape the scope-`remove()` tests assert on the Ex side. + executor + .borrow_mut() + .execute_script( + session, + "watch_setup.vim", + "function! Changed(d, k, c)\n call add(g:seen, [a:k, has_key(a:c, 'new'), has_key(a:c, 'old')])\nendfunction\nlet g:seen = []\ncall dictwatcheradd(g:, 'watched', 'Changed')", + ) + .unwrap(); + } + + fn seen(session: &ApiSession) -> Object { + nvim_get_var(session, OxStr::from("seen")).unwrap() + } + + fn row(key: &str, has_new: i64, has_old: i64) -> Object { + Object::Array(vec![ + Object::String(OxStr::from(key)), + Object::Integer(has_new), + Object::Integer(has_old), + ]) + } + + #[test] + fn nvim_set_var_fires_scope_watcher_with_new() { + let (session, executor) = session_with_live_host(); + install_scope_watcher(&session, &executor); + nvim_set_var( + &session, + OxStr::from("watched"), + Object::String(OxStr::from("test")), + ) + .unwrap(); + // The write itself lands where `nvim_get_var` reads it. + assert_eq!( + nvim_get_var(&session, OxStr::from("watched")).unwrap(), + Object::String(OxStr::from("test")) + ); + // The API write notifies with `new` and no `old`, exactly like the + // `:let` write the reference reports. + assert_eq!(seen(&session), Object::Array(vec![row("watched", 1, 0)])); + // A later `remove()` on the same key then reports the API-written + // value as `old`: this is the `remove() when updated with + // nvim_*_var` reference sequence. + executor + .borrow_mut() + .execute_line(&session, "call remove(g:, 'watched')") + .unwrap(); + assert_eq!( + seen(&session), + Object::Array(vec![row("watched", 1, 0), row("watched", 0, 1)]) + ); + } + + #[test] + fn nvim_del_var_fires_scope_watcher_with_old() { + let (session, executor) = session_with_live_host(); + install_scope_watcher(&session, &executor); + nvim_set_var( + &session, + OxStr::from("watched"), + Object::String(OxStr::from("test")), + ) + .unwrap(); + executor + .borrow_mut() + .execute_line(&session, "let g:seen = []") + .unwrap(); + nvim_del_var(&session, OxStr::from("watched")).unwrap(); + // The API removal drops the variable and notifies with `old` and no + // `new`, the same change shape as `unlet`. + assert!(nvim_get_var(&session, OxStr::from("watched")).is_err()); + assert_eq!(seen(&session), Object::Array(vec![row("watched", 0, 1)])); + // Deleting a missing key keeps the `Key not found` error. + let error = nvim_del_var(&session, OxStr::from("watched")).unwrap_err(); + assert!( + error.to_string().contains("Key not found"), + "missing key keeps its error: {error:?}" + ); + } + + fn install_buffer_watcher( + session: &ApiSession, + executor: &RefCell, + pattern: &str, + ) { + // Same recording callback as the `g:` watcher, but registered on the + // `b:` namespace the API writes below target. + executor + .borrow_mut() + .execute_script( + session, + "buffer_watch_setup.vim", + &format!( + "function! Changed(d, k, c)\n call add(g:seen, [a:k, has_key(a:c, 'new'), has_key(a:c, 'old')])\nendfunction\nlet g:seen = []\ncall dictwatcheradd(b:, '{pattern}', 'Changed')" + ), + ) + .unwrap(); + } + + #[test] + fn nvim_buf_set_var_fires_buffer_scope_watcher_with_new() { + let (session, executor, buffer) = session_with_live_buffer_host(); + install_buffer_watcher(&session, &executor, "watched"); + crate::buffer::nvim_buf_set_var( + &session, + buffer, + OxStr::from("watched"), + Object::String(OxStr::from("test")), + ) + .unwrap(); + // The write itself lands where `nvim_buf_get_var` reads it. + assert_eq!( + crate::buffer::nvim_buf_get_var(&session, buffer, OxStr::from("watched")).unwrap(), + Object::String(OxStr::from("test")) + ); + // The API write notifies with `new` and no `old`, exactly like the + // `:let b:` write the reference reports. + assert_eq!(seen(&session), Object::Array(vec![row("watched", 1, 0)])); + // Deleting the same key then reports the API-written value as `old`. + crate::buffer::nvim_buf_del_var(&session, buffer, OxStr::from("watched")).unwrap(); + assert!(crate::buffer::nvim_buf_get_var(&session, buffer, OxStr::from("watched")).is_err()); + assert_eq!( + seen(&session), + Object::Array(vec![row("watched", 1, 0), row("watched", 0, 1)]) + ); + } + + #[test] + fn nvim_buf_failed_writes_record_nothing() { + let (session, executor, buffer) = session_with_live_buffer_host(); + install_buffer_watcher(&session, &executor, "*"); + // Deleting a missing key keeps the `Key not found` error and fires no + // watcher round. + let error = + crate::buffer::nvim_buf_del_var(&session, buffer, OxStr::from("watched")).unwrap_err(); + assert!( + error.to_string().contains("Key not found"), + "missing key keeps its error: {error:?}" + ); + // A read-only key refuses the write the same silent way. + let error = crate::buffer::nvim_buf_set_var( + &session, + buffer, + OxStr::from("changedtick"), + Object::Integer(1), + ) + .unwrap_err(); + assert!( + error.to_string().contains("read-only"), + "read-only key keeps its error: {error:?}" + ); + assert_eq!(seen(&session), Object::Array(vec![])); + } + #[test] + fn nvim_cmd_set_records_channel_provenance_and_verbose_set_reports_it() { + let (session, _executor) = session_with_live_host(); + let _caller = session.enter_rpc_call(ox_rpc::ChannelId::new(3)); + let cmd = dict(&[ + ("cmd", Object::String(OxStr::from("set"))), + ( + "args", + Object::Array(vec![Object::String(OxStr::from("cursorline"))]), + ), + ]); + crate::global::nvim_cmd(&session, cmd, Dict(Vec::new())).unwrap(); + + let info = crate::global::nvim_get_option_info2( + &session, + OxStr::from("cursorline"), + Dict(Vec::new()), + ) + .unwrap(); + assert_eq!( + info.get(&OxStr::from("was_set")), + Some(&Object::Boolean(true)) + ); + assert_eq!( + info.get(&OxStr::from("last_set_sid")), + Some(&Object::Integer(-9)) + ); + assert_eq!( + info.get(&OxStr::from("last_set_chan")), + Some(&Object::Integer(3)) + ); + + let query = dict(&[ + ("cmd", Object::String(OxStr::from("set"))), + ( + "args", + Object::Array(vec![Object::String(OxStr::from("cursorline?"))]), + ), + ( + "mods", + Object::Dict(dict(&[("verbose", Object::Integer(1))])), + ), + ]); + let output = crate::global::nvim_cmd( + &session, + query, + dict(&[("output", Object::Boolean(true))]), + ) + .unwrap(); + let out_str = output.to_string_lossy(); + assert!( + out_str.contains("cursorline\n\tLast set from API client (channel id 3)"), + "expected API client Last set text in output, got: {out_str}" + ); + } + + #[test] + fn nvim_set_option_records_channel_provenance_and_verbose_reports_it() { + let (session, _executor) = session_with_live_host(); + let _caller = session.enter_rpc_call(ox_rpc::ChannelId::new(7)); + crate::global::nvim_set_option( + &session, + OxStr::from("cursorline"), + Object::Boolean(true), + ) + .unwrap(); + + let info = crate::global::nvim_get_option_info2( + &session, + OxStr::from("cursorline"), + Dict(Vec::new()), + ) + .unwrap(); + assert_eq!( + info.get(&OxStr::from("was_set")), + Some(&Object::Boolean(true)) + ); + assert_eq!( + info.get(&OxStr::from("last_set_sid")), + Some(&Object::Integer(-9)) + ); + assert_eq!( + info.get(&OxStr::from("last_set_chan")), + Some(&Object::Integer(7)) + ); + + let query = dict(&[ + ("cmd", Object::String(OxStr::from("set"))), + ( + "args", + Object::Array(vec![Object::String(OxStr::from("cursorline?"))]), + ), + ( + "mods", + Object::Dict(dict(&[("verbose", Object::Integer(1))])), + ), + ]); + let output = crate::global::nvim_cmd( + &session, + query, + dict(&[("output", Object::Boolean(true))]), + ) + .unwrap(); + let out_str = output.to_string_lossy(); + assert!( + out_str.contains("cursorline\n\tLast set from API client (channel id 7)"), + "expected channel 7 in output, got: {out_str}" + ); + } +} diff --git a/crates/ox-api/src/lib.rs b/crates/ox-api/src/lib.rs index f081a515..d86739fa 100644 --- a/crates/ox-api/src/lib.rs +++ b/crates/ox-api/src/lib.rs @@ -21,8 +21,8 @@ mod registry; mod runtime; mod session; -pub use session::{ApiCallerGuard, ApiSession}; pub use buffer::{EnteredResidency, restore_buffer_context}; +pub use session::{ApiCallerGuard, ApiSession}; mod tabpage; mod ui; mod window; diff --git a/crates/ox-api/src/session.rs b/crates/ox-api/src/session.rs index 1499c8e3..47f7aeef 100644 --- a/crates/ox-api/src/session.rs +++ b/crates/ox-api/src/session.rs @@ -58,6 +58,9 @@ pub struct SessionState { /// In-flight `FileType` dispatches as `(buffer, raw filetype)` pairs. A /// a changed value may fire again. pub(crate) filetype_dispatches: Vec<(BufHandle, String)>, + /// Set while an `OptionSet` dispatch runs: the event never nests into + /// itself (`apply_optionset_autocmd_now`, option.c:2102). + pub(crate) option_set_in_flight: bool, /// Lua registry references awaiting a free while every Lua host slot is /// checked out (a nested decoration-provider replacement inside a /// callback). Lifetime-ordered: the same integer may appear again after @@ -126,6 +129,7 @@ impl Default for SessionState { paste_cancelled: false, channel_sink: None, filetype_dispatches: Vec::new(), + option_set_in_flight: false, pending_lua_callback_releases: Vec::new(), pending_autocmd_callback_releases: Vec::new(), callers: Rc::new(RefCell::new(Vec::new())), diff --git a/crates/ox-api/src/shada_codec.rs b/crates/ox-api/src/shada_codec.rs new file mode 100644 index 00000000..7648fe2c --- /dev/null +++ b/crates/ox-api/src/shada_codec.rs @@ -0,0 +1,71 @@ +use std::io::Cursor; + +use ox_editor::shada::{Stream, StreamEntry}; +use ox_types::{ApiError, Object}; + +const MAX_CONTEXT_ENTRY_KBYTE: usize = 10 * 1024; + +pub fn bytes_to_readfile_lines(data: &[u8]) -> Vec { + if data.is_empty() { + return Vec::new(); + } + let mut items = Vec::new(); + let mut start = 0; + for (index, &byte) in data.iter().enumerate() { + if byte == b'\n' { + items.push(Object::String(nl_to_nul_item(&data[start..index]))); + start = index + 1; + } + } + items.push(Object::String(nl_to_nul_item(&data[start..]))); + items +} + +fn nl_to_nul_item(piece: &[u8]) -> ox_types::OxStr { + let converted: Vec = piece + .iter() + .map(|&byte| if byte == 0 { b'\n' } else { byte }) + .collect(); + ox_types::OxStr(converted) +} + +pub fn readfile_lines_to_bytes(lines: &[Object]) -> Result, ApiError> { + if lines.is_empty() { + return Ok(Vec::new()); + } + let mut chunks = Vec::with_capacity(lines.len()); + for item in lines { + let Object::String(s) = item else { + return Err(ApiError::exception( + "E474: Failed to convert list to msgpack string buffer", + )); + }; + let converted: Vec = s + .as_bytes() + .iter() + .map(|&byte| if byte == b'\n' { 0 } else { byte }) + .collect(); + chunks.push(converted); + } + Ok(chunks.join(&b'\n')) +} + +pub fn write_shada_entries(entries: &[StreamEntry]) -> Result, ApiError> { + let stream = Stream { + entries: entries.to_vec(), + }; + let mut bytes = Vec::new(); + stream + .write(&mut bytes, 0) + .map_err(|error| ApiError::exception(error.to_string()))?; + Ok(bytes) +} + +pub fn read_shada_entries(bytes: &[u8]) -> Result, ApiError> { + if bytes.is_empty() { + return Ok(Vec::new()); + } + let stream = Stream::read(Cursor::new(bytes), MAX_CONTEXT_ENTRY_KBYTE) + .map_err(|error| ApiError::exception(error.to_string()))?; + Ok(stream.entries) +} diff --git a/crates/ox-api/src/tabpage.rs b/crates/ox-api/src/tabpage.rs index af922e1c..f8e68400 100644 --- a/crates/ox-api/src/tabpage.rs +++ b/crates/ox-api/src/tabpage.rs @@ -165,14 +165,13 @@ pub fn nvim_open_tabpage( Some(_) => return Err(invalid_config("after", "expected Integer")), None => 0, }; - let (original, original_buffer, caller_window) = - session.with_editor(|editor| { - ( - editor.current_tabpage(), - editor.current_buffer(), - editor.current_window(), - ) - }); + let (original, original_buffer, caller_window) = session.with_editor(|editor| { + ( + editor.current_tabpage(), + editor.current_buffer(), + editor.current_window(), + ) + }); let original_buffer = original_buffer.ok_or_else(|| ApiError::exception("No current buffer"))?; if enter { @@ -303,6 +302,11 @@ pub fn nvim_tabpage_set_var( value: Object, ) -> Result<(), ApiError> { let tabpage = resolve_tabpage(session, tabpage)?; + if crate::global::host_present(session) { + return crate::runtime::with_command_executor(session, |session, executor| { + executor.set_tabpage_var(session, tabpage, name, value) + }); + } session.with_editor_mut(|editor| { editor .tabpage_variables_mut(tabpage) @@ -323,6 +327,11 @@ pub fn nvim_tabpage_del_var( name: OxStr, ) -> Result<(), ApiError> { let tabpage = resolve_tabpage(session, tabpage)?; + if crate::global::host_present(session) { + return crate::runtime::with_command_executor(session, |session, executor| { + executor.del_tabpage_var(session, tabpage, name) + }); + } session.with_editor_mut(|editor| { let variables = editor.tabpage_variables_mut(tabpage).map_err(exception)?; let Some(index) = variables.iter().position(|(key, _)| key == &name) else { diff --git a/crates/ox-api/src/tests.rs b/crates/ox-api/src/tests.rs index a8f551a5..6d2f43cd 100644 --- a/crates/ox-api/src/tests.rs +++ b/crates/ox-api/src/tests.rs @@ -5,7 +5,7 @@ use std::rc::Rc; use ox_editor::{ AutocmdAction, AutocmdKind, AutocmdOptions, Editor, Event, Geometry, K_SPECIAL, KS_EXTRA, Mode, - ModeMachine, NullExprEval, OptionValue, + ModeMachine, NullExprEval, OptionValue, RegisterContent, RegisterKind, }; use ox_eval::{BuiltinHost, Builtins, Evaluator, NoRegex, Parser, Scope, scope::ScopeMap}; use ox_text::Buffer; @@ -21,6 +21,14 @@ fn session_with(editor: Editor) -> crate::ApiSession { crate::ApiSession::new(Rc::new(RefCell::new(editor))) } +fn hex_readfile_lines(hex: &str) -> Vec { + let data: Vec = (0..hex.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&hex[index..index + 2], 16).expect("fixture is hex")) + .collect(); + crate::context::bytes_to_readfile_lines(&data) +} + fn dict(entries: &[(&str, Object)]) -> Dict { Dict( entries @@ -285,7 +293,7 @@ fn nvim_cmd_decodes_structure_and_captures_output() { assert!(session.with_editor(|editor| editor.messages().is_empty())); let parsed = executor.commands.first().expect("one parsed command"); assert!(!parsed.bang); - assert_eq!(parsed.count, None); + assert_eq!(parsed.count, Some(3)); assert_eq!(parsed.args, ""); assert!(parsed.range.is_none()); assert_eq!(parsed.modifiers.len(), 4); @@ -1421,7 +1429,7 @@ fn option_value_scope_distinguishes_global_and_local() { #[test] fn core_registry_metadata_matches_cross_family_sample() { let registry = crate::core().unwrap(); - assert_eq!(registry.len(), 263); + assert_eq!(registry.len(), 262); let expected = [ ("nvim_buf_get_lines", 1, TypeRef::ArrayOf(&TypeRef::String)), ("nvim_buf_get_text", 9, TypeRef::ArrayOf(&TypeRef::String)), @@ -1696,19 +1704,6 @@ fn buffer_delete_rehomes_windows_without_force() { ); } -#[test] -fn buffer_delete_rehomes_windows_with_explicit_false_force() { - let (editor, buffer, _, window) = editor_with_lines(&["one"]); - let session = session_with(editor); - crate::buffer::nvim_buf_delete(&session, buffer, dict(&[("force", Object::Boolean(false))])) - .unwrap(); - assert!(session.with_editor(|editor| editor.buffer(buffer).is_err())); - assert_ne!( - session.with_editor(|editor| editor.window(window).unwrap().buffer), - buffer - ); -} - #[test] fn cursor_column_rejects_values_above_maxcol_before_clamping() { // Upstream rejects col > MAXCOL before the silent clamp @@ -1731,65 +1726,6 @@ fn cursor_column_rejects_values_above_maxcol_before_clamping() { ); } -#[test] -fn nvim_mcursor_validates_arguments_before_extmark_insert() { - // api/vim.c:1462-1484: buffer lookup precedes 'pos' validation, which - // precedes the range checks; only then does mc_add dedupe positions. - let (editor, buffer, _, _) = editor_with_lines(&["abc", "z"]); - let session = session_with(editor); - let missing = crate::BufHandle::try_from(9999).unwrap(); - assert_eq!( - crate::global::nvim_mcursor(&session, missing, vec![5, 0]), - Err(ApiError::validation("Invalid buffer id: 9999")) - ); - assert_eq!( - crate::global::nvim_mcursor(&session, buffer, vec![1]), - Err(ApiError::validation( - "Invalid 'pos': expected [row, col] array" - )) - ); - assert_eq!( - crate::global::nvim_mcursor(&session, buffer, vec![0, 0]), - Err(ApiError::validation("Invalid cursor line: out of range")) - ); - assert_eq!( - crate::global::nvim_mcursor(&session, buffer, vec![3, 0]), - Err(ApiError::validation("Invalid cursor line: out of range")) - ); - assert_eq!( - crate::global::nvim_mcursor(&session, buffer, vec![1, -1]), - Err(ApiError::validation("Invalid cursor column: out of range")) - ); - assert_eq!( - crate::global::nvim_mcursor(&session, buffer, vec![1, i64::MAX]), - Err(ApiError::validation("Invalid cursor column: out of range")) - ); -} - -#[test] -fn nvim_mcursor_dedupes_positions_and_returns_global_count() { - let (editor, buffer, _, _) = editor_with_lines(&["abc", "z"]); - let session = session_with(editor); - assert_eq!( - crate::global::nvim_mcursor(&session, buffer, vec![1, 2]), - Ok(1) - ); - // Re-adding the tracked position is a no-op: mc_add dedupes. - assert_eq!( - crate::global::nvim_mcursor(&session, buffer, vec![1, 2]), - Ok(1) - ); - // An over-long column clamps to the EOL insertion point (a new position). - assert_eq!( - crate::global::nvim_mcursor(&session, buffer, vec![1, 99]), - Ok(2) - ); - assert_eq!( - crate::global::nvim_mcursor(&session, buffer, vec![2, 0]), - Ok(3) - ); -} - #[derive(Clone)] struct CapturingAutocmds(Rc>>>); @@ -2535,6 +2471,256 @@ fn definition_count(session: &crate::ApiSession, id: i64) -> usize { } #[test] +/// A `nvim_set_option_value` call with no `scope`, `buf`, or `win` key leaves +/// upstream's `opt_flags` at zero (`api/options.c:427`), which is the plain +/// `set` branch: `v:option_command` is `set`, `v:option_type` is `global`, and +/// both old values are published (`option.c:2125-2129`). +#[test] +fn api_option_write_without_a_scope_reports_plain_set() { + let (editor, _, _, _) = editor_with_lines(&["one"]); + let session = session_with(editor); + session.with_editor_mut(|editor| editor.set_v_vim_did_enter()); + let id = crate::autocmd::nvim_create_autocmd( + &session, + Object::String(OxStr::from("OptionSet")), + dict(&[ + ("pattern", Object::String(OxStr::from("scrolloff"))), + ("command", Object::String(OxStr::from("echo fired"))), + ]), + ) + .unwrap(); + let actions = Rc::new(RefCell::new(Vec::new())); + let seen = Rc::new(RefCell::new(Vec::new())); + let recorded = seen.clone(); + let reenter: SessionReentry = Rc::new(move |session| { + let snapshot = session.with_editor(|editor| { + [ + editor.vvars().get(&OxStr::from("option_command")).cloned(), + editor.vvars().get(&OxStr::from("option_type")).cloned(), + editor.vvars().get(&OxStr::from("option_oldlocal")).cloned(), + editor + .vvars() + .get(&OxStr::from("option_oldglobal")) + .cloned(), + ] + }); + recorded.borrow_mut().push(snapshot); + Ok(()) + }); + crate::set_autocmd_executor( + &session, + Box::new(ActionRecorder { + actions: actions.clone(), + reenter: Some(reenter), + }), + Box::new(ActionRecorder::default()), + ); + + crate::global::nvim_set_option_value( + &session, + OxStr::from("scrolloff"), + Object::Integer(9), + dict(&[]), + ) + .unwrap(); + + assert_eq!(actions.borrow().len(), 1, "one dispatch"); + let seen = seen.borrow(); + assert_eq!( + seen[0][0], + Some(Object::String(OxStr::from("set"))), + "no scope key means the plain set branch" + ); + assert_eq!( + seen[0][1], + Some(Object::String(OxStr::from("global"))), + "and the global type" + ); + assert!( + matches!(seen[0][2], Some(Object::Integer(_))), + "v:option_oldlocal is published for a plain set: {:?}", + seen[0][2] + ); + assert!( + matches!(seen[0][3], Some(Object::Integer(_))), + "and so is v:option_oldglobal: {:?}", + seen[0][3] + ); + let _ = id; +} + +/// The deprecated `nvim_set_option` reaches the same `set_option_value` path +/// upstream, so it publishes `OptionSet` too, with the global scope +/// (`option.c:4373`). +#[test] +fn legacy_set_option_fires_optionset_with_global_scope() { + let (editor, _, _, _) = editor_with_lines(&["one"]); + let session = session_with(editor); + session.with_editor_mut(|editor| editor.set_v_vim_did_enter()); + let id = crate::autocmd::nvim_create_autocmd( + &session, + Object::String(OxStr::from("OptionSet")), + dict(&[ + ("pattern", Object::String(OxStr::from("scrolloff"))), + ("command", Object::String(OxStr::from("echo fired"))), + ]), + ) + .unwrap(); + let actions = Rc::new(RefCell::new(Vec::new())); + let seen = Rc::new(RefCell::new(Vec::new())); + let recorded = seen.clone(); + let reenter: SessionReentry = Rc::new(move |session| { + let snapshot = session.with_editor(|editor| { + [ + editor.vvars().get(&OxStr::from("option_new")).cloned(), + editor.vvars().get(&OxStr::from("option_command")).cloned(), + editor + .vvars() + .get(&OxStr::from("option_oldglobal")) + .cloned(), + ] + }); + recorded.borrow_mut().push(snapshot); + Ok(()) + }); + crate::set_autocmd_executor( + &session, + Box::new(ActionRecorder { + actions: actions.clone(), + reenter: Some(reenter), + }), + Box::new(ActionRecorder::default()), + ); + + crate::global::nvim_set_option(&session, OxStr::from("scrolloff"), Object::Integer(9)).unwrap(); + + assert_eq!(actions.borrow().len(), 1, "one dispatch"); + let seen = seen.borrow(); + assert_eq!(seen.len(), 1, "one snapshot"); + assert_eq!( + seen[0][0], + Some(Object::Integer(9)), + "v:option_new during the callback" + ); + assert_eq!( + seen[0][1], + Some(Object::String(OxStr::from("setglobal"))), + "the legacy call writes the global scope" + ); + assert!( + matches!(seen[0][2], Some(Object::Integer(_))), + "v:option_oldglobal carries the previous value" + ); + let _ = id; +} + +/// A committed option write fires `OptionSet` with the option's full name as +/// the pattern and `v:option_new` set (`apply_optionset_autocmd_now`, +/// option.c:2099-2134). `nvim.dir` relies on exactly this to notice +/// `buftype` becoming `nowrite`. +#[test] +fn set_option_value_fires_optionset_with_the_new_value() { + let (editor, _, _, _) = editor_with_lines(&["one"]); + let session = session_with(editor); + // Startup is over: the event does not fire before `v:vim_did_enter` is 1. + session.with_editor_mut(|editor| editor.set_v_vim_did_enter()); + let id = crate::autocmd::nvim_create_autocmd( + &session, + Object::String(OxStr::from("OptionSet")), + dict(&[ + ("pattern", Object::String(OxStr::from("buftype"))), + ("command", Object::String(OxStr::from("echo fired"))), + ]), + ) + .unwrap(); + let actions = Rc::new(RefCell::new(Vec::new())); + // The `v:option_*` values are live only while the callback runs: upstream + // resets them when the dispatch returns (`reset_v_option_vars`). + let seen = Rc::new(RefCell::new(Vec::new())); + let recorded = seen.clone(); + let actions_written = actions.clone(); + let reenter: SessionReentry = Rc::new(move |session| { + let snapshot = session.with_editor(|editor| { + [ + editor.vvars().get(&OxStr::from("option_new")).cloned(), + editor.vvars().get(&OxStr::from("option_command")).cloned(), + editor.vvars().get(&OxStr::from("option_type")).cloned(), + ] + }); + recorded.borrow_mut().push(snapshot); + let _ = &actions_written; + Ok(()) + }); + crate::set_autocmd_executor( + &session, + Box::new(ActionRecorder { + actions: actions.clone(), + reenter: Some(reenter), + }), + Box::new(ActionRecorder::default()), + ); + + crate::global::nvim_set_option_value( + &session, + OxStr::from("buftype"), + Object::String(OxStr::from("nowrite")), + dict(&[("buf", Object::Integer(1))]), + ) + .unwrap(); + + assert_eq!(actions.borrow().len(), 1, "one dispatch"); + let seen = seen.borrow(); + assert_eq!(seen.len(), 1, "one snapshot"); + assert_eq!( + seen[0][0], + Some(Object::String(OxStr::from("nowrite"))), + "v:option_new during the callback" + ); + assert_eq!( + seen[0][1], + Some(Object::String(OxStr::from("setlocal"))), + "a buffer-local write reports setlocal" + ); + assert_eq!( + seen[0][2], + Some(Object::String(OxStr::from("local"))), + "and local scope" + ); + // After the dispatch the variables are back to empty, as upstream resets + // them. + session.with_editor(|editor| { + assert_eq!( + editor.vvars().get(&OxStr::from("option_new")), + Some(&Object::String(OxStr::from(""))) + ); + }); + let _ = id; +} + +/// `nvim_buf_set_name` passes the name through `fname_expand` and then +/// `vim_FullName`, which leaves a URL alone (`path.c:1780`). A URL that gained +/// the working directory in front of it would be a different buffer name, and +/// `nvim.dir` names its listing buffers with exactly such a URI. +#[test] +fn buf_set_name_keeps_a_url_out_of_the_working_directory() { + let (mut editor, _, _, _) = editor_with_lines(&["one"]); + let target = editor + .create_buffer_with(Buffer::from_lines(&[b"x".to_vec()], false).unwrap(), true) + .unwrap(); + let session = session_with(editor); + crate::buffer::nvim_buf_set_name(&session, target, OxStr::from("example:///z/w")).unwrap(); + let name = crate::buffer::nvim_buf_get_name(&session, target).unwrap(); + assert_eq!(name.as_bytes(), b"example:///z/w"); + + // A plain relative name still becomes absolute. + crate::buffer::nvim_buf_set_name(&session, target, OxStr::from("src/lib.rs")).unwrap(); + let name = crate::buffer::nvim_buf_get_name(&session, target).unwrap(); + assert_eq!( + name.as_bytes(), + ox_editor::expand_buffer_name(&OxStr::from("src/lib.rs")).as_bytes() + ); +} + fn set_option_value_fires_one_populated_filetype_after_commit() { let (mut editor, current, _, window) = editor_with_lines(&["one"]); let target = editor @@ -2769,7 +2955,7 @@ fn nonmatching_filetype_write_does_not_consume_once() { .register_legacy( &[Event::FileType], "", - &AutocmdKind::ExString("echo fired".to_owned()), + &AutocmdKind::ExString { source: "echo fired".to_owned(), script: None }, &AutocmdOptions { buffer: Some(current), once: true, @@ -2897,7 +3083,7 @@ fn get_autocmds_serializes_optional_fields_only_when_present() { .register_legacy( &[Event::BufEnter], "*", - &AutocmdKind::ExString("legacy".to_owned()), + &AutocmdKind::ExString { source: "legacy".to_owned(), script: None }, &AutocmdOptions::default(), ) .unwrap(); @@ -3135,7 +3321,7 @@ fn query_event_type_errors_match_upstream_text() { .register_legacy( &[Event::BufEnter], "*", - &AutocmdKind::ExString("legacy".to_owned()), + &AutocmdKind::ExString { source: "legacy".to_owned(), script: None }, &AutocmdOptions::default(), ) .unwrap(); @@ -4112,6 +4298,37 @@ fn extmark_boolean_option_diagnostic_matches_api_contract() { assert_eq!(error.message(), "Invalid 'right_gravity': expected boolean"); } +#[test] +fn context_validation_rejects_unknown_keys_and_type_names() { + let (editor, _, _, _) = editor_with_lines(&["one"]); + let session = session_with(editor); + + let error = crate::context::nvim_get_context( + &session, + dict(&[("blah", Object::Array(Vec::new()))]), + ) + .unwrap_err(); + assert_eq!(error.to_string(), "Invalid key: 'blah'"); + + let error = crate::context::nvim_get_context(&session, dict(&[("types", Object::Integer(42))])) + .unwrap_err(); + assert_eq!(error.to_string(), "Invalid 'types': expected Array, got Integer"); + + let error = crate::context::nvim_get_context( + &session, + dict(&[( + "types", + Object::Array(vec![ + Object::String(OxStr::from("jumps")), + Object::String(OxStr::from("zub")), + Object::String(OxStr::from("zam")), + ]), + )]), + ) + .unwrap_err(); + assert_eq!(error.to_string(), "Invalid 'type': 'zub'"); +} + #[test] fn context_round_trip_restores_gvars() { let (mut editor, _, _, _) = editor_with_lines(&["one"]); @@ -4122,13 +4339,7 @@ fn context_round_trip_restores_gvars() { let session = session_with(editor); let context = crate::context::nvim_get_context( &session, - dict(&[( - "types", - Object::Array(vec![ - Object::String(OxStr::from("gvars")), - Object::String(OxStr::from("bufs")), - ]), - )]), + dict(&[("types", Object::Array(vec![Object::String(OxStr::from("gvars"))]))]), ) .unwrap(); session.with_editor_mut(|editor| editor.gvars_mut().0.clear()); @@ -4138,6 +4349,351 @@ fn context_round_trip_restores_gvars() { Some(Object::Integer(42)) ); } + +#[test] +fn context_round_trip_restores_binary_blockwise_register_lines_and_width() { + let (mut editor, _, _, _) = editor_with_lines(&["one"]); + let lines = vec![vec![0xff, b'\n', b'x'], vec![0x80]]; + editor + .registers_mut() + .set( + 'a', + RegisterContent::from_binary_lines( + RegisterKind::BlockWise { width: 7 }, + lines.clone(), + ), + ) + .unwrap(); + let session = session_with(editor); + let context = crate::context::nvim_get_context( + &session, + dict(&[("types", Object::Array(vec![Object::String(OxStr::from("regs"))]))]), + ) + .unwrap(); + + session.with_editor_mut(|editor| { + editor + .registers_mut() + .set('a', RegisterContent::characterwise(b"changed").unwrap()) + .unwrap(); + }); + crate::context::nvim_load_context(&session, context).unwrap(); + + assert_eq!( + session.with_editor(|editor| editor.registers().get('a').unwrap().cloned()), + Some(RegisterContent::from_binary_lines( + RegisterKind::BlockWise { width: 7 }, + lines, + )) + ); +} + +#[test] +fn context_round_trip_restores_one_jump() { + let (editor, _, _, _) = editor_with_lines(&["one"]); + let session = session_with(editor); + session + .with_editor_mut(|editor| { + editor.jumplist_mut().push(ox_editor::MarkLocation::in_file( + "/tmp/oxvim-context-jump", + ox_text::Position { lnum: 3, col: 0 }, + )); + }); + let context = crate::context::nvim_get_context( + &session, + dict(&[("types", Object::Array(vec![Object::String(OxStr::from("jumps"))]))]), + ) + .unwrap(); + session.with_editor_mut(|editor| { + *editor.jumplist_mut() = ox_editor::Jumplist::new(); + }); + crate::context::nvim_load_context(&session, context).unwrap(); + let entries = session.with_editor(|editor| editor.jumplist().entries().to_vec()); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].file(), Some(std::path::Path::new("/tmp/oxvim-context-jump"))); + assert_eq!(entries[0].position, ox_text::Position { lnum: 3, col: 0 }); +} + +#[test] +fn context_round_trip_restores_buffer_list() { + let (mut editor, buffer, _, _) = editor_with_lines(&["one"]); + editor + .rename_buffer(buffer, OxStr::from("/tmp/oxvim-context-buf")) + .unwrap(); + let session = session_with(editor); + let context = crate::context::nvim_get_context( + &session, + dict(&[("types", Object::Array(vec![Object::String(OxStr::from("bufs"))]))]), + ) + .unwrap(); + let listed = session.with_editor(|editor| { + editor + .buffers() + .into_iter() + .filter(|handle| { + editor + .buffer(*handle) + .is_ok_and(|state| state.flags.contains(ox_editor::BufferFlags::LISTED)) + }) + .collect::>() + }); + for handle in listed { + crate::buffer::nvim_buf_delete(&session, handle, dict(&[("force", Object::Boolean(true))])) + .unwrap(); + } + let wiped = session.with_editor(|editor| { + editor + .buffers() + .into_iter() + .all(|handle| editor.buffer(handle).is_ok_and(|state| state.name().as_bytes().is_empty())) + }); + assert!(wiped, "the named buffer must be gone before load"); + crate::context::nvim_load_context(&session, context).unwrap(); + let restored = session.with_editor(|editor| { + editor + .buffers() + .into_iter() + .filter_map(|handle| { + editor + .buffer(handle) + .ok() + .map(|state| state.name().to_string_lossy().into_owned()) + .filter(|name| !name.is_empty()) + }) + .collect::>() + }); + assert_eq!(restored, vec!["/tmp/oxvim-context-buf".to_owned()]); +} + +#[test] +fn context_load_frees_saved_registers_before_applying() { + let (mut editor, _, _, _) = editor_with_lines(&["one"]); + editor + .registers_mut() + .set('a', RegisterContent::characterwise(b"kept").unwrap()) + .unwrap(); + let session = session_with(editor); + let context = crate::context::nvim_get_context( + &session, + dict(&[("types", Object::Array(vec![Object::String(OxStr::from("regs"))]))]), + ) + .unwrap(); + session.with_editor_mut(|editor| { + editor + .registers_mut() + .set('b', RegisterContent::characterwise(b"dropped").unwrap()) + .unwrap(); + }); + crate::context::nvim_load_context(&session, context).unwrap(); + assert_eq!( + session.with_editor(|editor| editor.registers().get('a').unwrap().cloned()), + Some(RegisterContent::characterwise(b"kept").unwrap()) + ); + assert_eq!( + session.with_editor(|editor| editor.registers().get('b').unwrap().cloned()), + None + ); +} + +#[test] +fn context_load_frees_saved_registers_even_without_regs() { + let (mut editor, _, _, _) = editor_with_lines(&["one"]); + editor + .registers_mut() + .set('a', RegisterContent::characterwise(b"gone").unwrap()) + .unwrap(); + let session = session_with(editor); + crate::context::nvim_load_context(&session, dict(&[])).unwrap(); + assert_eq!( + session.with_editor(|editor| editor.registers().get('a').unwrap().cloned()), + None + ); +} + +#[test] +fn context_load_restores_blockwise_width_from_stream() { + let (mut editor, _, _, _) = editor_with_lines(&["one"]); + let rows = vec![b"hello".to_vec(), b"world".to_vec()]; + let entry = ox_editor::shada::register_entry( + 'a', + &RegisterContent::from_binary_lines(RegisterKind::BlockWise { width: 10 }, rows.clone()), + 0, + ); + let stream = ox_editor::shada::Stream { + entries: vec![entry], + }; + let mut bytes = Vec::new(); + stream.write(&mut bytes, 0).unwrap(); + let context = dict(&[( + "regs", + Object::Array(crate::context::bytes_to_readfile_lines(&bytes)), + )]); + editor + .registers_mut() + .set('a', RegisterContent::characterwise(b"reset").unwrap()) + .unwrap(); + let session = session_with(editor); + crate::context::nvim_load_context(&session, context).unwrap(); + let loaded = session + .with_editor(|editor| editor.registers().get('a').unwrap().cloned()) + .unwrap(); + assert_eq!(loaded.kind(), RegisterKind::BlockWise { width: 10 }); + assert_eq!(loaded.lines(), &rows); +} + +const UPSTREAM_REGS_FIXTURE: &str = "05cf18e37d8f341069011e84a2726392c40568656c6c6fc405776f726c64a16e61a2727402a2727709"; +const UPSTREAM_JUMPS_FIXTURE: &str = "08cf0a0a0a0a6ae557da3782a166c42f2f686f6d652f616c7068612f726577726974652f4f7876696d2f6f7876696d2d70726f62652d6a756d702d66696c65a16c0308cf0a0a0a0a6ae557da3481a166c42f2f686f6d652f616c7068612f726577726974652f4f7876696d2f6f7876696d2d70726f62652d6a756d702d66696c65"; +const UPSTREAM_GVARS_FIXTURE: &str = "06cf0a0a0a0a6ae557da0d92c40970726f62655f7661722a"; + +#[test] +fn context_load_applies_upstream_register_bytes_and_width() { + let (editor, _, _, _) = editor_with_lines(&["one"]); + let session = session_with(editor); + let context = dict(&[( + "regs", + Object::Array(hex_readfile_lines(UPSTREAM_REGS_FIXTURE)), + )]); + crate::context::nvim_load_context(&session, context).unwrap(); + let loaded = session + .with_editor(|editor| editor.registers().get('a').unwrap().cloned()) + .unwrap(); + assert_eq!(loaded.kind(), RegisterKind::BlockWise { width: 10 }); + assert_eq!(loaded.lines(), &[b"hello".to_vec(), b"world".to_vec()]); +} + +#[test] +fn context_load_applies_upstream_jump_bytes() { + let (editor, _, _, _) = editor_with_lines(&["one"]); + let session = session_with(editor); + let context = dict(&[( + "jumps", + Object::Array(hex_readfile_lines(UPSTREAM_JUMPS_FIXTURE)), + )]); + crate::context::nvim_load_context(&session, context).unwrap(); + let entries = session.with_editor(|editor| editor.jumplist().entries().to_vec()); + assert_eq!(entries.len(), 2); + let file = entries[0] + .file() + .map(std::path::Path::to_string_lossy) + .map(std::borrow::Cow::into_owned); + assert_eq!( + file.as_deref(), + Some("/home/alpha/rewrite/Oxvim/oxvim-probe-jump-file") + ); + assert_eq!(entries[0].position, ox_text::Position { lnum: 3, col: 0 }); + assert_eq!(entries[1].position, ox_text::Position { lnum: 1, col: 0 }); +} + +#[test] +fn context_load_applies_upstream_gvar_bytes() { + let (editor, _, _, _) = editor_with_lines(&["one"]); + let session = session_with(editor); + let context = dict(&[( + "gvars", + Object::Array(hex_readfile_lines(UPSTREAM_GVARS_FIXTURE)), + )]); + crate::context::nvim_load_context(&session, context).unwrap(); + assert_eq!( + session.with_editor(|editor| editor.gvars().get(&OxStr::from("probe_var")).cloned()), + Some(Object::Integer(42)) + ); +} + +#[test] +fn context_load_ignores_unknown_keys_and_non_array_values() { + let (editor, _, _, _) = editor_with_lines(&["one"]); + let session = session_with(editor); + crate::context::nvim_load_context( + &session, + dict(&[ + ("unknown", Object::Integer(123)), + ("regs", Object::Integer(456)), + ]), + ) + .unwrap(); +} + +#[test] +fn context_load_rejects_non_string_register_lines() { + let (editor, _, _, _) = editor_with_lines(&["one"]); + let session = session_with(editor); + let error = crate::context::nvim_load_context( + &session, + dict(&[( + "regs", + Object::Array(vec![Object::Dict(Dict(vec![]))]), + )]), + ) + .unwrap_err(); + assert_eq!( + error.to_string(), + "E474: Failed to convert list to msgpack string buffer" + ); +} + +#[test] +fn context_display_cells_matches_mb_string2cells_len() { + assert_eq!(crate::context::display_cells(b"\xff"), 4); + assert_eq!(crate::context::display_cells(b"\xc0\xaf"), 8); + assert_eq!(crate::context::display_cells(b"\xc3("), 5); + assert_eq!(crate::context::display_cells(b"\xe7\x95\x8c"), 2); + assert_eq!(crate::context::display_cells(b"\xe7\x95\x8c\xff"), 6); + assert_eq!(crate::context::display_cells(b"\xe0\xc3\xa9"), 5); +} + +#[test] +fn context_get_jump_entries_resolves_named_buffers_and_skips_unnamed() { + let (mut editor, buf1, _, _) = editor_with_lines(&["one"]); + let buf2 = editor + .create_buffer_with(ox_text::Buffer::new(), true) + .unwrap(); + editor.rename_buffer(buf2, OxStr::from("BUF1")).unwrap(); + editor + .jumplist_mut() + .push(ox_editor::MarkLocation::in_buffer( + buf1, + ox_text::Position { lnum: 2, col: 0 }, + )); + editor + .jumplist_mut() + .push(ox_editor::MarkLocation::in_buffer( + buf2, + ox_text::Position { lnum: 5, col: 0 }, + )); + let session = session_with(editor); + let context = crate::context::nvim_get_context( + &session, + dict(&[("types", Object::Array(vec![Object::String(OxStr::from("jumps"))]))]), + ) + .unwrap(); + session.with_editor_mut(|editor| { + *editor.jumplist_mut() = ox_editor::Jumplist::new(); + }); + crate::context::nvim_load_context(&session, context).unwrap(); + let entries = session.with_editor(|editor| editor.jumplist().entries().to_vec()); + let cwd = std::env::current_dir().unwrap(); + let expected_buf2_path = cwd.join("BUF1"); + assert!(entries.iter().any(|e| e.file() == Some(&expected_buf2_path) && e.position.lnum == 5)); + assert!(!entries.iter().any(|e| e.position.lnum == 2)); +} + +#[test] +fn context_load_does_not_impute_ru_flag_when_unset() { + let (mut editor, _, _, _) = editor_with_lines(&["one"]); + editor.registers_mut().delete( + ox_editor::RegisterContent::linewise(vec![b"del".to_vec()]).unwrap(), + ); + assert_eq!(editor.registers().unnamed_target_name(), '1'); + let session = session_with(editor); + let context = dict(&[( + "regs", + Object::Array(vec![Object::String(OxStr::from("line"))]), + )]); + let _ = crate::context::nvim_load_context(&session, context); + let after_unnamed = session.with_editor(|editor| editor.registers().unnamed_target_name()); + assert_eq!(after_unnamed, '"'); +} + #[test] fn client_info_round_trip_reports_registered_channel() { let (editor, _, _, _) = editor_with_lines(&["one"]); @@ -4487,6 +5043,83 @@ fn open_win_float_enter_false_preserves_current_window() { ); } +#[test] +fn win_resize_non_current_float_clamps_height_to_one_when_winminheight_is_zero() { + // Upstream window.c:6246: floating windows keep at least one row even + // when 'winminheight' is zero and the float is not the current window. + let (editor, buffer, _, origin) = editor_with_lines(&["one"]); + let session = session_with(editor); + session + .with_editor_mut(|editor| { + editor + .options_mut() + .set_global("winminheight", OptionValue::Number(0)) + }) + .unwrap(); + let float = crate::window::nvim_open_win( + &session, + buffer, + false, + dict(&[ + ("relative", Object::String(OxStr::from("editor"))), + ("row", Object::Float(2.0)), + ("col", Object::Float(4.0)), + ("width", Object::Integer(20)), + ("height", Object::Integer(5)), + ]), + ) + .unwrap(); + assert_eq!( + session.with_editor(Editor::current_window), + Some(origin), + "float enter=false preserves the current window" + ); + crate::window::nvim_win_resize(&session, float, -1, 0, dict(&[])).unwrap(); + assert_eq!(crate::window::nvim_win_get_height(&session, float).unwrap(), 1); + assert_eq!(crate::window::nvim_win_get_width(&session, float).unwrap(), 20); +} + +#[test] +fn win_resize_float_clamps_height_to_winminheight_when_requested_less() { + // Upstream window.c:6250: floating windows clamp their height to + // 'winminheight' (plus winbar) before enforcing the minimum of one row. + // When 'winminheight' is 5, requested heights of 2 and 0 both clamp to 5. + let (editor, buffer, _, origin) = editor_with_lines(&["one"]); + let session = session_with(editor); + session + .with_editor_mut(|editor| { + editor + .options_mut() + .set_global("winminheight", OptionValue::Number(5)) + }) + .unwrap(); + let float = crate::window::nvim_open_win( + &session, + buffer, + false, + dict(&[ + ("relative", Object::String(OxStr::from("editor"))), + ("row", Object::Float(2.0)), + ("col", Object::Float(4.0)), + ("width", Object::Integer(20)), + ("height", Object::Integer(5)), + ]), + ) + .unwrap(); + assert_eq!( + session.with_editor(Editor::current_window), + Some(origin), + "float enter=false preserves the current window" + ); + crate::window::nvim_win_resize(&session, float, -1, 2, dict(&[])).unwrap(); + assert_eq!(crate::window::nvim_win_get_height(&session, float).unwrap(), 5); + assert_eq!(crate::window::nvim_win_get_width(&session, float).unwrap(), 20); + + crate::window::nvim_win_resize(&session, float, -1, 0, dict(&[])).unwrap(); + assert_eq!(crate::window::nvim_win_get_height(&session, float).unwrap(), 5); + assert_eq!(crate::window::nvim_win_get_width(&session, float).unwrap(), 20); +} + #[test] fn open_win_external_reports_typed_not_implemented() { let (editor, buffer, _, _) = editor_with_lines(&["one"]); @@ -4717,9 +5350,9 @@ fn replace_termcodes_translates_named_special_keys() { ("", [K_SPECIAL, b'k', b';']), ("", [K_SPECIAL, b'F', b'1']), ("", [K_SPECIAL, b'F', b'2']), - // and are special keys (K_BS, K_TAB), not literal control bytes. + // stays a special key, but is its control byte: the oracle's + // `vim.keycode('')` is the single byte 0x09, not a K_SPECIAL key. ("", [K_SPECIAL, b'k', b'b']), - ("", [K_SPECIAL, KS_EXTRA, 54]), ("", [K_SPECIAL, KS_EXTRA, 53]), ("", [K_SPECIAL, KS_EXTRA, 97]), // Editing/document keys (K_INS, K_HELP, K_UNDO). @@ -4765,7 +5398,9 @@ fn replace_termcodes_translates_named_special_keys() { for (input, bytes) in NAMED_KEY_CODES { assert_eq!(termcodes(input, true, true).as_bytes(), bytes.as_slice()); } - // Literal control keys remain single bytes. + // Literal control keys remain single bytes, and is one of them: + // the oracle answers 0x09 for `vim.keycode('')`. + assert_eq!(termcodes("", true, true).as_bytes(), b"\t"); assert_eq!(termcodes("", true, true).as_bytes(), b"\r"); assert_eq!(termcodes("", true, true).as_bytes(), &[0x1b]); assert_eq!(termcodes("", true, true).as_bytes(), b" "); @@ -8841,7 +9476,7 @@ fn open_tabpage_fires_creation_events_in_new_tab_context() { .register_legacy( &[event], "*", - &AutocmdKind::ExString("echo fired".to_owned()), + &AutocmdKind::ExString { source: "echo fired".to_owned(), script: None }, &AutocmdOptions::default(), ) .unwrap(); @@ -9759,10 +10394,7 @@ fn set_hl_link_global_reprojects_across_namespaces() { &session, 7, OxStr::from("FarLink"), - dict(&[( - "link_global", - Object::String(OxStr::from("GlobalTarget")), - )]), + dict(&[("link_global", Object::String(OxStr::from("GlobalTarget")))]), ) .unwrap(); // Make ns 7 the active namespace before the global target changes: @@ -9882,10 +10514,7 @@ fn set_hl_namespace_zero_link_global_reprojects() { crate::ui::nvim_set_hl(&session, 0, OxStr::from(name), dict(entries)).unwrap(); }; set("GT", &[("fg", Object::Integer(0x00_0011))]); - set( - "GL", - &[("link_global", Object::String(OxStr::from("GT")))], - ); + set("GL", &[("link_global", Object::String(OxStr::from("GT")))]); set("GT", &[("fg", Object::Integer(0x00_ccdd))]); let painted = session.with_state(|state| { state.hl_namespaces[&0] @@ -9895,3 +10524,4 @@ fn set_hl_namespace_zero_link_global_reprojects() { }); assert_eq!(painted, Some(Some(0x00_ccdd))); } + diff --git a/crates/ox-api/src/ui.rs b/crates/ox-api/src/ui.rs index 76168d25..8ec74cc6 100644 --- a/crates/ox-api/src/ui.rs +++ b/crates/ox-api/src/ui.rs @@ -322,9 +322,7 @@ fn ui_extra_mut( operation: impl FnOnce(&mut UiExtra) -> R, ) -> Result { require_ui(session, channel)?; - Ok(session.with_state_mut(|state| { - operation(state.ui_extra.entry(channel).or_default()) - })) + Ok(session.with_state_mut(|state| operation(state.ui_extra.entry(channel).or_default()))) } /// `api_err_exp` shape (`api/private/validate.c:41-58`): a name without a @@ -3572,14 +3570,8 @@ mod tests { .map(|extra| (extra.pum_nlines, extra.pum_bounds)) }) }; - assert_eq!( - popup_state(&first), - Some((5, Some((10.0, 4.0, 1.0, 2.0)))) - ); - assert_eq!( - popup_state(&second), - Some((9, Some((20.0, 8.0, 3.0, 4.0)))) - ); + assert_eq!(popup_state(&first), Some((5, Some((10.0, 4.0, 1.0, 2.0))))); + assert_eq!(popup_state(&second), Some((9, Some((20.0, 8.0, 3.0, 4.0))))); } #[expect( diff --git a/crates/ox-api/src/window.rs b/crates/ox-api/src/window.rs index 6f6a3821..8313ca1a 100644 --- a/crates/ox-api/src/window.rs +++ b/crates/ox-api/src/window.rs @@ -3,8 +3,8 @@ use std::collections::BTreeMap; use ox_editor::{ Anchor, AutocmdContext, Border, BorderText, BufferFlags, BufferRelease, BufferState, Editor, Event, Extmark, ExtmarkPosition, ExtmarkVirtualLinesOverflow, ExtmarkVirtualTextPosition, - FileIO, Margins, OptionStore, OptionValue, RealFileIO, RelativeTo, TextAlignment, - VirtualTextChunk, WinConfig, + FileIO, Margins, MarkLocation, MarkTarget, OptionStore, OptionValue, RealFileIO, RelativeTo, + TextAlignment, VirtualTextChunk, WinConfig, }; use ox_text::{Buffer, Position}; use unicode_width::UnicodeWidthChar; @@ -684,7 +684,7 @@ fn set_dimension( } if let Some(height) = height { editor - .set_window_height(window, height) + .set_window_text_height(window, height) .map_err(exception)?; } Ok(()) @@ -807,9 +807,7 @@ where Ok(_) => false, Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, Err(_) => { - return Err(exception( - ox_editor::buffer::BufferStateError::Unloaded, - )); + return Err(exception(ox_editor::buffer::BufferStateError::Unloaded)); } }; @@ -848,9 +846,7 @@ where Ok(content) => content, Err(_) => { finish_buffer_switch(session, buffer, &mut restore, false, true); - return Err(exception( - ox_editor::buffer::BufferStateError::Unloaded, - )); + return Err(exception(ox_editor::buffer::BufferStateError::Unloaded)); } }; let text = match Buffer::from_bytes(content.as_bytes()) { @@ -918,16 +914,10 @@ pub fn nvim_win_set_buf( move |session, succeeded| { session.with_editor_mut(|editor| { if !succeeded - && editor - .window(win) - .is_ok_and(|window| window.buffer == buf) + && editor.window(win).is_ok_and(|window| window.buffer == buf) && editor.buffer(original).is_ok() { - let _ = editor.set_window_buffer( - win, - original, - BufferRelease::KeepLoaded, - ); + let _ = editor.set_window_buffer(win, original, BufferRelease::KeepLoaded); } if caller != win && editor.window(caller).is_ok() { let _ = editor.set_current_window(caller); @@ -1007,11 +997,7 @@ pub fn nvim_win_set_cursor( pub fn nvim_win_get_height(session: &ApiSession, win: WinHandle) -> Result { let win = resolve_window(session, win)?; session.with_editor(|editor| { - if let Some(config) = editor.window_config(win).map_err(exception)? { - return i64::try_from(config.height) - .map_err(|_| ApiError::exception("Window height exceeds API integer range")); - } - i64::try_from(editor.window_geometry(win).map_err(exception)?.height) + i64::try_from(editor.window_text_height(win).map_err(exception)?) .map_err(|_| ApiError::exception("Window height exceeds API integer range")) }) } @@ -1096,6 +1082,11 @@ pub fn nvim_win_set_var( value: Object, ) -> Result<(), ApiError> { let win = resolve_window(session, win)?; + if crate::global::host_present(session) { + return crate::runtime::with_command_executor(session, |session, executor| { + executor.set_window_var(session, win, name, value) + }); + } session.with_editor_mut(|editor| { editor .window_variables_mut(win) @@ -1112,6 +1103,11 @@ pub fn nvim_win_set_var( )] pub fn nvim_win_del_var(session: &ApiSession, win: WinHandle, name: OxStr) -> Result<(), ApiError> { let win = resolve_window(session, win)?; + if crate::global::host_present(session) { + return crate::runtime::with_command_executor(session, |session, executor| { + executor.del_window_var(session, win, name) + }); + } session.with_editor_mut(|editor| { let variables = editor.window_variables_mut(win).map_err(exception)?; let Some(index) = variables @@ -1303,17 +1299,13 @@ pub fn nvim_win_close(session: &ApiSession, win: WinHandle, force: bool) -> Resu events.push(Event::BufDelete); events.push(Event::BufWipeout); } - BufferClosePolicy::Hide - | BufferClosePolicy::Default - | BufferClosePolicy::Unload => {} + BufferClosePolicy::Hide | BufferClosePolicy::Default | BufferClosePolicy::Unload => {} } } for event in events { fire_buffer_event(session, event, buffer)?; let target_live = session.with_editor(|editor| { - editor - .window(win) - .is_ok_and(|state| state.buffer == buffer) + editor.window(win).is_ok_and(|state| state.buffer == buffer) && editor.buffer(buffer).is_ok() }); if !target_live { @@ -1325,10 +1317,7 @@ pub fn nvim_win_close(session: &ApiSession, win: WinHandle, force: bool) -> Resu // An event may have attached the buffer elsewhere or closed this // window without deleting the buffer. Revalidate before committing // the structural removal and its policy-specific state change. - if !editor - .window(win) - .is_ok_and(|state| state.buffer == buffer) - { + if !editor.window(win).is_ok_and(|state| state.buffer == buffer) { return Ok(()); } editor @@ -1708,17 +1697,19 @@ pub fn nvim_win_resize( let floating = editor.window_config(win).map_err(exception)?.is_some(); let current = editor.current_window() == Some(win); if height >= 0 { - // win_setheight_win (window.c:6242): the current window keeps at + // win_setheight_win (window.c:6250): the current window keeps at // least max('winminheight', 1) rows plus the winbar row; other - // windows 'winminheight' rows plus the winbar row; floats at - // least one row (window.c:6246). + // windows 'winminheight' rows plus the winbar row; floats also + // clamp to at least one row (window.c:6253). let minimum = option_number(editor.options(), "winminheight", 1); let minimum = if current { minimum.max(1) } else { minimum }; let height = - height.max(minimum + i64::from(winbar_shown(editor.options(), win, false))); + height.max(minimum + i64::from(winbar_shown(editor.options(), win, floating))); let height = if floating { height.max(1) } else { height }; let rows = usize::try_from(height).unwrap_or(usize::MAX); - editor.set_window_height(win, rows).map_err(exception)?; + editor + .set_window_text_height(win, rows) + .map_err(exception)?; } if width >= 0 { // win_setwidth_win (window.c:6415-6419): only the current window @@ -2894,7 +2885,10 @@ mod tests { ); let (loaded, line) = session.with_editor(|editor| { let state = editor.buffer(target).unwrap(); - (state.residency.is_loaded(), state.text().unwrap().line(1).unwrap()) + ( + state.residency.is_loaded(), + state.text().unwrap().line(1).unwrap(), + ) }); assert!(loaded); assert!(line.is_empty()); @@ -3066,6 +3060,74 @@ mod tests { assert_eq!(nvim_win_get_height(&session, float).unwrap(), 7); } + #[test] + fn resize_clamps_non_current_float_height_to_one_when_winminheight_is_zero() { + // win_setheight_win (window.c:6246): even when 'winminheight' is zero + // and the float is not current, its height is clamped to at least one row. + let (session, buffer, _window) = session_with(&["a"], 80, 24); + session + .with_editor_mut(|editor| { + editor + .options_mut() + .set_global("winminheight", OptionValue::Number(0)) + }) + .unwrap(); + let float = nvim_open_win( + &session, + buffer, + false, + dict(&[ + ("relative", Object::String(OxStr::from("editor"))), + ("row", Object::Float(2.0)), + ("col", Object::Float(4.0)), + ("width", Object::Integer(20)), + ("height", Object::Integer(5)), + ]), + ) + .unwrap(); + assert!(session.with_editor(|editor| editor.current_window() != Some(float))); + nvim_win_resize(&session, float, -1, 0, dict(&[])).unwrap(); + assert_eq!(nvim_win_get_height(&session, float).unwrap(), 1); + assert_eq!(nvim_win_get_width(&session, float).unwrap(), 20); + } + + #[test] + fn resize_clamps_float_height_to_winminheight() { + // win_setheight_win (window.c:6250): floating windows clamp their + // height to 'winminheight' (plus winbar) before enforcing the minimum + // of one row. When 'winminheight' is 5, requested heights of 2 and 0 + // both clamp to 5. + let (session, buffer, _window) = session_with(&["a"], 80, 24); + session + .with_editor_mut(|editor| { + editor + .options_mut() + .set_global("winminheight", OptionValue::Number(5)) + }) + .unwrap(); + let float = nvim_open_win( + &session, + buffer, + false, + dict(&[ + ("relative", Object::String(OxStr::from("editor"))), + ("row", Object::Float(2.0)), + ("col", Object::Float(4.0)), + ("width", Object::Integer(20)), + ("height", Object::Integer(5)), + ]), + ) + .unwrap(); + assert!(session.with_editor(|editor| editor.current_window() != Some(float))); + nvim_win_resize(&session, float, -1, 2, dict(&[])).unwrap(); + assert_eq!(nvim_win_get_height(&session, float).unwrap(), 5); + assert_eq!(nvim_win_get_width(&session, float).unwrap(), 20); + + nvim_win_resize(&session, float, -1, 0, dict(&[])).unwrap(); + assert_eq!(nvim_win_get_height(&session, float).unwrap(), 5); + assert_eq!(nvim_win_get_width(&session, float).unwrap(), 20); + } + #[test] fn resize_resizes_a_tiled_split() { let (session, buffer, window) = session_with(&["a"], 80, 24); @@ -3081,6 +3143,7 @@ mod tests { #[test] fn resize_clamps_height_to_winminheight() { // The clamp is a split negotiation: a lone window fills the frame + // and cannot shrink (nothing can take the space), so the clamp // needs a sibling (win_setheight_win -> frame_setheight, // window.c:6238-6254). @@ -3097,6 +3160,22 @@ mod tests { nvim_win_resize(&session, window, -1, 2, dict(&[])).unwrap(); assert_eq!(nvim_win_get_height(&session, window).unwrap(), 5); } + #[test] + fn resize_height_round_trips_text_rows_for_a_tiled_window() { + let (session, buffer, window) = session_with(&["a"], 80, 24); + session + .with_editor_mut(|editor| { + let tab = editor.window_tabpage(window).unwrap(); + editor.split_horizontal(tab, window, buffer, false) + }) + .unwrap(); + let saved = nvim_win_get_height(&session, window).unwrap(); + let reduced = saved.saturating_sub(2).max(1); + nvim_win_resize(&session, window, -1, reduced, dict(&[])).unwrap(); + assert_eq!(nvim_win_get_height(&session, window).unwrap(), reduced); + nvim_win_resize(&session, window, -1, saved, dict(&[])).unwrap(); + assert_eq!(nvim_win_get_height(&session, window).unwrap(), saved); + } // -- nvim_win_text_height ------------------------------------------------ diff --git a/crates/ox-editor/src/autocmd.rs b/crates/ox-editor/src/autocmd.rs index fb69561a..c1e5cb87 100644 --- a/crates/ox-editor/src/autocmd.rs +++ b/crates/ox-editor/src/autocmd.rs @@ -225,8 +225,15 @@ pub struct AutocmdEntryId(pub u64); /// Host-owned action referenced by an autocommand. #[derive(Clone, Debug, Eq, PartialEq)] pub enum AutocmdKind { - /// Ex source to execute later. - ExString(String), + /// Ex source to execute later, with the script that defined it so an `s:` + /// reference in the body keeps that script's scope. `None` means the + /// definition had no script context. + ExString { + /// The Ex source registered for this entry. + source: String, + /// The `s:` script that defined the body (`autocmd.c` `sc_sid`). + script: Option, + }, /// Named zero-argument Vimscript function to invoke later. VimscriptFunction(String), /// Lua registry callback identity to invoke later. @@ -289,10 +296,7 @@ impl AutocmdAction { Object::String(self.match_name.clone()), ), (OxStr::from("buf"), buffer), - ( - OxStr::from("file"), - Object::String(self.file_name.clone()), - ), + (OxStr::from("file"), Object::String(self.file_name.clone())), ]; if self.group != AugroupId::default() { let group = i64::try_from(self.group.0) @@ -1036,27 +1040,32 @@ impl Autocmds { }), match_name: match context.match_name { Some(name) => OxStr::from(name), - None => context.file_name.map_or_else(|| OxStr(Vec::new()), |name| { - let name = OxStr::from(name); - if entry.event.pattern_kind() == PatternKind::None || name.as_bytes().is_empty() { - return name; - } - let path = crate::excmd_exec::path_from_ox_str(&name); - if path.is_absolute() { - return name; - } - let full = std::env::current_dir().unwrap_or_default().join(path); - #[cfg(unix)] - { - use std::os::unix::ffi::OsStrExt; - - OxStr::from(full.as_os_str().as_bytes()) - } - #[cfg(not(unix))] - { - OxStr::from(full.to_string_lossy().as_ref()) - } - }), + None => context.file_name.map_or_else( + || OxStr(Vec::new()), + |name| { + let name = OxStr::from(name); + if entry.event.pattern_kind() == PatternKind::None + || name.as_bytes().is_empty() + { + return name; + } + let path = crate::excmd_exec::path_from_ox_str(&name); + if path.is_absolute() { + return name; + } + let full = std::env::current_dir().unwrap_or_default().join(path); + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + + OxStr::from(full.as_os_str().as_bytes()) + } + #[cfg(not(unix))] + { + OxStr::from(full.to_string_lossy().as_ref()) + } + }, + ), }, file_name: OxStr::from(context.file_name.unwrap_or_default()), description: entry.options.description.clone(), diff --git a/crates/ox-editor/src/buffer.rs b/crates/ox-editor/src/buffer.rs index b965727b..86ff7841 100644 --- a/crates/ox-editor/src/buffer.rs +++ b/crates/ox-editor/src/buffer.rs @@ -200,17 +200,18 @@ fn span_bytes(lines: &[Vec], start: ExtmarkPosition, end: ExtmarkPosition) - /// total for the editor's internal invariant while matching the replacement /// character accounting if an invalid byte sequence ever crosses this seam. fn linewise_deleted_sizes(lines: &[Vec]) -> (usize, usize, usize) { - lines.iter().fold((0, 0, 0), |(bytes, codepoints, codeunits), line| { - let text = String::from_utf8_lossy(line); - ( - bytes.saturating_add(line.len().saturating_add(1)), - codepoints.saturating_add(text.chars().count().saturating_add(1)), - codeunits.saturating_add(text.encode_utf16().count().saturating_add(1)), - ) - }) + lines + .iter() + .fold((0, 0, 0), |(bytes, codepoints, codeunits), line| { + let text = String::from_utf8_lossy(line); + ( + bytes.saturating_add(line.len().saturating_add(1)), + codepoints.saturating_add(text.chars().count().saturating_add(1)), + codeunits.saturating_add(text.encode_utf16().count().saturating_add(1)), + ) + }) } - /// End column relative to the change start: absolute when the span covers /// several rows, start-relative within a single row, matching upstream /// `on_bytes` (`buf_updates_send_tick` reports the same two shapes). @@ -415,10 +416,28 @@ pub struct BufferState { pub residency: BufferResidency, /// Number of windows displaying the buffer. pub attachments: usize, + /// Upstream `b_did_filetype`: whether the `FileType` event fired for this + /// buffer with a non-empty value and no reset has run since. `:setf` is a + /// silent no-op while set; `did_filetype()` reads it. + did_filetype: bool, + /// Absolute file path resolved when the buffer was loaded/staged. + pub file_path: Option, + /// Buffer-local working directory set by `:bcd` (`b_localdir`); `None` + /// means the buffer follows the window/tab/global chain. This port owns + /// the field on `BufferState` (upstream keeps it on `buf_T`) so the Ex + /// layer can resolve `getcwd(-1, -1, {bufnr})` without a window. + pub local_directory: Option, + /// Previous directory for this buffer's `bcd -` (`b_prevdir`); `None` + /// when no `:bcd` has recorded one yet. + pub previous_directory: Option, /// Text generation last consumed by diagnostics. pub changedtick_diag: u64, /// Text generation last consumed by fold computation. pub changedtick_fold: u64, + /// Per-buffer syntax group registry. + pub syntax: crate::syntax::SyntaxRegistry, + /// Loaded syntax file name, preventing redundant re-sourcing. + pub loaded_syntax: Option, } impl Default for BufferState { @@ -476,8 +495,14 @@ impl BufferState { saved_undo_state: (0, 0), forced_modified: false, attachments: 0, + did_filetype: false, + file_path: None, + local_directory: None, + previous_directory: None, changedtick_diag: 0, changedtick_fold: 0, + syntax: crate::syntax::SyntaxRegistry::default(), + loaded_syntax: None, } } @@ -525,6 +550,24 @@ impl BufferState { self.name = name; } + /// Reports upstream `b_did_filetype` for `:setf` gating and + /// `did_filetype()`. + #[must_use] + pub const fn did_filetype(&self) -> bool { + self.did_filetype + } + + /// Records a `FileType` fire with a non-empty value (`:setf` gates on + /// this until the next reset). + pub const fn mark_did_filetype(&mut self) { + self.did_filetype = true; + } + + /// Clears upstream `b_did_filetype` (`:setf FALLBACK`, batch reset). + pub const fn clear_did_filetype(&mut self) { + self.did_filetype = false; + } + /// Returns buffer-local API variables. #[must_use] pub const fn variables(&self) -> &Dict { @@ -598,11 +641,7 @@ impl BufferState { } /// Adds a subscription, queuing the previous value when its identity is reused. - pub fn insert_subscription( - &mut self, - id: u128, - subscription: BufferAttachSubscription, - ) { + pub fn insert_subscription(&mut self, id: u128, subscription: BufferAttachSubscription) { let is_rpc = subscription.channel_id != 0; let send_buffer = subscription.send_buffer; if let Some(previous) = self.subscriptions.insert(id, subscription) { @@ -648,10 +687,7 @@ impl BufferState { /// Removes one attachment as part of a reload without creating a normal /// release record. The reload drain owns that record so it can preserve /// callback ordering before freeing its Lua references. - pub fn remove_subscription_for_reload( - &mut self, - id: u128, - ) -> Option { + pub fn remove_subscription_for_reload(&mut self, id: u128) -> Option { let subscription = self.subscriptions.remove(&id)?; for event in &mut self.pending_bytes { event.subscribers.retain(|recipient| *recipient != id); @@ -714,14 +750,12 @@ impl BufferState { subscription, }) .collect::>(); - released.extend( - std::mem::take(&mut self.subscriptions) - .into_values() - .map(|subscription| BufferSubscriptionRelease { - buffer, - subscription, - }), - ); + released.extend(std::mem::take(&mut self.subscriptions).into_values().map( + |subscription| BufferSubscriptionRelease { + buffer, + subscription, + }, + )); released } @@ -838,11 +872,10 @@ impl BufferState { if recipients.is_empty() { return; } - self.pending_bytes - .push(BufferBytesEvent::reload_for( - recipients, - self.script_changedtick(), - )); + self.pending_bytes.push(BufferBytesEvent::reload_for( + recipients, + self.script_changedtick(), + )); } /// Attaches one window to resident text. @@ -2084,8 +2117,14 @@ mod tests { // A re-read has no splice geometry: it must not replay as a delta. assert_eq!( ( - event.start_row, event.start_col, event.start_byte, event.old_row, - event.old_col, event.old_byte, event.new_row, event.new_col, + event.start_row, + event.start_col, + event.start_byte, + event.old_row, + event.old_col, + event.old_byte, + event.new_row, + event.new_col, event.new_byte, ), (0, 0, 0, 0, 0, 0, 0, 0, 0) diff --git a/crates/ox-editor/src/builtins/assert.rs b/crates/ox-editor/src/builtins/assert.rs index 1b22372c..3c60453f 100644 --- a/crates/ox-editor/src/builtins/assert.rs +++ b/crates/ox-editor/src/builtins/assert.rs @@ -380,21 +380,17 @@ fn assert_equal_message(expected: &Typval, actual: &Typval) -> String { left.key == right.key && left.value == right.value }; let equal = expected_data - .entries .iter() .filter(|entry| { actual_data - .entries .iter() .any(|other| entries_equal(entry, other)) }) .count(); let mut expected_difference = expected_data - .entries .iter() .filter(|entry| { !actual_data - .entries .iter() .any(|other| entries_equal(entry, other)) }) @@ -402,11 +398,9 @@ fn assert_equal_message(expected: &Typval, actual: &Typval) -> String { .collect::>(); expected_difference.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes())); let mut actual_difference = actual_data - .entries .iter() .filter(|entry| { !expected_data - .entries .iter() .any(|other| entries_equal(entry, other)) }) diff --git a/crates/ox-editor/src/builtins/buffer.rs b/crates/ox-editor/src/builtins/buffer.rs index e5f5670e..2bb5d62f 100644 --- a/crates/ox-editor/src/builtins/buffer.rs +++ b/crates/ox-editor/src/builtins/buffer.rs @@ -20,7 +20,8 @@ use crate::{Editor, LineReplaceRequest}; use crate::autocmd::Event; use crate::excmd_exec::{ CurrentBuffer, EvalHost, Flow, fire_buffer_lifecycle, flow_to_eval_error, object_to_typval, - option_to_typval, path_from_ox_str, resolve_buffer_argument, typval_number, typval_to_object, + find_buffer_argument, option_to_typval, path_from_ox_str, resolve_buffer_argument, typval_number, + typval_to_object, typval_to_option, typval_to_text, }; @@ -72,6 +73,9 @@ pub(crate) fn call( "changenr" => host .access .with_ex_editor(|editor| call_changenr_builtin(editor, args)), + "did_filetype" => host + .access + .with_ex_editor(|editor| call_did_filetype_builtin(editor, args)), "undotree" => host .access .with_ex_editor(|editor| call_undotree_builtin(editor, args)), @@ -237,8 +241,7 @@ fn call_bufexists_builtin(editor: &Editor, args: &[Typval]) -> ox_eval::Result OxStr { + let Some(directory) = editor.effective_directory() else { + return name.clone(); + }; + let text = name.to_string_lossy(); + if text.contains("://") { + return name.clone(); + } + let directory = directory.to_string_lossy(); + let prefix = format!("{}/", directory.trim_end_matches('/')); + match text.strip_prefix(prefix.as_str()) { + Some(tail) if !tail.is_empty() => OxStr::from(tail), + _ => name.clone(), + } +} + fn call_bufadd_builtin(editor: &mut Editor, args: &[Typval]) -> ox_eval::Result { let [value] = args else { let (code, message) = if args.is_empty() { @@ -1125,6 +1153,25 @@ fn call_changenr_builtin(editor: &Editor, args: &[Typval]) -> ox_eval::Result ox_eval::Result { + if !args.is_empty() { + return Err(EvalError::new( + "E118", + 0, + "Too many arguments for function: did_filetype", + )); + } + let did = editor + .current_buffer() + .and_then(|buffer| editor.buffer(buffer).ok()) + .is_some_and(|state| state.did_filetype()); + Ok(Typval::Number(i64::from(did))) +} + /// `undotree([{buf}])`: the buffer's undo state and header list /// (`f_undotree`, `undo.c:3243-3263`). /// diff --git a/crates/ox-editor/src/builtins/buffer_lifecycle_tests.rs b/crates/ox-editor/src/builtins/buffer_lifecycle_tests.rs index dd32f874..525db2a1 100644 --- a/crates/ox-editor/src/builtins/buffer_lifecycle_tests.rs +++ b/crates/ox-editor/src/builtins/buffer_lifecycle_tests.rs @@ -293,7 +293,7 @@ fn bufload_read_pre_handler_reenters_two_levels() { .register_legacy( &[Event::BufReadPre], "", - &AutocmdKind::ExString(format!("call bufload({})", i64::from(next))), + &AutocmdKind::ExString { source: format!("call bufload({})", i64::from(next)), script: None }, &AutocmdOptions { buffer: Some(buffer), nested: true, @@ -308,7 +308,7 @@ fn bufload_read_pre_handler_reenters_two_levels() { .register_legacy( &[Event::BufReadPre], "", - &AutocmdKind::ExString(format!("let g:deep = [bufname({})]", i64::from(outer))), + &AutocmdKind::ExString { source: format!("let g:deep = [bufname({})]", i64::from(outer)), script: None }, &AutocmdOptions { buffer: Some(inner), nested: true, diff --git a/crates/ox-editor/src/builtins/cmdline.rs b/crates/ox-editor/src/builtins/cmdline.rs new file mode 100644 index 00000000..820dbee5 --- /dev/null +++ b/crates/ox-editor/src/builtins/cmdline.rs @@ -0,0 +1,729 @@ +//! Command-line introspection builtins (`eval/funcs.c` `f_getcmd*`, +//! `f_setcmdpos`, `f_pumvisible`, `f_wildmenumode`) and command-line history +//! builtins (`cmdhist.c` `f_histadd`, `f_histdel`, `f_histget`, `f_histnr`). +//! +//! These read the live command line owned by [`Mode::Cmdline`] state on the +//! mode machine, or the [`PromptDialog`] backing `input()` (upstream's `@` +//! command line, `ex_getln.c:756-760`). The mode machine lives on +//! `ExRuntime`, not on `Editor`, so every entry snapshots what it needs +//! through a short `try_borrow` and drops it before touching the editor — +//! user code must never execute under a machine borrow. +//! +//! The history banks themselves live on [`Editor`], beside registers and the +//! quickfix stack — deliberately not on the mode machine, whose Ex recall +//! list (`ModeMachine::cmdline_history`, ``/`` walk state) is a +//! separate interactive mechanism the parent still owns. + +use ox_eval::{EvalError, RegexEngine, builtin_spec}; +use ox_types::{OxStr, Typval}; + +use super::input_string_arg; +use crate::Editor; +use crate::excmd_exec::{EvalHost, ExEditorAccess, VimRegex}; +use crate::history::HistoryKind; +use crate::mode::{CmdlineKind, Mode}; +use crate::options::OptionValue; +use crate::script::FileIO; +use crate::search::SearchDirection; + +/// Routes one command-line builtin. +pub(crate) fn call( + host: &mut EvalHost<'_, F, E>, + name: &str, + args: &[Typval], +) -> ox_eval::Result { + check_arity(name, args.len())?; + match name { + "getcmdline" => Ok(Typval::String(OxStr::from(cmdline_text(host).as_str()))), + "getcmdpos" => Ok(Typval::Number(cmdline_cursor(host))), + "setcmdpos" => call_setcmdpos(host, args), + "getcmdtype" => Ok(Typval::String(OxStr::from(cmdline_type(host)))), + // `getcmdwintype` (`eval.c:f_getcmdwintype`): the command-line window's + // type, or an empty string while no such window is open. This port has + // no command-line window, so the answer is always empty, which is what + // upstream returns outside `q:`, `q/` and `q?`. + "getcmdwintype" => Ok(Typval::String(OxStr::from(""))), + "getcmdprompt" => Ok(Typval::String(OxStr::from(cmdline_prompt(host).as_str()))), + "pumvisible" => Ok(Typval::Number(i64::from(completion_visible(host)))), + "wildmenumode" => Ok(Typval::Number(0)), + "histadd" => call_histadd(host, args), + "histdel" => call_histdel(host, args), + "histget" => call_histget(host, args), + "histnr" => call_histnr(host, args), + _ => unreachable!("cmdline builtin route and dispatcher disagree"), + } +} + +/// Enforces the `eval.lua` argument counts the way upstream's function table +/// does before a builtin body runs. +fn check_arity(name: &str, count: usize) -> ox_eval::Result<()> { + let spec = builtin_spec(name) + .ok_or_else(|| EvalError::new("E117", 0, format!("Unknown function: {name}")))?; + if count < spec.min_args { + return Err(EvalError::new( + "E119", + 0, + format!("Not enough arguments for function: {name}"), + )); + } + if spec.max_args.is_some_and(|maximum| count > maximum) { + return Err(EvalError::new( + "E118", + 0, + format!("Too many arguments for function: {name}"), + )); + } + Ok(()) +} + +/// `tv_get_number` (`typval.c`): numbers pass, floats truncate, bools become +/// 0/1, strings parse their leading numeric prefix (else 0); containers are +/// `E728`/`E730`, anything else is `E729`. +fn tv_get_number(value: &Typval) -> ox_eval::Result { + match value { + Typval::Number(value) => Ok(*value), + Typval::Float(value) => { + // `as` is exact here: the range check proves the truncation + // fits, and NaN/infinite magnitudes take the error arm instead + // of saturating. The upper bound is exclusive: `i64::MAX as + // f64` rounds up to 2^63, which does not fit, while every + // `f64` below it truncates exactly. The lower bound is + // inclusive: -2^63 is exactly representable. + if value.is_finite() && *value >= i64::MIN as f64 && *value < i64::MAX as f64 { + Ok(value.trunc() as i64) + } else { + Err(EvalError::new("E729", 0, "Using invalid value as a Number")) + } + } + Typval::Bool(value) => Ok(i64::from(*value)), + Typval::String(value) => Ok(number_prefix(&value.to_string_lossy())), + Typval::List(_) => Err(EvalError::new("E730", 0, "Using a List as a Number")), + Typval::Dict(_) => Err(EvalError::new("E728", 0, "Using a Dictionary as a Number")), + _ => Err(EvalError::new("E729", 0, "Using invalid value as a Number")), + } +} + +/// Leading-numeric-prefix parse (`typval.c` `tv_get_number` string leg): +/// optional sign, then digits; no digits means 0. +fn number_prefix(text: &str) -> i64 { + let bytes = text.trim_start().as_bytes(); + let mut end = 0; + if bytes + .first() + .is_some_and(|byte| matches!(byte, b'+' | b'-')) + { + end = 1; + } + while bytes.get(end).is_some_and(u8::is_ascii_digit) { + end += 1; + } + if end == 0 || (end == 1 && !bytes[0].is_ascii_digit()) { + return 0; + } + std::str::from_utf8(&bytes[..end]) + .ok() + .and_then(|number| number.parse().ok()) + .unwrap_or(0) +} + +/// The live command-line text: the open `Mode::Cmdline` first, then the +/// `input()` dialog reply (`@`), else empty. +fn cmdline_text(host: &EvalHost<'_, F, E>) -> String { + if let Some(text) = host + .runtime + .mode_machine + .as_ref() + .and_then(|machine| machine.try_borrow().ok()) + .as_deref() + .and_then(|machine| match machine.mode() { + Mode::Cmdline(state) => Some(state.text.clone()), + _ => None, + }) + { + return text; + } + host.access.with_ex_editor(|editor| { + editor + .prompt_dialog + .as_ref() + .map_or(String::new(), |dialog| { + dialog.reply.to_string_lossy().into_owned() + }) + }) +} + +/// 1-based character cursor for `getcmdpos()`; 0 when no command line is +/// open. Deferred `setcmdpos()` positions are deliberately invisible here: +/// upstream only applies `new_cmdpos` at the next `CTRL-R` paste. +fn cmdline_cursor(host: &EvalHost<'_, F, E>) -> i64 { + let (text, cursor_byte, active) = cmdline_snapshot(host); + if !active { + return 0; + } + let end = floor_char_boundary(&text, cursor_byte); + i64::try_from(text[..end].chars().count() + 1).unwrap_or(i64::MAX) +} + +/// `(line, cursor_byte, open)`: the raw snapshot behind [`cmdline_cursor`]. +fn cmdline_snapshot( + host: &EvalHost<'_, F, E>, +) -> (String, usize, bool) { + if let Some((text, cursor)) = host + .runtime + .mode_machine + .as_ref() + .and_then(|machine| machine.try_borrow().ok()) + .as_deref() + .and_then(|machine| match machine.mode() { + Mode::Cmdline(state) => Some((state.text.clone(), state.cursor_byte)), + _ => None, + }) + { + return (text, cursor, true); + } + if let Some((reply, cursor)) = host.access.with_ex_editor(|editor| { + editor + .prompt_dialog + .as_ref() + .map(|dialog| (dialog.reply.to_string_lossy().into_owned(), dialog.cursor)) + }) { + return (reply, cursor, true); + } + (String::new(), 0, false) +} + +/// `:`/`/`/`?` for the open command line, `@` for `input()`, else empty. +fn cmdline_type(host: &EvalHost<'_, F, E>) -> &'static str { + if let Some(kind) = host + .runtime + .mode_machine + .as_ref() + .and_then(|machine| machine.try_borrow().ok()) + .as_deref() + .and_then(|machine| match machine.mode() { + Mode::Cmdline(state) => Some(state.kind), + _ => None, + }) + { + return match kind { + CmdlineKind::Ex => ":", + CmdlineKind::Search(direction) => { + if matches!(direction, SearchDirection::Forward) { + "/" + } else { + "?" + } + } + }; + } + if host + .access + .with_ex_editor(|editor| editor.prompt_dialog.is_some()) + { + return "@"; + } + "" +} + +/// Prompt text: the type character for `:`/`/`/`?`, the dialog prompt for +/// `input()`, else empty. +fn cmdline_prompt(host: &EvalHost<'_, F, E>) -> String { + if let Some(kind) = host + .runtime + .mode_machine + .as_ref() + .and_then(|machine| machine.try_borrow().ok()) + .as_deref() + .and_then(|machine| match machine.mode() { + Mode::Cmdline(state) => Some(state.kind), + _ => None, + }) + { + return match kind { + CmdlineKind::Ex => ":".to_owned(), + CmdlineKind::Search(direction) => { + if matches!(direction, SearchDirection::Forward) { + "/".to_owned() + } else { + "?".to_owned() + } + } + }; + } + host.access.with_ex_editor(|editor| { + editor + .prompt_dialog + .as_ref() + .map_or(String::new(), |dialog| { + dialog.prompt.to_string_lossy().into_owned() + }) + }) +} + +/// `setcmdpos({pos})`: defers the cursor move into the session's `new_cmdpos` +/// slot, consumed by the next `CTRL-R` paste — never applied eagerly, so +/// `getcmdpos()` still reports the old position afterwards (`ex_getln.c`). +/// Returns 1 on success, 0 when `{pos} < 1`. +fn call_setcmdpos( + host: &mut EvalHost<'_, F, E>, + args: &[Typval], +) -> ox_eval::Result { + let position = tv_get_number(&args[0])?; + if position < 1 { + return Ok(Typval::Number(0)); + } + // Outside an open command line there is no session slot; upstream keeps a + // static one for the next command line, this port resets per session, so + // there is nowhere to store it — still a success either way. + if let Some(machine) = host + .runtime + .mode_machine + .as_ref() + .and_then(|machine| machine.try_borrow_mut().ok()) + .as_deref_mut() + && let Mode::Cmdline(state) = machine.mode_mut() + { + state.new_cmdpos = Some(usize::try_from(position).unwrap_or(usize::MAX)); + } + Ok(Typval::Number(1)) +} + +/// Whether the insert-completion popup is visible (`pum_visible()`). The +/// command line has no wildmenu/popup in this port, so this is only true +/// while an insert-mode completion list is live — otherwise an honest 0, +// matching upstream outside completion. +fn completion_visible(host: &EvalHost<'_, F, E>) -> bool { + host.runtime + .mode_machine + .as_ref() + .and_then(|machine| machine.try_borrow().ok()) + .is_some_and(|machine| { + machine + .completion + .pum() + .is_some_and(|pum| !pum.items.is_empty()) + }) +} + +/// Snaps a byte index down to the containing character boundary. +fn floor_char_boundary(text: &str, byte: usize) -> usize { + let mut end = byte.min(text.len()); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + end +} + +/// Resolves a history `kind` argument (`f_histadd`, `f_histdel`, +/// `f_histget`, `f_histnr` via `get_histtype`, `cmdhist.c:270-289`). An +/// empty name selects the live command line (`return_default == false`): +/// the open `Mode::Cmdline` kind, `input()`'s `@` dialog, else the `NUL` +/// fallback, which `hist_char2type` maps to search. Unknown names are +/// `None` — never an error, the builtins answer miss values instead. +fn history_kind( + host: &EvalHost<'_, F, E>, + name: &str, +) -> Option { + if name.is_empty() { + return Some(match cmdline_type(host) { + ":" => HistoryKind::Cmd, + "@" => HistoryKind::Input, + _ => HistoryKind::Search, + }); + } + HistoryKind::from_name(name) +} + +/// Table capacity from the `'history'` option (`p_hi`, `init_history`), +/// falling back to upstream's 10000 default (`option.c`) when unset or +/// non-numeric — the same fallback `shada.rs` uses. A zero option stores +/// nothing, matching `hislen == 0`. +fn history_capacity(editor: &Editor) -> usize { + match editor.options().get_global("history") { + Ok(OptionValue::Number(value)) => usize::try_from((*value).max(0)).unwrap_or(usize::MAX), + _ => 10_000, + } +} + +/// `histadd({kind}, {item})` (`f_histadd`, `cmdhist.c:523-541`): 1 on +/// success, 0 for an unknown kind or an empty item. Container arguments +/// raise the port's usual `E73x` string-coercion errors, like every other +/// editor-stateful builtin. +fn call_histadd( + host: &mut EvalHost<'_, F, E>, + args: &[Typval], +) -> ox_eval::Result { + let kind_name = input_string_arg(&args[0])?; + let item = input_string_arg(&args[1])?; + let Some(kind) = history_kind(host, &kind_name.to_string_lossy()) else { + return Ok(Typval::Number(0)); + }; + if item.as_bytes().is_empty() { + return Ok(Typval::Number(0)); + } + let text = item.to_string_lossy().into_owned(); + host.access.with_ex_editor(|editor| { + let capacity = history_capacity(editor); + editor.history_mut().add(kind, &text, capacity); + }); + Ok(Typval::Number(1)) +} + +/// `histdel({kind}[, {item}])` (`f_histdel`, `cmdhist.c:544-553`): with one +/// argument clears the whole bank; a Number second argument deletes one +/// indexed entry (`calc_hist_idx`); anything else is a magic regex and +/// deletes every match (`del_history_entry`, always case-sensitive). +/// Reports 1 when something was deleted, else 0 — including unknown kinds, +/// misses, empty patterns, and patterns that fail to compile. +fn call_histdel( + host: &mut EvalHost<'_, F, E>, + args: &[Typval], +) -> ox_eval::Result { + let kind_name = input_string_arg(&args[0])?; + let Some(kind) = history_kind(host, &kind_name.to_string_lossy()) else { + return Ok(Typval::Number(0)); + }; + if args.len() == 1 { + if host.access.with_ex_editor(|editor| { + if history_capacity(editor) == 0 { + return false; + } + editor.history_mut().clear(kind); + true + }) { + return Ok(Typval::Number(1)); + } + return Ok(Typval::Number(0)); + } + if let Typval::Number(index) = args[1] { + let deleted = host + .access + .with_ex_editor(|editor| editor.history_mut().delete_index(kind, index)); + return Ok(Typval::Number(i64::from(deleted))); + } + let pattern = input_string_arg(&args[1])?.to_string_lossy().into_owned(); + if pattern.is_empty() { + return Ok(Typval::Number(0)); + } + let deleted = host.access.with_ex_editor(|editor| { + editor.history_mut().delete_pattern(kind, |text| { + history_pattern_matches(&pattern, text) + }) + }); + Ok(Typval::Number(i64::from(deleted))) +} + +/// One `del_history_entry` trial (`cmdhist.c:429+`): magic, case-sensitive, +/// unanchored (`vim_regcomp(str, RE_MAGIC + RE_STRING)` + `vim_regexec`). +/// A pattern that fails to compile matches nothing — upstream's `regprog == +/// NULL` early `false`. +fn history_pattern_matches(pattern: &str, text: &str) -> bool { + VimRegex + .is_match(&OxStr::from(text), &OxStr::from(pattern), false) + .unwrap_or(false) +} + +/// `histget({kind}[, {index}])` (`f_histget`, `cmdhist.c:556-576`): the +/// entry text, or `""` for any miss — unknown kind, empty bank, `0`, an +/// unknown absolute hisnum, or an out-of-range relative index. Never an +/// error on the value itself; a bad index *type* still raises through +/// `tv_get_number`, exactly like upstream's `tv_get_number_chk`. +fn call_histget( + host: &mut EvalHost<'_, F, E>, + args: &[Typval], +) -> ox_eval::Result { + let kind_name = input_string_arg(&args[0])?; + let Some(kind) = history_kind(host, &kind_name.to_string_lossy()) else { + return Ok(Typval::String(OxStr::from(""))); + }; + let index = if args.len() == 1 { + host.access + .with_ex_editor(|editor| editor.history().newest(kind)) + } else { + tv_get_number(&args[1])? + }; + let text = host + .access + .with_ex_editor(|editor| editor.history().get(kind, index).unwrap_or("").to_owned()); + Ok(Typval::String(OxStr::from(text.as_str()))) +} + +/// `histnr({kind})` (`f_histnr`, `cmdhist.c:579-591`): the newest entry's +/// hisnum, or -1 for an unknown kind or a bank with no numbered entry. +fn call_histnr( + host: &mut EvalHost<'_, F, E>, + args: &[Typval], +) -> ox_eval::Result { + let kind_name = input_string_arg(&args[0])?; + let Some(kind) = history_kind(host, &kind_name.to_string_lossy()) else { + return Ok(Typval::Number(-1)); + }; + let number = host + .access + .with_ex_editor(|editor| editor.history().newest(kind)); + Ok(Typval::Number(number)) +} + +#[cfg(test)] +mod tests { + use super::{floor_char_boundary, number_prefix, tv_get_number}; + use ox_types::Typval; + + #[test] + fn number_coercion_matches_tv_get_number() { + assert_eq!(tv_get_number(&Typval::Number(3)).unwrap(), 3); + // Numeric strings parse their leading prefix, the rest is 0. + assert_eq!(number_prefix(" -12ab"), -12); + assert_eq!(number_prefix("x"), 0); + assert_eq!(number_prefix(""), 0); + assert!(tv_get_number(&Typval::dict(vec![])).is_err()); + let error = tv_get_number(&Typval::dict(vec![])).unwrap_err(); + assert_eq!(error.code, "E728"); + } + + #[test] + fn boundary_flooring_counts_characters() { + let text = "éab"; + assert_eq!(floor_char_boundary(text, usize::MAX), text.len()); + assert_eq!(floor_char_boundary(text, 1), 0); + assert_eq!(text[..floor_char_boundary(text, 2)].chars().count(), 1); + assert_eq!(text[..floor_char_boundary(text, 3)].chars().count(), 2); + } +} + +#[cfg(test)] +mod history_tests { + use ox_eval::ScopeKind; + use ox_types::Typval; + + use crate::excmd_exec::ExEditorAccess as _; + use crate::{Editor, ExExecutor, ExecError, TestEditorAccess, VimExceptionKind}; + + fn error_code(err: &ExecError) -> String { + match err { + ExecError::Vim(exception) => match &exception.kind { + VimExceptionKind::Error(code) => code.clone(), + VimExceptionKind::Throw => "Throw".to_owned(), + }, + other => panic!("expected Vim error, got {other:?}"), + } + } + + fn fixture() -> (TestEditorAccess, ExExecutor) { + (TestEditorAccess::new(Editor::new()), ExExecutor::new()) + } + + fn run(editor: &TestEditorAccess, exec: &mut ExExecutor, script: &str) { + exec.execute_script(editor, "", script).unwrap(); + } + + fn run_err( + editor: &TestEditorAccess, + exec: &mut ExExecutor, + script: &str, + ) -> String { + error_code(&exec.execute_script(editor, "", script).unwrap_err()) + } + + fn global(exec: &ExExecutor, name: &str) -> Option { + exec.scope() + .get_scoped(ScopeKind::Global, name.as_bytes(), 0) + .ok() + .cloned() + } + + fn global_number(exec: &ExExecutor, name: &str) -> Option { + match global(exec, name)? { + Typval::Number(value) => Some(value), + _ => None, + } + } + + fn global_string(exec: &ExExecutor, name: &str) -> Option { + match global(exec, name)? { + Typval::String(value) => Some(value.to_string_lossy().into_owned()), + _ => None, + } + } + + /// `f_histadd` + `f_histget` + `f_histnr` (`cmdhist.c:523-591`): adds + /// number from 1, a re-add moves to the newest slot with a fresh hisnum + /// (the old number is a miss), omitted index is the newest, and `0` is + /// always a miss. + #[test] + fn add_dedup_moves_to_newest_with_fresh_hisnum() { + let (editor, mut exec) = fixture(); + run( + &editor, + &mut exec, + "let g:a1 = histadd('cmd', 'echo one') \ + | let g:a2 = histadd('cmd', 'echo two') \ + | let g:a3 = histadd('cmd', 'echo one')", + ); + assert_eq!(global_number(&exec, "a1"), Some(1)); + assert_eq!(global_number(&exec, "a2"), Some(1)); + assert_eq!(global_number(&exec, "a3"), Some(1)); + run( + &editor, + &mut exec, + "let g:newest = histget('cmd') \ + | let g:prev = histget('cmd', -2) \ + | let g:moved = histget('cmd', 1) \ + | let g:second = histget('cmd', 2) \ + | let g:third = histget('cmd', 3) \ + | let g:zero = histget('cmd', 0) \ + | let g:nr = histnr('cmd')", + ); + assert_eq!(global_string(&exec, "newest"), Some("echo one".to_owned())); + assert_eq!(global_string(&exec, "prev"), Some("echo two".to_owned())); + assert_eq!(global_string(&exec, "moved"), Some(String::new())); + assert_eq!(global_string(&exec, "second"), Some("echo two".to_owned())); + assert_eq!(global_string(&exec, "third"), Some("echo one".to_owned())); + assert_eq!(global_string(&exec, "zero"), Some(String::new())); + assert_eq!(global_number(&exec, "nr"), Some(3)); + } + + /// All five banks, prefix/case/single-char kinds, and unknown-kind + /// misses: `""` for `histget`, `-1` for `histnr`, `0` for `histadd`. + #[test] + fn five_tables_share_kind_resolution() { + let (editor, mut exec) = fixture(); + run( + &editor, + &mut exec, + "let g:kinds = [histadd(':', 'c1'), histadd('/', 's1'), \ + histadd('=', 'e1'), histadd('@', 'i1'), histadd('>', 'd1')]", + ); + run( + &editor, + &mut exec, + "let g:row = histget('c') . '|' . histget('s') . '|' . histget('e') \ + . '|' . histget('i') . '|' . histget('d') \ + | let g:nrs = histnr('CMD') . '|' . histnr('/') . '|' . histnr('?') \ + | let g:bad_get = histget('bogus') \ + | let g:bad_nr = histnr('bogus') \ + | let g:bad_add = histadd('bogus', 'x') \ + | let g:empty_item = histadd('c', '')", + ); + assert_eq!(global_string(&exec, "row"), Some("c1|s1|e1|i1|d1".to_owned())); + assert_eq!(global_string(&exec, "nrs"), Some("1|1|1".to_owned())); + assert_eq!(global_string(&exec, "bad_get"), Some(String::new())); + assert_eq!(global_number(&exec, "bad_nr"), Some(-1)); + assert_eq!(global_number(&exec, "bad_add"), Some(0)); + assert_eq!(global_number(&exec, "empty_item"), Some(0)); + } + + /// `histdel` (`f_histdel`, `cmdhist.c:544-553`): index leg deletes one + /// entry by absolute (`2`) or relative (`-1`) index and reports misses + /// (`99`, `0`) as 0; the pattern leg deletes every regex match. + #[test] + fn delete_index_and_pattern_legs() { + let (editor, mut exec) = fixture(); + run( + &editor, + &mut exec, + "call histdel('c') \ + | call histadd('c', 'a') | call histadd('c', 'b') | call histadd('c', 'c') \ + | let g:d1 = histdel('c', 2) \ + | let g:row = histget('c', -1) . '|' . histget('c', -2) . '|' . histnr('c') \ + | let g:miss = histdel('c', 99) \ + | let g:zero = histdel('c', 0) \ + | let g:nomatch = histdel('c', 'zzz') \ + | let g:pat = histdel('c', 'a') \ + | let g:left = histget('c')", + ); + assert_eq!(global_number(&exec, "d1"), Some(1)); + assert_eq!(global_string(&exec, "row"), Some("c|a|3".to_owned())); + assert_eq!(global_number(&exec, "miss"), Some(0)); + assert_eq!(global_number(&exec, "zero"), Some(0)); + assert_eq!(global_number(&exec, "nomatch"), Some(0)); + assert_eq!(global_number(&exec, "pat"), Some(1)); + assert_eq!(global_string(&exec, "left"), Some("c".to_owned())); + } + + /// The clear leg resets the bank and its counter: the next add restarts + /// at hisnum 1 and `histnr` reads -1 while empty. + #[test] + fn delete_clear_resets_bank_and_counter() { + let (editor, mut exec) = fixture(); + run( + &editor, + &mut exec, + "call histadd('c', 'x') \ + | let g:cleared = histdel('c') \ + | let g:nr = histnr('c') \ + | let g:get = histget('c') \ + | let g:re = histadd('c', 'fresh') \ + | let g:nr2 = histnr('c')", + ); + assert_eq!(global_number(&exec, "cleared"), Some(1)); + assert_eq!(global_number(&exec, "nr"), Some(-1)); + assert_eq!(global_string(&exec, "get"), Some(String::new())); + assert_eq!(global_number(&exec, "re"), Some(1)); + assert_eq!(global_number(&exec, "nr2"), Some(1)); + } + + /// `del_history_idx` on the final entry leaves `histnr()` at 0 (not -1) + /// while `histget()` still misses; the counter is not reset. + #[test] + fn delete_final_entry_by_index_reports_zero_nr() { + let (editor, mut exec) = fixture(); + run( + &editor, + &mut exec, + "call histdel('c') \ + | let g:added = histadd('c', 'solo') \ + | let g:del = histdel('c', 1) \ + | let g:nr = histnr('c') \ + | let g:get = histget('c') \ + | let g:re = histadd('c', 'again') \ + | let g:nr2 = histnr('c')", + ); + assert_eq!(global_number(&exec, "added"), Some(1)); + assert_eq!(global_number(&exec, "del"), Some(1)); + assert_eq!(global_number(&exec, "nr"), Some(0)); + assert_eq!(global_string(&exec, "get"), Some(String::new())); + assert_eq!(global_number(&exec, "re"), Some(1)); + assert_eq!(global_number(&exec, "nr2"), Some(2)); + } + + /// Arity follows `eval.lua` (`E119`/`E118`), container values raise the + /// port's coercion errors, and an empty pattern or a pattern that fails + /// to compile deletes nothing. + #[test] + fn arity_and_type_errors_match_port_conventions() { + let (editor, mut exec) = fixture(); + assert_eq!(run_err(&editor, &mut exec, "call histadd('c')"), "E119"); + assert_eq!(run_err(&editor, &mut exec, "call histnr()"), "E119"); + assert_eq!(run_err(&editor, &mut exec, "call histget('c', 1, 2)"), "E118"); + assert_eq!(run_err(&editor, &mut exec, "call histadd([], 'x')"), "E730"); + assert_eq!(run_err(&editor, &mut exec, "echo histget('c', [])"), "E730"); + run( + &editor, + &mut exec, + "call histdel('c') | call histadd('c', 'kept')", + ); + run( + &editor, + &mut exec, + "let g:empty_pat = histdel('c', '') \ + | let g:bad_pat = histdel('c', '[z-a]') \ + | let g:kept = histget('c')", + ); + assert_eq!(global_number(&exec, "empty_pat"), Some(0)); + assert_eq!(global_number(&exec, "bad_pat"), Some(0)); + assert_eq!(global_string(&exec, "kept"), Some("kept".to_owned())); + } + + /// The store lives on [`Editor`], not the mode machine: direct access + /// serves the builtins without touching `ModeMachine`. + #[test] + fn store_lives_on_editor_beside_registers() { + let (editor, mut exec) = fixture(); + run(&editor, &mut exec, "call histadd('=', '2+2')"); + let (entry, newest) = editor + .with_ex_editor(|state| (state.history().get(crate::history::HistoryKind::Expr, -1).map(str::to_owned), state.history().newest(crate::history::HistoryKind::Expr))); + assert_eq!(entry.as_deref(), Some("2+2")); + assert_eq!(newest, 1); + } +} diff --git a/crates/ox-editor/src/builtins/completion.rs b/crates/ox-editor/src/builtins/completion.rs index ea0f8d1c..d5e1c740 100644 --- a/crates/ox-editor/src/builtins/completion.rs +++ b/crates/ox-editor/src/builtins/completion.rs @@ -8,10 +8,64 @@ //! `getcompletion({pat}, {type} [, {filtered}])` returns a List of completion //! candidates for the given pattern and type. -use ox_eval::{EvalError, builtin_spec}; +use ox_eval::{EvalError, Scope, builtin_spec}; use ox_types::{OxStr, Typval}; use crate::Editor; +use crate::excmd_exec::{EvalHost, ExEditorAccess}; +use crate::script::FileIO; + +pub(crate) fn call_host( + host: &mut EvalHost<'_, F, E>, + name: &str, + args: &[Typval], + _scope: &mut Scope, +) -> ox_eval::Result { + if name == "complete_check" { + return call_complete_check(host, args); + } + host.access.with_ex_editor(|editor| call(editor, name, args)) +} + +/// `complete_check()` (`insexpand.c:3620-3629`): 1 when a key was typed while +/// a completion lookup runs, 0 otherwise. Upstream polls the typeahead buffer +/// (`ins_compl_check_keys`) and answers `ins_compl_interrupted()`; without an +/// active session there is nothing to interrupt. Only a `complete-functions` +/// lookup should call this, like upstream. +fn call_complete_check( + host: &mut EvalHost<'_, F, E>, + args: &[Typval], +) -> ox_eval::Result { + let Some(spec) = builtin_spec("complete_check") else { + unreachable!("completion builtin route and dispatcher disagree"); + }; + if args.len() < spec.min_args { + return Err(EvalError::new( + "E119", + 0, + "Not enough arguments for function: complete_check", + )); + } + if spec.max_args.is_some_and(|maximum| args.len() > maximum) { + return Err(EvalError::new( + "E118", + 0, + "Too many arguments for function: complete_check", + )); + } + let active = host + .runtime + .mode_machine + .as_ref() + .is_some_and(|machine| machine.borrow().completion.is_active()); + if !active { + return Ok(Typval::Number(0)); + } + let interrupted = host + .access + .with_ex_editor(|editor| !editor.typeahead().is_empty()); + Ok(Typval::Number(i64::from(interrupted))) +} /// Routes one completion builtin. use crate::options::OptionValue; @@ -96,12 +150,44 @@ fn call_getcompletion(editor: &Editor, args: &[Typval]) -> ox_eval::Result complete_highlights(editor, &pat), "messages" => complete_messages(&pat), "filetypecmd" => complete_filetypecmd(&pat), + "help" => complete_help(editor, &pat), _ => Vec::new(), }; Ok(Typval::list( matches.into_iter().map(Typval::String).collect(), )) } +fn complete_help(editor: &Editor, pat: &str) -> Vec { + let Ok(crate::options::OptionValue::String(rtp)) = + editor.options().get_global("runtimepath") + else { + return Vec::new(); + }; + let mut tags = Vec::new(); + for dir in crate::fs_builtins::split_path_list(&rtp) { + let tags_path = std::path::Path::new(&dir).join("doc").join("tags"); + collect_tags_from_file(&tags_path, &mut tags); + } + let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect(); + let mut results = prefix_filter(&tag_refs, pat); + results.sort(); + results.dedup(); + results +} + +fn collect_tags_from_file(tags_path: &std::path::Path, tags: &mut Vec) { + let Ok(content) = std::fs::read_to_string(tags_path) else { + return; + }; + for line in content.lines() { + let Some(tag) = line.split('\t').next() else { + continue; + }; + if !tag.is_empty() && !tag.starts_with('!') { + tags.push(tag.to_owned()); + } + } +} fn prefix_filter(candidates: &[&str], pat: &str) -> Vec { let pattern = if pat.contains(['*', '?', '[']) { @@ -182,6 +268,7 @@ fn complete_commands(pat: &str) -> Vec { "print", "iabbrev", "abclear", + "iput", "put", "quit", "read", @@ -2433,6 +2520,34 @@ mod tests { use super::*; use ox_text::Buffer; + /// `complete_check()` (`insexpand.c:3620-3629`): without an active + /// completion session there is nothing to interrupt, so the answer is 0 + /// — the oracle ground truth for `echo complete_check()`. + #[test] + fn complete_check_idle_answers_zero() { + let editor = crate::TestEditorAccess::new(crate::Editor::new()); + let mut exec = crate::ExExecutor::new(); + exec.execute_script(&editor, "complete_check.vim", "let g:idle = complete_check()") + .unwrap(); + match exec + .scope() + .get_scoped(ox_eval::ScopeKind::Global, b"idle", 0) + { + Ok(Typval::Number(value)) => assert_eq!(*value, 0), + other => panic!("expected Number(0), got {other:?}"), + } + let error = exec + .execute_line(&editor, "call complete_check(1)") + .unwrap_err(); + match error { + crate::excmd_exec::ExecError::Vim(exception) => match &exception.kind { + crate::VimExceptionKind::Error(code) => assert_eq!(code, "E118"), + crate::VimExceptionKind::Throw => panic!("expected E118, got Throw"), + }, + other => panic!("expected Vim error, got {other:?}"), + } + } + #[test] fn command_completion_accepts_wildcard_patterns() { let commands = complete_commands("*add"); @@ -2509,9 +2624,7 @@ fn line_bytes(editor: &Editor, buffer: BufHandle, lnum: usize) -> Result #[cfg(test)] mod completion_engine_tests { - use super::{ - CTRL_D, CTRL_E, CTRL_N, CTRL_P, CTRL_X, CompletionOutcome, CompletionSession, - }; + use super::{CTRL_D, CTRL_E, CTRL_N, CTRL_P, CTRL_X, CompletionOutcome, CompletionSession}; use crate::layout::Geometry; use ox_text::{Buffer, Position}; diff --git a/crates/ox-editor/src/builtins/datetime.rs b/crates/ox-editor/src/builtins/datetime.rs new file mode 100644 index 00000000..deba8ca6 --- /dev/null +++ b/crates/ox-editor/src/builtins/datetime.rs @@ -0,0 +1,921 @@ +//! Date and time formatting builtins (`strftime`). + +use std::fmt::Write as _; +use std::path::PathBuf; + +use ox_eval::EvalError; +use ox_types::{OxStr, Typval}; + +const ABBR_WDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const FULL_WDAYS: [&str; 7] = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; +const ABBR_MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; +const FULL_MONTHS: [&str; 12] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +const MIN_REPRESENTABLE_TIMESTAMP: i64 = -67_768_040_609_771_272; +const MAX_REPRESENTABLE_TIMESTAMP: i64 = 67_768_036_191_644_399; +const MAX_RESULT_LEN: usize = 255; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct TimeZoneInfo { + offset: i64, + name: String, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum Pad { + Default, + None, + Space, + Zero, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct DateTimeParts { + year: i64, + month: u32, + month_day: u32, + hour: u32, + minute: u32, + second: u32, + wday: u32, + yday: u32, + u_week: u32, + w_week: u32, + iso_year: i64, + iso_week: u32, +} + +impl DateTimeParts { + fn from_local_seconds(local_sec: i64) -> Option { + let day = local_sec.div_euclid(86_400); + let sec_of_day = local_sec.rem_euclid(86_400); + let (year, month, month_day) = civil_from_days(day); + + let year_min = i64::from(i32::MIN) + 1900; + let year_max = i64::from(i32::MAX) + 1900; + if year < year_min || year > year_max { + return None; + } + + let hour = u32::try_from(sec_of_day / 3600).ok()?; + let minute = u32::try_from((sec_of_day % 3600) / 60).ok()?; + let second = u32::try_from(sec_of_day % 60).ok()?; + let wday = u32::try_from((day + 4).rem_euclid(7)).ok()?; + let jan1 = days_from_civil(year, 1, 1); + let yday = u32::try_from(day - jan1 + 1).ok()?; + + let sunday_offset = i64::from(yday) - 1 - i64::from(wday); + let u_week = u32::try_from(if sunday_offset < 0 { 0 } else { (sunday_offset / 7) + 1 }).ok()?; + + let mon_wday = (wday + 6) % 7; + let monday_offset = i64::from(yday) - 1 - i64::from(mon_wday); + let w_week = u32::try_from(if monday_offset < 0 { 0 } else { (monday_offset / 7) + 1 }).ok()?; + + let thu_day = day + 3 - i64::from(mon_wday); + let (iso_year, _, _) = civil_from_days(thu_day); + let iso_jan1 = days_from_civil(iso_year, 1, 1); + let iso_week = u32::try_from((thu_day - iso_jan1) / 7 + 1).ok()?; + + Some(Self { + year, + month, + month_day, + hour, + minute, + second, + wday, + yday, + u_week, + w_week, + iso_year, + iso_week, + }) + } +} + +/// Formats date and time according to format string and optional timestamp. +pub(crate) fn call_strftime_builtin(args: &[Typval]) -> ox_eval::Result { + if args.is_empty() { + return Err(EvalError::new( + "E119", + 0, + "Not enough arguments for function: strftime", + )); + } + if args.len() > 2 { + return Err(EvalError::new( + "E118", + 0, + "Too many arguments for function: strftime", + )); + } + + let format_str = super::input_string_arg(&args[0])?; + let format = format_str.to_string_lossy(); + let timestamp = extract_timestamp(args.get(1))?; + + if !(MIN_REPRESENTABLE_TIMESTAMP..=MAX_REPRESENTABLE_TIMESTAMP).contains(×tamp) { + return Ok(Typval::String(OxStr::from("(Invalid)"))); + } + + let tz = resolve_timezone(timestamp); + let Some(local_sec) = timestamp.checked_add(tz.offset) else { + return Ok(Typval::String(OxStr::from("(Invalid)"))); + }; + + let Some(parts) = DateTimeParts::from_local_seconds(local_sec) else { + return Ok(Typval::String(OxStr::from("(Invalid)"))); + }; + + let formatted = format_strftime(&format, timestamp, &parts, &tz); + + Ok(Typval::String(OxStr::from(formatted.as_str()))) +} + +fn extract_timestamp(time_arg: Option<&Typval>) -> ox_eval::Result { + if let Some(arg) = time_arg { + super::position::number_value(arg) + } else { + Ok(std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) + })) + } +} + +fn resolve_timezone(timestamp: i64) -> TimeZoneInfo { + if let Ok(tz_env) = std::env::var("TZ") { + if !tz_env.is_empty() { + if let Some(info) = resolve_tz_string(&tz_env, timestamp) { + return info; + } + } + } + + if let Ok(data) = std::fs::read("/etc/localtime") { + if let Some(info) = parse_tzif(&data, timestamp) { + return info; + } + } + + TimeZoneInfo { + offset: 0, + name: "UTC".to_string(), + } +} + +fn resolve_tz_string(tz: &str, timestamp: i64) -> Option { + if tz == "UTC" || tz == "GMT" || tz == "UTC0" || tz == "GMT0" { + return Some(TimeZoneInfo { + offset: 0, + name: tz.to_string(), + }); + } + + let path = if let Some(stripped) = tz.strip_prefix(':') { + if stripped.starts_with('/') { + PathBuf::from(stripped) + } else { + PathBuf::from("/usr/share/zoneinfo").join(stripped) + } + } else { + PathBuf::from("/usr/share/zoneinfo").join(tz) + }; + + if let Ok(data) = std::fs::read(&path) { + if let Some(info) = parse_tzif(&data, timestamp) { + return Some(info); + } + } + + parse_posix_tz(tz, timestamp) +} + +fn parse_posix_tz(tz: &str, timestamp: i64) -> Option { + let bytes = tz.as_bytes(); + let mut cursor = 0; + + let std_name = parse_tz_name(bytes, &mut cursor)?; + let std_offset = parse_tz_offset(bytes, &mut cursor); + + if cursor >= bytes.len() || bytes.get(cursor) == Some(&b',') { + return Some(TimeZoneInfo { + offset: std_offset, + name: std_name.to_string(), + }); + } + + let dst_name = match parse_tz_name(bytes, &mut cursor) { + Some(name) => name, + None => { + return Some(TimeZoneInfo { + offset: std_offset, + name: std_name.to_string(), + }); + } + }; + + let dst_offset = if cursor < bytes.len() + && matches!(bytes.get(cursor), Some(b'+' | b'-' | b'0'..=b'9')) + { + parse_tz_offset(bytes, &mut cursor) + } else { + std_offset + 3600 + }; + + if let Ok(data) = std::fs::read("/usr/share/zoneinfo/posixrules") { + if let Some(info) = parse_tzif(&data, timestamp) { + let name = if info.offset == dst_offset { + dst_name.to_string() + } else { + std_name.to_string() + }; + return Some(TimeZoneInfo { + offset: info.offset, + name, + }); + } + } + + Some(TimeZoneInfo { + offset: std_offset, + name: std_name.to_string(), + }) +} + +fn parse_tz_name<'a>(bytes: &'a [u8], cursor: &mut usize) -> Option<&'a str> { + if bytes.get(*cursor) == Some(&b'<') { + let start = *cursor + 1; + let end = bytes.get(start..)?.iter().position(|&b| b == b'>')? + start; + *cursor = end + 1; + std::str::from_utf8(bytes.get(start..end)?).ok() + } else { + let start = *cursor; + while *cursor < bytes.len() && bytes.get(*cursor).is_some_and(u8::is_ascii_alphabetic) { + *cursor += 1; + } + (*cursor > start).then(|| std::str::from_utf8(bytes.get(start..*cursor)?).ok())? + } +} + +fn parse_tz_offset(bytes: &[u8], cursor: &mut usize) -> i64 { + if *cursor >= bytes.len() { + return 0; + } + let sign: i64 = if bytes.get(*cursor) == Some(&b'-') { + *cursor += 1; + 1 + } else { + if bytes.get(*cursor) == Some(&b'+') { + *cursor += 1; + } + -1 + }; + + let hours = parse_digits(bytes, cursor); + let mins = parse_colon_digits(bytes, cursor); + let secs = parse_colon_digits(bytes, cursor); + + sign * (hours * 3600 + mins * 60 + secs) +} + +fn parse_digits(bytes: &[u8], cursor: &mut usize) -> i64 { + let mut value = 0_i64; + while *cursor < bytes.len() && bytes.get(*cursor).is_some_and(u8::is_ascii_digit) { + if let Some(b) = bytes.get(*cursor) { + value = value * 10 + i64::from(b - b'0'); + } + *cursor += 1; + } + value +} + +fn parse_colon_digits(bytes: &[u8], cursor: &mut usize) -> i64 { + if bytes.get(*cursor) == Some(&b':') { + *cursor += 1; + parse_digits(bytes, cursor) + } else { + 0 + } +} + +fn parse_tzif(data: &[u8], timestamp: i64) -> Option { + if data.len() < 44 || data.get(0..4) != Some(b"TZif") { + return None; + } + + if let Some(pos) = data.get(4..)?.windows(4).position(|w| w == b"TZif") { + let v2_start = 4 + pos; + if data.len().saturating_sub(v2_start) >= 44 { + if let Some(v2_block) = data.get(v2_start..) { + return parse_tzif_v2(v2_block, timestamp); + } + } + } + + parse_tzif_v1(data, timestamp) +} + +fn parse_tzif_v1(data: &[u8], timestamp: i64) -> Option { + parse_tzif_data(data, timestamp, 4, |slice| { + Some(i64::from(i32::from_be_bytes(slice.try_into().ok()?))) + }) +} + +fn parse_tzif_v2(data: &[u8], timestamp: i64) -> Option { + parse_tzif_data(data, timestamp, 8, |slice| { + Some(i64::from_be_bytes(slice.try_into().ok()?)) + }) +} + +fn parse_tzif_data(data: &[u8], timestamp: i64, time_elem_size: usize, read_time: F) -> Option +where + F: Fn(&[u8]) -> Option, +{ + if data.len() < 44 { + return None; + } + let timecnt = usize::try_from(u32::from_be_bytes(data.get(32..36)?.try_into().ok()?)).ok()?; + let typecnt = usize::try_from(u32::from_be_bytes(data.get(36..40)?.try_into().ok()?)).ok()?; + let charcnt = usize::try_from(u32::from_be_bytes(data.get(40..44)?.try_into().ok()?)).ok()?; + + let times_len = timecnt.checked_mul(time_elem_size)?; + let types_len = timecnt; + let ttinfo_len = typecnt.checked_mul(6)?; + + let offset = 44_usize; + let times_end = offset.checked_add(times_len)?; + let types_end = times_end.checked_add(types_len)?; + let ttinfo_end = types_end.checked_add(ttinfo_len)?; + let char_end = ttinfo_end.checked_add(charcnt)?; + + if data.len() < char_end { + return None; + } + + let times_slice = data.get(offset..times_end)?; + let types_slice = data.get(times_end..types_end)?; + let ttinfo_slice = data.get(types_end..ttinfo_end)?; + let abbrs_slice = data.get(ttinfo_end..char_end)?; + + let mut ttinfos = Vec::with_capacity(typecnt); + for chunk in ttinfo_slice.chunks_exact(6) { + let utoff = i32::from_be_bytes(chunk.get(0..4)?.try_into().ok()?); + let is_dst = chunk.get(4).copied()? != 0; + let desigidx = usize::from(chunk.get(5).copied()?); + ttinfos.push((utoff, is_dst, desigidx)); + } + if ttinfos.is_empty() { + return None; + } + + let selected_type = select_transition_type(timecnt, times_slice, types_slice, &ttinfos, time_elem_size, timestamp, &read_time); + let (utoff, _, desigidx) = ttinfos.get(selected_type).copied().unwrap_or(ttinfos[0]); + + let abbr = if desigidx < abbrs_slice.len() { + let end = abbrs_slice[desigidx..] + .iter() + .position(|&b| b == 0) + .map_or(abbrs_slice.len(), |pos| desigidx + pos); + String::from_utf8_lossy(&abbrs_slice[desigidx..end]).into_owned() + } else { + "UTC".to_string() + }; + + Some(TimeZoneInfo { + offset: i64::from(utoff), + name: abbr, + }) +} + +fn select_transition_type( + timecnt: usize, + times_slice: &[u8], + types_slice: &[u8], + ttinfos: &[(i32, bool, usize)], + time_elem_size: usize, + timestamp: i64, + read_time: &F, +) -> usize +where + F: Fn(&[u8]) -> Option, +{ + if timecnt == 0 { + return 0; + } + let Some(first_time) = times_slice.get(0..time_elem_size).and_then(read_time) else { + return 0; + }; + + if timestamp < first_time { + return ttinfos.iter().position(|t| !t.1).unwrap_or(0); + } + + let mut low = 0; + let mut high = timecnt; + while low < high { + let mid = (low + high) / 2; + let start = mid * time_elem_size; + let mid_time = times_slice.get(start..start + time_elem_size).and_then(read_time).unwrap_or(0); + if mid_time <= timestamp { + low = mid + 1; + } else { + high = mid; + } + } + let idx = low.saturating_sub(1); + usize::from(types_slice.get(idx).copied().unwrap_or(0)) +} + +fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + let y = y - i64::from(m <= 2); + let era = y.div_euclid(400); + let yoe = y.rem_euclid(400) as u64; + let mp = u64::from(if m > 2 { m - 3 } else { m + 9 }); + let doy = (153 * mp + 2) / 5 + u64::from(d) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let civil = i64::try_from(doe).unwrap_or(0); + era * 146097 + civil - 719468 +} + +fn civil_from_days(day: i64) -> (i64, u32, u32) { + let z = day + 719468; + let era = z.div_euclid(146097); + let doe = z.rem_euclid(146097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = (i64::try_from(yoe).unwrap_or(0)) + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = u32::try_from(doy - (153 * mp + 2) / 5 + 1).unwrap_or(1); + let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1); + let year = y + i64::from(m <= 2); + (year, m, d) +} + +fn format_strftime( + format: &str, + timestamp: i64, + parts: &DateTimeParts, + tz: &TimeZoneInfo, +) -> String { + let mut out = String::with_capacity(format.len() * 2); + let bytes = format.as_bytes(); + let mut i = 0; + + while i < bytes.len() { + if bytes.get(i) == Some(&b'%') { + i += 1; + if i >= bytes.len() { + out.push('%'); + break; + } + + let pad = match bytes.get(i) { + Some(b'-') => { + i += 1; + Pad::None + } + Some(b'_') => { + i += 1; + Pad::Space + } + Some(b'0') => { + i += 1; + Pad::Zero + } + Some(b'E' | b'O') => { + i += 1; + Pad::Default + } + _ => Pad::Default, + }; + + if i >= bytes.len() { + out.push('%'); + break; + } + + let spec = bytes.get(i).copied().unwrap_or(b'%'); + i += 1; + + if !format_directive(&mut out, spec, pad, parts, timestamp, tz) { + out.push('%'); + match pad { + Pad::None => out.push('-'), + Pad::Space => out.push('_'), + Pad::Zero => out.push('0'), + Pad::Default => {} + } + out.push(char::from(spec)); + } + } else { + let next_pct = bytes[i..] + .iter() + .position(|&b| b == b'%') + .map_or(bytes.len(), |pos| i + pos); + if let Some(slice) = bytes.get(i..next_pct) { + out.push_str(&String::from_utf8_lossy(slice)); + } + i = next_pct; + } + } + + if out.len() > MAX_RESULT_LEN { + return String::new(); + } + out +} + +fn format_directive( + out: &mut String, + spec: u8, + pad: Pad, + parts: &DateTimeParts, + timestamp: i64, + tz: &TimeZoneInfo, +) -> bool { + format_date_directive(out, spec, pad, parts) + || format_time_directive(out, spec, pad, parts) + || format_week_directive(out, spec, pad, parts) + || format_zone_directive(out, spec, parts, timestamp, tz) +} + +fn format_date_directive(out: &mut String, spec: u8, pad: Pad, parts: &DateTimeParts) -> bool { + match spec { + b'%' => out.push('%'), + b'n' => out.push('\n'), + b't' => out.push('\t'), + b'Y' => format_num(out, parts.year, 4, pad), + b'y' => format_num(out, parts.year.abs() % 100, 2, pad), + b'C' => format_num(out, parts.year / 100, 2, pad), + b'm' => format_num(out, parts.month, 2, pad), + b'B' => out.push_str(FULL_MONTHS.get(parts.month.saturating_sub(1) as usize).copied().unwrap_or("")), + b'b' | b'h' => out.push_str(ABBR_MONTHS.get(parts.month.saturating_sub(1) as usize).copied().unwrap_or("")), + b'd' => format_num(out, parts.month_day, 2, pad), + b'e' => format_num( + out, + parts.month_day, + 2, + if pad == Pad::Default { Pad::Space } else { pad }, + ), + b'F' => { + let _ = write!(out, "{:04}-{:02}-{:02}", parts.year, parts.month, parts.month_day); + } + b'D' => { + let _ = write!(out, "{:02}/{:02}/{:02}", parts.month, parts.month_day, parts.year.abs() % 100); + } + _ => return false, + } + true +} + +fn format_time_directive(out: &mut String, spec: u8, pad: Pad, parts: &DateTimeParts) -> bool { + let hour12 = match parts.hour % 12 { + 0 => 12, + h => h, + }; + let is_pm = parts.hour >= 12; + + match spec { + b'H' => format_num(out, parts.hour, 2, pad), + b'k' => format_num( + out, + parts.hour, + 2, + if pad == Pad::Default { Pad::Space } else { pad }, + ), + b'I' => format_num(out, hour12, 2, pad), + b'l' => format_num( + out, + hour12, + 2, + if pad == Pad::Default { Pad::Space } else { pad }, + ), + b'M' => format_num(out, parts.minute, 2, pad), + b'S' => format_num(out, parts.second, 2, pad), + b'p' => out.push_str(if is_pm { "PM" } else { "AM" }), + b'P' => out.push_str(if is_pm { "pm" } else { "am" }), + b'r' => { + let _ = write!( + out, + "{hour12:02}:{:02}:{:02} {}", + parts.minute, + parts.second, + if is_pm { "PM" } else { "AM" } + ); + } + b'R' => { + let _ = write!(out, "{:02}:{:02}", parts.hour, parts.minute); + } + b'T' => { + let _ = write!(out, "{:02}:{:02}:{:02}", parts.hour, parts.minute, parts.second); + } + _ => return false, + } + true +} + +fn format_week_directive(out: &mut String, spec: u8, pad: Pad, parts: &DateTimeParts) -> bool { + let u_wday = if parts.wday == 0 { 7 } else { parts.wday }; + match spec { + b'a' => out.push_str(ABBR_WDAYS.get(parts.wday as usize).copied().unwrap_or("")), + b'A' => out.push_str(FULL_WDAYS.get(parts.wday as usize).copied().unwrap_or("")), + b'u' => { + let _ = write!(out, "{u_wday}"); + } + b'w' => { + let _ = write!(out, "{}", parts.wday); + } + b'j' => format_num(out, parts.yday, 3, pad), + b'U' => format_num(out, parts.u_week, 2, pad), + b'W' => format_num(out, parts.w_week, 2, pad), + b'V' => format_num(out, parts.iso_week, 2, pad), + b'G' => { + let _ = write!(out, "{:04}", parts.iso_year); + } + b'g' => { + let _ = write!(out, "{:02}", parts.iso_year.abs() % 100); + } + _ => return false, + } + true +} + +fn format_zone_directive( + out: &mut String, + spec: u8, + parts: &DateTimeParts, + timestamp: i64, + tz: &TimeZoneInfo, +) -> bool { + let hour12 = match parts.hour % 12 { + 0 => 12, + h => h, + }; + let is_pm = parts.hour >= 12; + + match spec { + b's' => { + let _ = write!(out, "{timestamp}"); + } + b'z' => { + let off_sign = if tz.offset >= 0 { '+' } else { '-' }; + let off_abs = tz.offset.abs(); + let off_h = off_abs / 3600; + let off_m = (off_abs % 3600) / 60; + let _ = write!(out, "{off_sign}{off_h:02}{off_m:02}"); + } + b'Z' => out.push_str(&tz.name), + b'c' => { + let _ = write!( + out, + "{} {:02} {} {:04} {:02}:{:02}:{:02} {} {}", + ABBR_WDAYS.get(parts.wday as usize).copied().unwrap_or(""), + parts.month_day, + ABBR_MONTHS.get(parts.month.saturating_sub(1) as usize).copied().unwrap_or(""), + parts.year, + hour12, + parts.minute, + parts.second, + if is_pm { "PM" } else { "AM" }, + tz.name + ); + } + b'x' => { + let _ = write!(out, "{:02}/{:02}/{:04}", parts.month, parts.month_day, parts.year); + } + b'X' => { + let _ = write!( + out, + "{hour12:02}:{:02}:{:02} {}", + parts.minute, + parts.second, + if is_pm { "PM" } else { "AM" } + ); + } + _ => return false, + } + true +} + +fn format_num( + out: &mut String, + val: T, + width: usize, + pad: Pad, +) { + let s = val.to_string(); + if pad == Pad::None { + out.push_str(&s); + return; + } + + let (is_negative, digits) = if let Some(rest) = s.strip_prefix('-') { + (true, rest) + } else { + (false, s.as_str()) + }; + + let total_len = if is_negative { digits.len() + 1 } else { digits.len() }; + let pad_char = if pad == Pad::Space { ' ' } else { '0' }; + + if pad == Pad::Space && is_negative { + for _ in total_len..width { + out.push(' '); + } + out.push('-'); + out.push_str(digits); + } else if pad == Pad::Zero && is_negative { + out.push('-'); + for _ in total_len..width { + out.push('0'); + } + out.push_str(digits); + } else { + for _ in total_len..width { + out.push(pad_char); + } + out.push_str(&s); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ox_types::OxStr; + + fn strftime_utc(fmt: &str, timestamp: i64) -> String { + let tz = TimeZoneInfo { + offset: 0, + name: "UTC".to_string(), + }; + let parts = DateTimeParts::from_local_seconds(timestamp).unwrap(); + format_strftime(fmt, timestamp, &parts, &tz) + } + + fn strftime_tz(fmt: &str, timestamp: i64, offset: i64, name: &str) -> String { + let tz = TimeZoneInfo { + offset, + name: name.to_string(), + }; + let local = timestamp + offset; + let parts = DateTimeParts::from_local_seconds(local).unwrap(); + format_strftime(fmt, timestamp, &parts, &tz) + } + + #[test] + fn strftime_epoch_and_fixed_times() { + assert_eq!(strftime_utc("%Y-%m-%d", 0), "1970-01-01"); + assert_eq!(strftime_utc("%H:%M:%S", 0), "00:00:00"); + assert_eq!(strftime_utc("%Y-%m-%d %H:%M:%S", 0), "1970-01-01 00:00:00"); + assert_eq!(strftime_utc("%H:%M:%S", 86461), "00:01:01"); + assert_eq!(strftime_utc("%Y-%m-%d", 86461), "1970-01-02"); + assert_eq!(strftime_utc("%s", 1234567890), "1234567890"); + assert_eq!(strftime_utc("%s", 0), "0"); + } + + #[test] + fn strftime_timezones() { + assert_eq!(strftime_utc("%z", 0), "+0000"); + assert_eq!(strftime_utc("%Z", 0), "UTC"); + assert_eq!( + strftime_tz("%Y-%m-%d %H:%M:%S %z %Z", 0, 32400, "KST"), + "1970-01-01 09:00:00 +0900 KST" + ); + assert_eq!( + strftime_tz("%Y-%m-%d %H:%M:%S %z %Z", 0, -18000, "EST"), + "1969-12-31 19:00:00 -0500 EST" + ); + } + + #[test] + fn strftime_posix_directives() { + assert_eq!(strftime_utc("%C", 0), "19"); + assert_eq!(strftime_utc("%y", 0), "70"); + assert_eq!(strftime_utc("%m", 0), "01"); + assert_eq!(strftime_utc("%d", 0), "01"); + assert_eq!(strftime_utc("%e", 0), " 1"); + assert_eq!(strftime_utc("%H", 0), "00"); + assert_eq!(strftime_utc("%k", 0), " 0"); + assert_eq!(strftime_utc("%I", 0), "12"); + assert_eq!(strftime_utc("%l", 0), "12"); + assert_eq!(strftime_utc("%M", 0), "00"); + assert_eq!(strftime_utc("%S", 0), "00"); + assert_eq!(strftime_utc("%p", 0), "AM"); + assert_eq!(strftime_utc("%P", 0), "am"); + assert_eq!(strftime_utc("%F", 0), "1970-01-01"); + assert_eq!(strftime_utc("%T", 0), "00:00:00"); + assert_eq!(strftime_utc("%R", 0), "00:00"); + assert_eq!(strftime_utc("%r", 0), "12:00:00 AM"); + assert_eq!(strftime_utc("%D", 0), "01/01/70"); + assert_eq!(strftime_utc("%a", 0), "Thu"); + assert_eq!(strftime_utc("%A", 0), "Thursday"); + assert_eq!(strftime_utc("%b", 0), "Jan"); + assert_eq!(strftime_utc("%B", 0), "January"); + assert_eq!(strftime_utc("%h", 0), "Jan"); + assert_eq!(strftime_utc("%u", 0), "4"); + assert_eq!(strftime_utc("%w", 0), "4"); + assert_eq!(strftime_utc("%j", 0), "001"); + assert_eq!(strftime_utc("%U", 0), "00"); + assert_eq!(strftime_utc("%W", 0), "00"); + assert_eq!(strftime_utc("%V", 0), "01"); + assert_eq!(strftime_utc("%G", 0), "1970"); + assert_eq!(strftime_utc("%g", 0), "70"); + assert_eq!(strftime_utc("%c", 0), "Thu 01 Jan 1970 12:00:00 AM UTC"); + assert_eq!(strftime_utc("%x", 0), "01/01/1970"); + assert_eq!(strftime_utc("%X", 0), "12:00:00 AM"); + } + + #[test] + fn strftime_pad_modifiers() { + assert_eq!(strftime_utc("%-m/%-d/%Y", 0), "1/1/1970"); + assert_eq!(strftime_utc("%_m/%_d/%Y", 0), " 1/ 1/1970"); + assert_eq!(strftime_utc("%0m/%0d/%Y", 0), "01/01/1970"); + } + + #[test] + fn strftime_edges_and_invalid_formats() { + assert_eq!(strftime_utc("%", 0), "%"); + assert_eq!(strftime_utc("%%", 0), "%"); + assert_eq!(strftime_utc("%q", 0), "%q"); + assert_eq!(strftime_utc("", 0), ""); + assert_eq!(strftime_utc(&"a".repeat(255), 0), "a".repeat(255)); + assert_eq!(strftime_utc(&"a".repeat(256), 0), ""); + } + + #[test] + fn strftime_builtin_call_contract() { + let res = call_strftime_builtin(&[Typval::String(OxStr::from("%s"))]).unwrap(); + let Typval::String(s) = res else { panic!("expected string") }; + assert!(!s.0.is_empty()); + assert!(s.to_string_lossy().chars().all(|c| c.is_ascii_digit())); + + let res = call_strftime_builtin(&[ + Typval::String(OxStr::from("%Y")), + Typval::Number(9_223_372_036_854_775_807), + ]) + .unwrap(); + assert_eq!(res, Typval::String(OxStr::from("(Invalid)"))); + + let res = call_strftime_builtin(&[ + Typval::String(OxStr::from("%Y")), + Typval::Number(-9_223_372_036_854_775_808), + ]) + .unwrap(); + assert_eq!(res, Typval::String(OxStr::from("(Invalid)"))); + + assert_eq!( + call_strftime_builtin(&[]).unwrap_err().code, + "E119" + ); + assert_eq!( + call_strftime_builtin(&[ + Typval::String(OxStr::from("%Y")), + Typval::Number(0), + Typval::Number(0), + ]) + .unwrap_err() + .code, + "E118" + ); + assert_eq!( + call_strftime_builtin(&[Typval::list(vec![])]) + .unwrap_err() + .code, + "E730" + ); + assert_eq!( + call_strftime_builtin(&[ + Typval::String(OxStr::from("%Y")), + Typval::list(vec![]), + ]) + .unwrap_err() + .code, + "E745" + ); + } +} diff --git a/crates/ox-editor/src/builtins/environment.rs b/crates/ox-editor/src/builtins/environment.rs index ccc8d62e..22eb8cd6 100644 --- a/crates/ox-editor/src/builtins/environment.rs +++ b/crates/ox-editor/src/builtins/environment.rs @@ -1,18 +1,25 @@ //! Ambient-environment builtins: working directory, system clock, standard //! paths, shell quoting, display width, highlight table, and event-loop state. +use std::rc::Rc; + use crate::excmd_exec::ExEditorAccess; use crate::mode::Mode; use crate::options::OptionValue; use crate::script::{FileIO, StdPath}; +use crate::userfunc::{WatchCallback, WatchTarget}; use crate::visual::VisualKind; use crate::{DirectoryScope, Editor}; use ox_eval::EvalError; +use ox_eval::Scope; +use ox_eval::ScopeKind; +use ox_eval::eval::CallArg; +use ox_eval::RegexEngine; use ox_types::{OxStr, Typval}; use super::input_string_arg; use crate::builtins::position::cell_width; -use crate::excmd_exec::{EvalHost, change_directory, typval_number, typval_to_text}; +use crate::excmd_exec::{EvalHost, VimRegex, change_directory, typval_number}; /// Routes one ambient-environment builtin. pub(crate) fn call( @@ -21,6 +28,9 @@ pub(crate) fn call( args: &[Typval], ) -> ox_eval::Result { match name { + "getcwd" => host + .access + .with_ex_editor(|editor| call_getcwd_builtin(editor, args)), "chdir" => host .access .with_ex_editor(|editor| call_chdir_builtin(editor, args)), @@ -44,6 +54,7 @@ pub(crate) fn call( .access .with_ex_editor(|editor| call_shellescape_builtin(editor, args)), "mode" => call_mode_builtin(host, args), + "state" => call_state_builtin(host, args), "swapname" => { if args.len() > 1 { return Err(EvalError::new( @@ -59,9 +70,163 @@ pub(crate) fn call( .access .with_ex_editor(|editor| call_strdisplaywidth_builtin(editor, args)), "strftime" => call_strftime_builtin(args), + "shiftwidth" => host + .access + .with_ex_editor(|editor| call_shiftwidth_builtin(editor, args)), + "haslocaldir" => host + .access + .with_ex_editor(|editor| call_haslocaldir_builtin(editor, args)), + "bufloaded" => host + .access + .with_ex_editor(|editor| call_bufloaded_builtin(editor, args)), + "buflisted" => host + .access + .with_ex_editor(|editor| call_buflisted_builtin(editor, args)), + "dictwatcheradd" => call_dictwatcher_add_builtin(host, args), + "dictwatcherdel" => call_dictwatcher_del_builtin(host, args), + "getscriptinfo" => call_getscriptinfo_builtin(host, args), _ => unreachable!("environment builtin route and dispatcher disagree"), } } + +/// `f_getscriptinfo` (`runtime.c:2670-2740`): every sourced script as +/// `{name, sid, version, autoload}`, filtered by `sid` or by a `name` pattern. +/// +/// A `sid` query returns one entry and adds the script's `variables` and +/// `functions`; a `name` query keeps every script whose name matches the +/// pattern. Anything else lists every named script. +/// +/// # Errors +/// +/// Returns E118 for a second argument, E1249 for a non-positive `sid`, and the +/// regex error for a bad `name` pattern. +fn call_getscriptinfo_builtin( + host: &mut EvalHost<'_, F, E>, + args: &[Typval], +) -> ox_eval::Result { + if args.len() > 1 { + return Err(EvalError::new( + "E118", + 0, + "Too many arguments for function: getscriptinfo", + )); + } + let query = match args.first() { + None => None, + Some(Typval::Dict(_)) => args.first(), + Some(value) => { + return Err(EvalError::new( + "E1206", + 0, + format!( + "Dictionary required for argument 1: {}", + crate::excmd_exec::typval_to_text(value) + ), + )); + } + }; + let requested_sid = match query { + Some(Typval::Dict(entries)) => { + let entries = entries.borrow(); + match entries.get(b"sid") { + Some(value) => { + let sid = ox_eval::builtins::number_arg(value)?; + if sid <= 0 { + return Err(EvalError::new( + "E475", + 0, + format!( + "Invalid argument: sid: {}", + crate::excmd_exec::typval_to_text(value) + ), + )); + } + Some(crate::script::Sid::try_from(sid).map_err(|_| { + EvalError::new("E475", 0, format!("Invalid argument: sid: {sid}")) + })?) + } + None => None, + } + } + _ => None, + }; + let pattern = match (requested_sid, query) { + (None, Some(Typval::Dict(entries))) => { + let entries = entries.borrow(); + match entries.get(b"name") { + Some(value) => Some(ox_eval::builtins::string_arg(value)?), + None => None, + } + } + _ => None, + }; + + let mut items = Vec::new(); + for (sid, name) in host.runtime.scripts.script_names() { + if requested_sid.is_some_and(|wanted| sid != wanted) { + continue; + } + if name.is_empty() { + continue; + } + if let Some(pattern) = &pattern { + let matches = + RegexEngine::is_match(&VimRegex, &OxStr::from(name), pattern, false)?; + if !matches { + continue; + } + } + let mut fields = vec![ + (OxStr::from("name"), Typval::String(OxStr::from(name))), + (OxStr::from("sid"), Typval::Number(sid as i64)), + (OxStr::from("version"), Typval::Number(1)), + (OxStr::from("autoload"), Typval::Bool(false)), + ]; + if requested_sid.is_some() { + let variables = Typval::dict( + host.runtime + .scripts + .script_vars(sid) + .cloned() + .unwrap_or_default(), + ); + fields.push((OxStr::from("variables"), variables)); + let functions = host + .runtime + .functions + .names_in_script(sid) + .into_iter() + .map(|name| Typval::String(OxStr::from(name.as_str()))) + .collect(); + fields.push((OxStr::from("functions"), Typval::list(functions))); + } + items.push(Typval::dict(fields)); + } + Ok(Typval::list(items)) +} + +/// Routes one ambient-environment builtin with per-argument identities. +/// +/// Only `dictwatcheradd()`/`dictwatcherdel()` read identities: a bare +/// `g:`/`b:`/`w:`/`t:` argument keeps the live scope map's identity, which +/// resolves to [`WatchTarget::Scope`], while a Dict-valued variable like +/// `g:d` keeps its slot identity and stays [`WatchTarget::Dict`]. Every +/// other builtin runs through [`call`] with plain values. +pub(crate) fn call_with_identities( + host: &mut EvalHost<'_, F, E>, + name: &str, + args: Vec, + scope: &mut Scope, +) -> ox_eval::Result { + match name { + "dictwatcheradd" => call_dictwatcher_add_scoped(host, &args, scope), + "dictwatcherdel" => call_dictwatcher_del_scoped(host, &args, scope), + _ => { + let values = args.into_iter().map(|arg| arg.value).collect::>(); + call(host, name, &values) + } + } +} /// `f_defer` through `add_defer` (`eval/userfunc.c` 3390-3484): register `{fn}` /// — a Funcref or a function-name String — with its remaining arguments on /// the innermost user-function frame, to be fired when that frame ends. @@ -89,7 +254,7 @@ fn call_defer_builtin( } // `userfunc.c:3398-3412`: a Partial's bound arguments come first, then // the arguments given to `defer()` itself. - let (name, mut call_args) = match &args[0] { + match &args[0] { Typval::Funcref(function) | Typval::Partial(function) => { if function.dict.is_some() { return Err(EvalError::new( @@ -98,10 +263,13 @@ fn call_defer_builtin( "Cannot use a partial with dictionary for :defer", )); } - ( - function.name.to_string_lossy().into_owned(), - function.args.clone(), - ) + // The value is deferred whole: a lambda's `N` resolves + // only through the closure registry captured in its Funcref, so + // re-dispatching the bare name at fire time would hit `E117`. + // Bound arguments stay inside the callee — `call_value` fires + // them before `defer()`'s own. + host.runtime + .push_deferred_call_value(args[0].clone(), args[1..].to_vec()); } value => { let name = input_string_arg(value)?; @@ -109,11 +277,10 @@ fn call_defer_builtin( if text.is_empty() { return Err(EvalError::new("E129", 0, "Function name required")); } - (text.into_owned(), Vec::new()) + host.runtime + .push_deferred_call(text.into_owned(), args[1..].to_vec()); } - }; - call_args.extend(args[1..].iter().cloned()); - host.runtime.push_deferred_call(name, call_args); + } Ok(Typval::Number(0)) } @@ -276,6 +443,11 @@ fn non_zero_arg(value: &Typval) -> bool { } } +/// `f_chdir` (`eval/fs.c:400-435`): change the directory of `{scope}`, or of +/// the narrowest scope holding a local directory when `{scope}` is absent: +/// buffer-local, else window-local, else tabpage-local, else global. +/// Returns the previous directory, empty when the change failed or the old +/// directory could not be read. fn call_chdir_builtin(editor: &mut Editor, args: &[Typval]) -> ox_eval::Result { if args.is_empty() || args.len() > 2 { return Err(EvalError::new( @@ -287,16 +459,34 @@ fn call_chdir_builtin(editor: &mut Editor, args: &[Typval]) -> ox_eval::Result { - if local_active { - DirectoryScope::Window + // Narrowest scope currently holding a local directory, else + // global (`f_chdir`'s `curbuf`/`curwin`/`curtab` chain). + let buffer_local = editor + .current_buffer() + .and_then(|buffer| editor.buffer_local_directory(buffer).ok().flatten()) + .is_some(); + if buffer_local { + DirectoryScope::Buffer } else { - DirectoryScope::Global + let window_local = editor + .current_window() + .and_then(|window| editor.window_local_directory(window).ok().flatten()) + .is_some(); + if window_local { + DirectoryScope::Window + } else { + let tab_local = editor + .current_tabpage() + .and_then(|tab| editor.tab_local_directory(tab).ok().flatten()) + .is_some(); + if tab_local { + DirectoryScope::Tabpage + } else { + DirectoryScope::Global + } + } } } Some(value) => { @@ -310,7 +500,9 @@ fn call_chdir_builtin(editor: &mut Editor, args: &[Typval]) -> ox_eval::Result DirectoryScope::Global, - b"window" | b"tabpage" | b"buffer" => DirectoryScope::Window, + b"window" => DirectoryScope::Window, + b"tabpage" => DirectoryScope::Tabpage, + b"buffer" => DirectoryScope::Buffer, _ => { return Err(EvalError::new( "E475", @@ -331,37 +523,7 @@ fn call_chdir_builtin(editor: &mut Editor, args: &[Typval]) -> ox_eval::Result ox_eval::Result { - if args.is_empty() { - return Err(EvalError::new( - "E119", - 0, - "Not enough arguments for function: strftime", - )); - } - if args.len() > 2 { - return Err(EvalError::new( - "E118", - 0, - "Too many arguments for function: strftime", - )); - } - if typval_to_text(&args[0]) != "%H:%M:%S" { - return Err(EvalError::not_implemented(OxStr::from("strftime format"))); - } - let timestamp = args.get(1).and_then(typval_number).unwrap_or_else(|| { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| { - i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) - }) - }); - let seconds = timestamp.rem_euclid(86_400); - let hours = seconds / 3_600; - let minutes = seconds % 3_600 / 60; - let seconds = seconds % 60; - Ok(Typval::String(OxStr::from( - format!("{hours:02}:{minutes:02}:{seconds:02}").as_str(), - ))) + super::datetime::call_strftime_builtin(args) } fn call_hlexists_builtin(editor: &Editor, args: &[Typval]) -> ox_eval::Result { @@ -438,6 +600,643 @@ fn call_mode_builtin( Ok(Typval::String(OxStr::from(text.as_str()))) } +/// `state([{what}])` (`eval/funcs.c:4385-4416`): flag characters for the +/// editor's current busyness, filtered to `{what}` when given. +/// +/// Each character reads live session state: `m` while typeahead waits, +/// `o` for a pending operator, `a` while insert completion is active, `x` +/// while an autocommand plan runs, and `c` per callback nesting level. +/// `S` marks a non-safe state, which is exactly when one of the other +/// conditions holds. `s` (messages scrolled off screen) has no counterpart +/// in this port's message model and is never emitted. +fn call_state_builtin( + host: &mut EvalHost<'_, F, E>, + args: &[Typval], +) -> ox_eval::Result { + if args.len() > 1 { + return Err(EvalError::new( + "E118", + 0, + "Too many arguments for function: state", + )); + } + // `may_add_state_char` (`eval/funcs.c:4372`): an empty `{what}` matches + // no flag, so `state('')` is always `""`; only a missing `{what}` lifts + // the filter. + let include = match args.first() { + None => None, + Some(value) => Some(input_string_arg(value)?.to_string_lossy().into_owned()), + }; + let completion_active = host + .runtime + .mode_machine + .as_ref() + .is_some_and(|machine| machine.borrow().completion.is_active()); + let typeahead_busy = host + .access + .with_ex_editor(|editor| !editor.typeahead().is_empty()); + let autocmd_busy = host.runtime.autocmd_busy > 0; + // `o` is `op_pending()` (`normal.c:505-512`), which is true whenever no + // clean Normal-mode operator context exists — including `current_oap == + // NULL`, which is every context this port can be in: there is no + // `normal_enter` lifecycle and no operator argument block, so a pending + // operator is indistinguishable from no operator context at all. A real + // `OperatorPending` mode reads the same. + // `S` is `!get_was_safe_state()` (`state.c:326-329`): safe state is only + // entered by an idle main loop (`may_trigger_safestate`), which this + // port never runs, so every builtin evaluation happens outside it. + // `c` counts `Callback` invocations (`eval.c:call_callback`: funcref and + // expression callbacks from timers, jobs, and `sort()`/`map()` — never + // plain `:call` frames), which this port does not track, so it is never + // emitted rather than invented from the user-function call stack. + // `s` (messages scrolled off screen) has no counterpart in this port's + // message model and is never emitted. + let mut state = String::new(); + let push = |state: &mut String, flag: char, on: bool| { + if on && include.as_ref().is_none_or(|filter| filter.contains(flag)) { + state.push(flag); + } + }; + push(&mut state, 'm', typeahead_busy); + push(&mut state, 'o', true); + push(&mut state, 'a', completion_active); + push(&mut state, 'x', autocmd_busy); + push(&mut state, 'S', true); + Ok(Typval::String(OxStr::from(state.as_str()))) +} + +/// Enforces the `eval.lua` argument counts the way upstream's function table +/// does before a builtin body runs. +fn check_arity(name: &str, count: usize) -> ox_eval::Result<()> { + let spec = ox_eval::builtin_spec(name) + .ok_or_else(|| EvalError::new("E117", 0, format!("Unknown function: {name}")))?; + if count < spec.min_args { + return Err(EvalError::new( + "E119", + 0, + format!("Not enough arguments for function: {name}"), + )); + } + if spec.max_args.is_some_and(|maximum| count > maximum) { + return Err(EvalError::new( + "E118", + 0, + format!("Too many arguments for function: {name}"), + )); + } + Ok(()) +} + +/// Reads a buffer-scoped numeric option for the current buffer, falling back +/// to the global baseline and then to `default` when neither exists. +fn buffer_number_option(editor: &Editor, name: &str, default: i64) -> i64 { + let value = editor + .current_buffer() + .and_then(|buffer| editor.options().get_buffer(buffer, name).ok()) + .or_else(|| editor.options().get_global_baseline(name).ok()); + match value { + Some(OptionValue::Number(value)) => *value, + _ => default, + } +} + +/// `f_shiftwidth` (`eval/funcs.c:6589`): the effective `'shiftwidth'` — the +/// option itself unless it is zero, in which case `'tabstop'`. An optional +/// column selects the indent step at that column; without `'vartabstop'` +/// (unmodelled here) the column only matters through its coercion, and a +/// negative column answers 0. +fn call_shiftwidth_builtin(editor: &Editor, args: &[Typval]) -> ox_eval::Result { + check_arity("shiftwidth", args.len())?; + if let Some(column) = args.first() { + // `tv_get_number_chk` (`ox_eval::builtins::number_arg`): coerce or + // fail with the typed error, then bail out silently on a negative + // column with the preset 0. Strings never error (`'x'` is 0). + if ox_eval::builtins::number_arg(column)? < 0 { + return Ok(Typval::Number(0)); + } + } + let shiftwidth = buffer_number_option(editor, "shiftwidth", 8); + if shiftwidth != 0 { + return Ok(Typval::Number(shiftwidth)); + } + Ok(Typval::Number(buffer_number_option(editor, "tabstop", 8))) +} + +/// Scope addressed by `getcwd()`/`haslocaldir()` arguments +/// (`getcwd_scope_args`, `eval/fs.c:657-760`). In an argument `0` means the +/// current object and `-1` skips the scope, requesting the next broader one; +/// the argument order (window, tab, buffer) differs from the `CdScope` order. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CwdAddress { + /// No arguments to `getcwd()`: the effective working directory. + Effective, + /// A window in a tabpage: the window's local directory, else the + /// tabpage's. The tab is the addressed tabpage (or the current one when + /// no `{tabnr}` was given), which the window lookup runs in. + Window { + /// Addressed window. + window: ox_types::WinHandle, + /// Addressed tabpage, if one is live. + tab: Option, + }, + /// A tabpage: its local directory. + Tabpage { + /// Addressed tabpage, if one is live. + tab: Option, + }, + /// A buffer: its local directory, skipping window and tab scopes. A + /// buffer belongs to no particular window/tabpage, so this falls back + /// to the global directory. + Buffer { + /// Addressed buffer. + buffer: ox_types::BufHandle, + }, + /// The session-global directory. + Global, +} + +/// Resolves `[{winnr}[, {tabnr}[, {bufnr}]]]` to the requested scope +/// (`getcwd_scope_args`, `eval/fs.c:657-760`). `unspecified` is the scope +/// with no arguments: [`CwdAddress::Effective`] for `getcwd()`, +/// the current window for `haslocaldir()`. +/// +/// # Errors +/// +/// Returns `E474` for non-number arguments or numbers below `-1`, `E5001` +/// for `-1` after an explicit scope, `E5006` for a buffer scope with a +/// window/tab argument, `E5007` for a missing buffer, `E5000` for a missing +/// tabpage, and `E5002` for a missing window. +fn resolve_cwd_address( + editor: &Editor, + args: &[Typval], + unspecified: CwdAddress, +) -> ox_eval::Result { + let mut selected = [0i64; 3]; + for (index, argument) in args.iter().enumerate() { + // Upstream rejects anything but `VAR_NUMBER` with `E474`. + let Typval::Number(number) = argument else { + return Err(EvalError::new("E474", 0, "Invalid argument")); + }; + if *number < -1 { + return Err(EvalError::new("E474", 0, "Invalid argument")); + } + selected[index] = *number; + } + // An argument may only be -1 when every preceding argument is -1. + let mut explicit = false; + for number in selected.iter().take(args.len()) { + explicit = explicit || *number >= 0; + if explicit && *number < 0 { + return Err(EvalError::new( + "E5001", + 0, + "Argument cannot be -1 if preceding argument is >= 0.", + )); + } + } + let argc = args.len(); + let (window_number, tab_number, buffer_number) = (selected[0], selected[1], selected[2]); + if argc == 0 { + return Ok(unspecified); + } + // A buffer target skips the window and tab scopes entirely; only + // `-1, -1, {bufnr}` reaches it. + if argc == 3 && buffer_number >= 0 { + if window_number >= 0 || tab_number >= 0 { + return Err(EvalError::new( + "E5006", + 0, + "Window and tab scope must be -1 when using buffer scope", + )); + } + let buffer = if buffer_number == 0 { + editor.current_buffer() + } else { + ox_types::BufHandle::try_from(buffer_number) + .ok() + .filter(|buffer| editor.buffer(*buffer).is_ok()) + }; + let Some(buffer) = buffer else { + return Err(EvalError::new("E5007", 0, "Cannot find buffer number.")); + }; + return Ok(CwdAddress::Buffer { buffer }); + } + // Narrowest requested scope. Missing `{tabnr}` implies the current + // tabpage, exactly like the zero-initialized `argv` upstream. + let tab = if argc >= 2 && tab_number > 0 { + let tabs = editor.tabpages(); + let Some(tab) = usize::try_from(tab_number) + .ok() + .and_then(|number| number.checked_sub(1)) + .and_then(|index| tabs.get(index).copied()) + else { + return Err(EvalError::new("E5000", 0, "Cannot find tab number.")); + }; + Some(tab) + } else { + editor.current_tabpage() + }; + if argc == 1 && window_number < 0 { + return Ok(CwdAddress::Tabpage { tab }); + } + if argc >= 2 && window_number < 0 { + if tab_number >= 0 { + return Ok(CwdAddress::Tabpage { tab }); + } + return Ok(CwdAddress::Global); + } + // A window target: `0` answers the current window (even when a `{tabnr}` + // was given, like `find_win_by_nr`); otherwise a live handle wins over + // the 1-based position in the addressed tabpage. + let window = if window_number == 0 { + editor.current_window() + } else { + let windows = tab + .and_then(|tab| editor.tabpage_windows(tab).ok()) + .unwrap_or_default(); + ox_types::WinHandle::try_from(window_number) + .ok() + .filter(|handle| windows.contains(handle)) + .or_else(|| { + usize::try_from(window_number) + .ok() + .and_then(|number| number.checked_sub(1)) + .and_then(|index| windows.get(index).copied()) + }) + }; + let Some(window) = window else { + return Err(EvalError::new("E5002", 0, "Cannot find window number.")); + }; + Ok(CwdAddress::Window { window, tab }) +} + +/// `f_getcwd` (`eval/fs.c:771-832`): the working directory of the addressed +/// scope. A window address falls back to its tabpage, then the global +/// directory; tabpage and buffer addresses fall back to the global directory +/// (a buffer belongs to no particular window/tabpage); the global address +/// reads the saved fallback, else the live process directory. Whatever falls +/// through to the process directory answers `""` when it cannot be read. +fn call_getcwd_builtin(editor: &Editor, args: &[Typval]) -> ox_eval::Result { + check_arity("getcwd", args.len())?; + let address = resolve_cwd_address(editor, args, CwdAddress::Effective)?; + let global_or_os = || { + editor + .global_directory() + .map(std::path::Path::to_path_buf) + .or_else(|| std::env::current_dir().ok()) + }; + let directory = match address { + CwdAddress::Effective => std::env::current_dir().ok(), + CwdAddress::Window { window, tab } => editor + .window_local_directory(window) + .ok() + .flatten() + .or_else(|| { + tab.and_then(|tab| editor.tab_local_directory(tab).ok().flatten()) + }) + .or_else(global_or_os), + CwdAddress::Tabpage { tab } => tab + .and_then(|tab| editor.tab_local_directory(tab).ok().flatten()) + .or_else(global_or_os), + CwdAddress::Buffer { buffer } => editor + .buffer_local_directory(buffer) + .ok() + .flatten() + .or_else(global_or_os), + CwdAddress::Global => global_or_os(), + }; + Ok(Typval::String(OxStr::from( + directory + .map(|directory| directory.to_string_lossy().into_owned()) + .unwrap_or_default() + .as_str(), + ))) +} + +/// `f_haslocaldir` (`eval/fs.c:1041`): whether the addressed scope object +/// has a local working directory. The global scope never has one; argument +/// validation mirrors `getcwd_scope_args` exactly. +fn call_haslocaldir_builtin(editor: &Editor, args: &[Typval]) -> ox_eval::Result { + check_arity("haslocaldir", args.len())?; + // Without arguments the window scope is implied, like + // `getcwd_scope_args` with a window default. A missing current window + // has no window-local directory to report. + let Some(current) = editor.current_window() else { + return Ok(Typval::Number(0)); + }; + let unspecified = CwdAddress::Window { + window: current, + tab: editor.current_tabpage(), + }; + let address = resolve_cwd_address(editor, args, unspecified)?; + let present = match address { + CwdAddress::Window { window, .. } => editor + .window_local_directory(window) + .ok() + .flatten() + .is_some(), + CwdAddress::Tabpage { tab } => tab + .and_then(|tab| editor.tab_local_directory(tab).ok().flatten()) + .is_some(), + CwdAddress::Buffer { buffer } => editor + .buffer_local_directory(buffer) + .ok() + .flatten() + .is_some(), + CwdAddress::Global | CwdAddress::Effective => false, + }; + Ok(Typval::Number(i64::from(present))) +} + +/// `f_bufloaded` (`eval/buffer.c:498`): whether `find_buffer()` resolves the +/// argument and the buffer text is resident. Unlike `bufexists()`, no +/// argument spells the current buffer: `0`, `""`, and `"%"` all miss, and +/// non-number/string values answer 0 without an error. +fn call_bufloaded_builtin(editor: &Editor, args: &[Typval]) -> ox_eval::Result { + check_arity("bufloaded", args.len())?; + let buffer = crate::excmd_exec::find_buffer_argument(editor, args.first()); + let loaded = buffer.is_some_and(|buffer| { + editor + .buffer(buffer) + .is_ok_and(|state| state.residency.is_loaded()) + }); + Ok(Typval::Number(i64::from(loaded))) +} + +/// `f_buflisted` (`eval/buffer.c:476-482`): whether `find_buffer()` resolves the +/// argument and the buffer is listed. Resolution matches `bufloaded()`, so `0`, +/// `""`, and non-name values miss. +fn call_buflisted_builtin(editor: &Editor, args: &[Typval]) -> ox_eval::Result { + check_arity("buflisted", args.len())?; + let buffer = crate::excmd_exec::find_buffer_argument(editor, args.first()); + let listed = buffer.is_some_and(|buffer| { + editor.buffer(buffer).is_ok_and(|state| { + state.flags.contains(crate::BufferFlags::LISTED) + }) + }); + Ok(Typval::Number(i64::from(listed))) +} + +/// Whether a callback name needs the digit-lead rejection: upstream's +/// `callback_from_typval` answers `E921` for a `String` starting with a +/// digit on both the add and the delete path, while an undefined name +/// otherwise registers fine (resolution happens at fire time). +fn dictwatcher_name_rejected(name: &OxStr) -> bool { + name.as_bytes().first().is_some_and(|byte| byte.is_ascii_digit()) +} + +/// Normalises a `dictwatcheradd()` callback: a `Funcref`/`Partial` value is +/// stored whole, a name is stored as written (an undefined name still +/// registers), and a null callback stores the never-firing +/// [`WatchCallback::None`]. Anything else — including a digit-leading name +/// — is `E921`. +fn dictwatcher_add_callback(argument: &Typval) -> ox_eval::Result { + match argument { + Typval::Funcref(function) => Ok(WatchCallback::Funcref(function.clone())), + Typval::Partial(function) => Ok(WatchCallback::Partial(function.clone())), + Typval::String(name) if !dictwatcher_name_rejected(name) => { + Ok(WatchCallback::Name(name.clone())) + } + Typval::Special(_) => Ok(WatchCallback::None), + _ => Err(EvalError::new("E921", 0, "Invalid callback argument")), + } +} + +/// Normalises a `dictwatcherdel()` callback: same shapes as the add path, +/// except a non-string/non-function (null included) is the `E475` +/// `funcref` refusal from the delete body's explicit type check. +fn dictwatcher_del_callback(argument: &Typval) -> ox_eval::Result { + match argument { + Typval::Funcref(function) => Ok(WatchCallback::Funcref(function.clone())), + Typval::Partial(function) => Ok(WatchCallback::Partial(function.clone())), + Typval::String(name) if dictwatcher_name_rejected(name) => Err(EvalError::new( + "E921", + 0, + "Invalid callback argument", + )), + Typval::String(name) => Ok(WatchCallback::Name(name.clone())), + _ => Err(EvalError::new("E475", 0, "Invalid argument: funcref")), + } +} + +/// The watched `Dict` identity for `dictwatcheradd()`/`dictwatcherdel()`. +/// Bare `g:`/`b:`/`w:`/`t:` snapshots carry no provenance in a bare +/// `Typval`, so scope namespaces need the evaluator to record +/// `WatchTarget::Scope` (reported to the route owner); every other dict +/// keys by live identity. +fn dictwatcher_target(argument: &Typval) -> ox_eval::Result { + match argument { + Typval::Dict(reference) => Ok(WatchTarget::Dict(Rc::as_ptr(reference) as usize)), + _ => Err(EvalError::new("E475", 0, "Invalid argument: dict")), + } +} + +/// The add-path target: `v:_null_dict` is refused with `E46`, while the +/// delete path lets the lookup miss instead (the `Couldn't find a watcher` +/// error). +fn dictwatcher_add_target(argument: &Typval) -> ox_eval::Result { + if argument.is_null_dict() { + return Err(EvalError::new( + "E46", + 0, + "Cannot change read-only variable \"dictwatcheradd() argument\"", + )); + } + dictwatcher_target(argument) +} + +/// Resolves a bare scope-namespace argument (`g:`, `b:`, `w:`, `t:`) to its +/// [`WatchTarget::Scope`]: the evaluator records the live map's identity for +/// exactly these expressions, so matching it against the same maps in `scope` +/// tells them apart from a Dict-valued variable like `g:d`, whose identity is +/// its variable slot's instead. Anything else is not a scope namespace. +fn scope_target(identity: Option, scope: &Scope) -> Option { + let identity = identity?; + if identity == std::ptr::from_ref(&scope.global) as usize { + Some(WatchTarget::Scope(ScopeKind::Global)) + } else if identity == std::ptr::from_ref(&scope.buffer) as usize { + Some(WatchTarget::Scope(ScopeKind::Buffer)) + } else if identity == std::ptr::from_ref(&scope.window) as usize { + Some(WatchTarget::Scope(ScopeKind::Window)) + } else if identity == std::ptr::from_ref(&scope.tab) as usize { + Some(WatchTarget::Scope(ScopeKind::Tab)) + } else { + None + } +} + +/// The scoped `dictwatcheradd()`/`dictwatcherdel()` target: a bare scope +/// namespace keeps [`WatchTarget::Scope`]; every other argument validates +/// exactly like the values-only path. +fn dictwatcher_target_scoped(argument: &CallArg, scope: &Scope) -> ox_eval::Result { + if let Some(target) = scope_target(argument.identity, scope) { + return Ok(target); + } + dictwatcher_target(&argument.value) +} + +/// The scoped add-path target: same `v:_null_dict` refusal as the +/// values-only path, with scope namespaces resolved first. +fn dictwatcher_add_target_scoped( + argument: &CallArg, + scope: &Scope, +) -> ox_eval::Result { + if let Some(target) = scope_target(argument.identity, scope) { + return Ok(target); + } + dictwatcher_add_target(&argument.value) +} + +/// Validates scoped `dictwatcheradd()` arguments: same pattern/callback +/// checks as [`dictwatcher_add_args`], with the target resolved against the +/// live `scope` maps. +fn dictwatcher_add_args_scoped( + args: &[CallArg], + scope: &Scope, +) -> ox_eval::Result<(WatchTarget, Option, WatchCallback)> { + check_arity("dictwatcheradd", args.len())?; + let target = dictwatcher_add_target_scoped(&args[0], scope)?; + let pattern = dictwatcher_pattern(&args[1].value)?; + let callback = dictwatcher_add_callback(&args[2].value)?; + Ok((target, pattern, callback)) +} + +/// Validates scoped `dictwatcherdel()` arguments: same pattern/callback +/// checks as [`dictwatcher_del_args`], with the target resolved against the +/// live `scope` maps. +fn dictwatcher_del_args_scoped( + args: &[CallArg], + scope: &Scope, +) -> ox_eval::Result<(WatchTarget, Option, WatchCallback)> { + check_arity("dictwatcherdel", args.len())?; + let target = dictwatcher_target_scoped(&args[0], scope)?; + let pattern = dictwatcher_pattern(&args[1].value)?; + let callback = dictwatcher_del_callback(&args[2].value)?; + Ok((target, pattern, callback)) +} + +/// Scoped `f_dictwatcheradd` (`eval/funcs.c:965`): same registry round as +/// [`call_dictwatcher_add_builtin`], with the target resolved against `scope`. +fn call_dictwatcher_add_scoped( + host: &mut EvalHost<'_, F, E>, + args: &[CallArg], + scope: &Scope, +) -> ox_eval::Result { + let (target, pattern, callback) = dictwatcher_add_args_scoped(args, scope)?; + let Some(pattern) = pattern else { + return Ok(Typval::Number(0)); + }; + host.runtime + .functions + .dictwatcher_add(target, pattern, callback); + Ok(Typval::Number(0)) +} + +/// Scoped `f_dictwatcherdel` (`eval/funcs.c:1003`): same match-all-or-miss +/// round as [`call_dictwatcher_del_builtin`], with the target resolved +/// against `scope`. +fn call_dictwatcher_del_scoped( + host: &mut EvalHost<'_, F, E>, + args: &[CallArg], + scope: &Scope, +) -> ox_eval::Result { + let (target, pattern, callback) = dictwatcher_del_args_scoped(args, scope)?; + let removed = match pattern { + Some(pattern) => host.runtime.functions.dictwatcher_remove( + &target, + pattern.as_bytes(), + &callback, + ), + // A null pattern never matches an added watcher. + None => false, + }; + if removed { + return Ok(Typval::Number(0)); + } + Err(EvalError::new( + "", + 0, + "Couldn't find a watcher matching key and callback", + )) +} + +/// Coerces a dictwatcher key pattern: upstream accepts `VAR_STRING` and +/// `VAR_NUMBER` (`tv_get_string_chk`), refusing anything else. A null +/// pattern adds no watcher and answers 0. +fn dictwatcher_pattern(argument: &Typval) -> ox_eval::Result> { + match argument { + Typval::String(pattern) => Ok(Some(pattern.clone())), + Typval::Number(number) => Ok(Some(OxStr::from(number.to_string().as_str()))), + Typval::Special(ox_types::Special::Null) => Ok(None), + _ => Err(EvalError::new("E475", 0, "Invalid argument: key")), + } +} + +/// Validates `dictwatcheradd()` arguments without touching the registry so +/// the checks stay unit-testable without an executor host. +fn dictwatcher_add_args( + args: &[Typval], +) -> ox_eval::Result<(WatchTarget, Option, WatchCallback)> { + check_arity("dictwatcheradd", args.len())?; + let target = dictwatcher_add_target(&args[0])?; + let pattern = dictwatcher_pattern(&args[1])?; + let callback = dictwatcher_add_callback(&args[2])?; + Ok((target, pattern, callback)) +} + +/// Validates `dictwatcherdel()` arguments without touching the registry. +fn dictwatcher_del_args( + args: &[Typval], +) -> ox_eval::Result<(WatchTarget, Option, WatchCallback)> { + check_arity("dictwatcherdel", args.len())?; + let target = dictwatcher_target(&args[0])?; + let pattern = dictwatcher_pattern(&args[1])?; + let callback = dictwatcher_del_callback(&args[2])?; + Ok((target, pattern, callback)) +} + +/// `f_dictwatcheradd` (`eval/funcs.c:965`). +fn call_dictwatcher_add_builtin( + host: &mut EvalHost<'_, F, E>, + args: &[Typval], +) -> ox_eval::Result { + let (target, pattern, callback) = dictwatcher_add_args(args)?; + let Some(pattern) = pattern else { + return Ok(Typval::Number(0)); + }; + host.runtime + .functions + .dictwatcher_add(target, pattern, callback); + Ok(Typval::Number(0)) +} + +/// `f_dictwatcherdel` (`eval/funcs.c:1003`): removes the watcher only when +/// dict, key, and callback all match, else the codeless +/// `Couldn't find a watcher matching key and callback`. +fn call_dictwatcher_del_builtin( + host: &mut EvalHost<'_, F, E>, + args: &[Typval], +) -> ox_eval::Result { + let (target, pattern, callback) = dictwatcher_del_args(args)?; + let removed = match pattern { + Some(pattern) => host.runtime.functions.dictwatcher_remove( + &target, + pattern.as_bytes(), + &callback, + ), + // A null pattern never matches an added watcher. + None => false, + }; + if removed { + return Ok(Typval::Number(0)); + } + Err(EvalError::new( + "", + 0, + "Couldn't find a watcher matching key and callback", + )) +} + #[cfg(test)] mod tests { use ox_eval::Scope; @@ -594,7 +1393,6 @@ mod api_and_highlight_tests { match value { Typval::Dict(dict) => Some( dict.borrow() - .entries .iter() .map(|field| field.key.to_string_lossy().into_owned()) .collect(), @@ -635,4 +1433,342 @@ mod api_and_highlight_tests { assert_eq!(global_number(&exec, "unknown"), Some(0)); assert_eq!(global_number(&exec, "exists"), Some(1)); } + + /// Reads a global string set by a probe script. + fn global_string(exec: &ExExecutor, name: &str) -> Option { + match exec + .scope() + .get_scoped(ox_eval::ScopeKind::Global, name.as_bytes(), 0) + .ok()? + { + Typval::String(value) => Some(value.to_string_lossy().into_owned()), + _ => None, + } + } + + /// `state()` (`eval/funcs.c:4385-4416`). + /// + /// Oracle ground truth (headless `nvim -u NONE -S`): a bare call answers + /// `'oS'` — `op_pending()` with no Normal-mode operator context and no + /// idle safe state; `state('o')` and `state('S')` select those flags; + /// `state('')` is always `""`; and an autocommand body observes `'oxS'`. + #[test] + fn state_reports_busy_flags_and_filter() { + let editor = TestEditorAccess::new(Editor::new()); + let mut exec = ExExecutor::new(); + run( + &editor, + &mut exec, + "let g:bare = state()\nlet g:o = state('o')\nlet g:s = state('S')\nlet g:none = state('')\nlet g:idle = state('xa')\nautocmd BufEnter * let g:au = state()\nenew", + ); + assert_eq!(global_string(&exec, "bare").as_deref(), Some("oS")); + assert_eq!(global_string(&exec, "o").as_deref(), Some("o")); + assert_eq!(global_string(&exec, "s").as_deref(), Some("S")); + assert_eq!(global_string(&exec, "none").as_deref(), Some("")); + assert_eq!(global_string(&exec, "idle").as_deref(), Some("")); + assert_eq!(global_string(&exec, "au").as_deref(), Some("oxS")); + } +} + +#[cfg(test)] +mod ambient_direct_tests { + use ox_types::Typval; + + use crate::options::OptionValue; + use crate::{DirectoryScope, Editor, TestEditorAccess}; + + /// A fresh editor with one listed buffer shown in one tabpage, so + /// current-buffer/window lookups resolve the way they do in a live + /// session (`Editor::new()` starts with neither). + fn editor_with_window() -> (TestEditorAccess, ox_types::BufHandle) { + let editor = TestEditorAccess::new(Editor::new()); + let buffer = editor + .editor_mut() + .create_buffer_with(ox_text::Buffer::new(), true) + .unwrap(); + editor + .editor_mut() + .create_tabpage(buffer, crate::Geometry::new(0, 0, 80, 24).unwrap()) + .unwrap(); + (editor, buffer) + } + + fn eval_builtin( + editor: &Editor, + name: &str, + args: &[Typval], + ) -> ox_eval::Result { + match name { + "shiftwidth" => super::call_shiftwidth_builtin(editor, args), + "haslocaldir" => super::call_haslocaldir_builtin(editor, args), + "bufloaded" => super::call_bufloaded_builtin(editor, args), + _ => unreachable!("covered builtin"), + } + } + + fn number_arg(value: &Typval) -> i64 { + match value { + Typval::Number(value) => *value, + _ => panic!("expected a Number, got {value:?}"), + } + } + + fn text(value: &str) -> Typval { + Typval::String(ox_types::OxStr::from(value)) + } + + // `shiftwidth()` (`eval/funcs.c:6589`): the buffer option, or `tabstop` + // when it is zero; the column form coerces, a negative column is 0. + #[test] + fn shiftwidth_reports_effective_indent_step() { + let (editor, buffer) = editor_with_window(); + editor + .editor_mut() + .options_mut() + .set_buffer(buffer, "shiftwidth", OptionValue::Number(4)) + .unwrap(); + let editor_ref = editor.editor(); + assert_eq!( + number_arg(&eval_builtin(&editor_ref, "shiftwidth", &[]).unwrap()), + 4 + ); + assert_eq!( + number_arg( + &eval_builtin(&editor_ref, "shiftwidth", &[Typval::Number(3)]).unwrap() + ), + 4 + ); + assert_eq!( + number_arg( + &eval_builtin(&editor_ref, "shiftwidth", &[Typval::Number(-1)]).unwrap() + ), + 0 + ); + drop(editor_ref); + editor + .editor_mut() + .options_mut() + .set_buffer(buffer, "shiftwidth", OptionValue::Number(0)) + .unwrap(); + editor + .editor_mut() + .options_mut() + .set_buffer(buffer, "tabstop", OptionValue::Number(6)) + .unwrap(); + let editor_ref = editor.editor(); + assert_eq!( + number_arg(&eval_builtin(&editor_ref, "shiftwidth", &[]).unwrap()), + 6 + ); + assert_eq!( + number_arg( + &eval_builtin(&editor_ref, "shiftwidth", &[Typval::Number(100)]).unwrap() + ), + 6 + ); + // A non-numeric column coerces silently: `'x'` is 0. + assert_eq!( + number_arg(&eval_builtin(&editor_ref, "shiftwidth", &[text("x")]).unwrap()), + 6 + ); + } + + // `haslocaldir()` (`eval/fs.c:1041`): 1 after a window-local directory + // is set, 0 without one, and upstream's validation errors for bad + // shapes. Tab/buffer/global scopes store nothing in this port. + #[test] + fn haslocaldir_tracks_window_directories_and_validates() { + let _guard = crate::lock_process_state(); + let original = std::env::current_dir().unwrap(); + let target = + std::env::temp_dir().join(format!("ox-editor-haslocaldir-{}", std::process::id())); + std::fs::create_dir_all(&target).unwrap(); + let (editor, _buffer) = editor_with_window(); + { + let editor_ref = editor.editor(); + assert_eq!( + number_arg(&eval_builtin(&editor_ref, "haslocaldir", &[]).unwrap()), + 0 + ); + } + { + let mut borrowed = editor.editor_mut(); + crate::excmd_exec::change_directory( + &mut borrowed, + &target.to_string_lossy(), + DirectoryScope::Window, + ) + .unwrap(); + } + { + let editor_ref = editor.editor(); + for args in [ + vec![], + vec![Typval::Number(0)], + vec![Typval::Number(0), Typval::Number(0)], + ] { + assert_eq!( + number_arg(&eval_builtin(&editor_ref, "haslocaldir", &args).unwrap()), + 1, + "{args:?}" + ); + } + for args in [ + vec![Typval::Number(-1)], + vec![Typval::Number(-1), Typval::Number(0)], + vec![ + Typval::Number(-1), + Typval::Number(-1), + ], + ] { + assert_eq!( + number_arg(&eval_builtin(&editor_ref, "haslocaldir", &args).unwrap()), + 0, + "{args:?}" + ); + } + } + std::env::set_current_dir(&original).unwrap(); + std::fs::remove_dir(&target).unwrap(); + // Validation mirrors `getcwd_scope_args`. + let editor_ref = editor.editor(); + for (args, code) in [ + (vec![text("x")], "E474"), + (vec![Typval::Number(1), Typval::Number(-1)], "E5001"), + ( + vec![ + Typval::Number(0), + Typval::Number(0), + Typval::Number(0), + ], + "E5006", + ), + ( + vec![ + Typval::Number(-1), + Typval::Number(-1), + Typval::Number(99999), + ], + "E5007", + ), + ( + vec![ + Typval::Number(0), + Typval::Number(0), + Typval::Number(0), + Typval::Number(0), + ], + "E118", + ), + (vec![Typval::Number(-1), Typval::Number(99)], "E5000"), + (vec![Typval::Number(99)], "E5002"), + ] { + assert_eq!( + eval_builtin(&editor_ref, "haslocaldir", &args).unwrap_err().code, + code, + "{args:?}" + ); + } + } + + // `bufloaded()` (`eval/buffer.c:498`): the current buffer by number is + // loaded, a missing number — and `0`, `""`, and every non-number/string + // — is not. + #[test] + fn bufloaded_reports_resident_buffers() { + let (editor, _buffer) = editor_with_window(); + let editor_ref = editor.editor(); + let current = editor_ref.current_buffer().unwrap(); + let current_number = Typval::Number(i64::from(current)); + assert_eq!( + number_arg(&eval_builtin(&editor_ref, "bufloaded", &[current_number]).unwrap()), + 1 + ); + for argument in [ + Typval::Number(99999), + Typval::Number(0), + text(""), + text("%"), + Typval::Bool(true), + Typval::Special(ox_types::Special::Null), + ] { + assert_eq!( + number_arg(&eval_builtin(&editor_ref, "bufloaded", &[argument]).unwrap()), + 0 + ); + } + } + + // `dictwatcheradd()`/`dictwatcherdel()` argument validation + // (`eval/funcs.c:965,1003`): the registry round-trip lives in + // `userfunc` tests; here the builtin layer's target/pattern/callback + // checks and upstream error codes are pinned. + #[test] + fn dictwatcher_argument_validation_matches_upstream() { + let dict = || Typval::dict(vec![(ox_types::OxStr::from("k"), Typval::Number(1))]); + let name = || Typval::String(ox_types::OxStr::from("g:Changed")); + // Valid shapes parse. + let (target, pattern, callback) = + super::dictwatcher_add_args(&[dict(), text("watched"), name()]).unwrap(); + assert!(matches!(target, crate::userfunc::WatchTarget::Dict(_))); + assert_eq!(pattern.unwrap().as_bytes(), b"watched"); + assert!(matches!(callback, crate::userfunc::WatchCallback::Name(_))); + // A null pattern adds nothing but still validates. + assert!( + super::dictwatcher_add_args(&[ + dict(), + Typval::Special(ox_types::Special::Null), + name(), + ]) + .unwrap() + .1 + .is_none() + ); + // A null callback registers the never-firing variant. + assert!(matches!( + super::dictwatcher_add_args(&[ + dict(), + text("x"), + Typval::Special(ox_types::Special::Null), + ]) + .unwrap() + .2, + crate::userfunc::WatchCallback::None + )); + for (arguments, code) in [ + // add path + (vec![dict(), text("x"), Typval::Number(42)], "E921"), + (vec![dict(), text("x"), text("123")], "E921"), + (vec![text("x"), text("x"), name()], "E475"), + (vec![Typval::null_dict(), text("x"), name()], "E46"), + (vec![dict(), Typval::Bool(true), name()], "E475"), + ] { + assert_eq!( + super::dictwatcher_add_args(&arguments).unwrap_err().code, + code, + "{arguments:?}" + ); + } + // A numeric pattern coerces like `tv_get_string_chk`. + assert_eq!( + super::dictwatcher_add_args(&[dict(), Typval::Number(1), name()]) + .unwrap() + .1 + .unwrap() + .as_bytes(), + b"1" + ); + for (arguments, code) in [ + // delete path: numbers are `E475`, digit names `E921`. + (vec![dict(), text("x"), Typval::Number(42)], "E475"), + (vec![dict(), text("x"), text("123")], "E921"), + (vec![dict(), text("x"), Typval::Bool(true)], "E475"), + ] { + assert_eq!( + super::dictwatcher_del_args(&arguments).unwrap_err().code, + code, + "{arguments:?}" + ); + } + } } diff --git a/crates/ox-editor/src/builtins/eval.rs b/crates/ox-editor/src/builtins/eval.rs index 8c4de3de..7b362c3f 100644 --- a/crates/ox-editor/src/builtins/eval.rs +++ b/crates/ox-editor/src/builtins/eval.rs @@ -18,7 +18,7 @@ use ox_eval::builtins::string_arg; use ox_eval::closure_index; use ox_eval::exists as exists_in_scope; use ox_excmd::{ResolveError, ResolvedCommand, resolve_command}; -use ox_types::{Funcref, Object, OxStr, Special, Typval}; +use ox_types::{BufHandle, Funcref, Object, OxStr, Special, Typval}; use std::rc::Rc; use super::input_string_arg; @@ -35,8 +35,29 @@ pub(crate) fn call( args: &[Typval], scope: &mut Scope, ) -> ox_eval::Result { + // The old `ox-eval` path ran `check_arity` before dispatch; keep the + // same `eval.lua` table gate here so `uniq()`/call()` report E119 + // instead of indexing past `args`. + let spec = ox_eval::builtin_spec(name) + .ok_or_else(|| EvalError::new("E117", 0, format!("Unknown function: {name}")))?; + if args.len() < spec.min_args { + return Err(EvalError::new( + "E119", + 0, + format!("Not enough arguments for function: {name}"), + )); + } + if spec.max_args.is_some_and(|maximum| args.len() > maximum) { + return Err(EvalError::new( + "E118", + 0, + format!("Too many arguments for function: {name}"), + )); + } match name { "eval" => call_eval_builtin(host, args, scope), + "call" => call_call_builtin(host, args, scope), + "uniq" => call_uniq_builtin(host, args, scope), "execute" => call_execute_builtin(host.runtime, host.access, scope, host.lua, args), "exists" => exists_with_editor(host.runtime, host.access, scope, args), "expand" => call_expand_builtin(host.runtime, host.access, args), @@ -54,6 +75,162 @@ pub(crate) fn call( /// `eval()`: parse the argument and evaluate it against this same host, so the /// editor seams stay reachable from the nested expression (`f_eval`). +/// `uniq({list} [, {func} [, {dict}]])` (`eval/list.c` `f_uniq`). +/// +/// Without a comparator adjacent items are removed when they are equal; with +/// one, the comparator decides. The comparator runs with no borrow on the list +/// held, because it may reenter the editor, and a comparator that changes the +/// list is refused the way upstream refuses it. +fn call_uniq_builtin( + host: &mut EvalHost<'_, F, E>, + args: &[Typval], + scope: &mut Scope, +) -> ox_eval::Result { + let container = args[0].clone(); + let Typval::List(reference) = &container else { + return Err(EvalError::new( + "E686", + 0, + "Argument of uniq() must be a List", + )); + }; + let comparator = match args.get(1) { + None | Some(Typval::Special(Special::Null)) => None, + Some(value) => Some(value.clone()), + }; + let receiver = match args.get(2) { + None | Some(Typval::Special(Special::Null)) => None, + Some(value @ Typval::Dict(_)) => Some(value.clone()), + Some(_) => return Err(EvalError::new("E731", 0, "Using a List as a Dict")), + }; + let snapshot = reference + .try_borrow() + .map_err(|_| EvalError::new("E742", 0, "Cannot change value"))? + .items + .clone(); + let mut removals = Vec::new(); + // Both paths compare the last kept item with the next original one, which + // is what upstream does; comparing snapshot[index - 1] instead keeps an + // item that the last kept item would have swallowed. + let mut kept = 0_usize; + for index in 1..snapshot.len() { + let duplicate = match &comparator { + None => snapshot[kept] == snapshot[index], + Some(comparator) => { + let callable = match (&receiver, comparator) { + (Some(receiver), Typval::String(name)) => { + let function = resolve_function_reference( + host.runtime, + None, + &Typval::String(name.clone()), + )?; + let function = + bind_function_arguments(function, std::slice::from_ref(receiver))?; + Typval::Partial(function) + } + (Some(receiver), Typval::Funcref(funcref) | Typval::Partial(funcref)) => { + let function = bind_function_arguments( + funcref.clone(), + std::slice::from_ref(receiver), + )?; + Typval::Partial(function) + } + (None, comparator) => comparator.clone(), + (Some(_), _) => return Err(EvalError::new("E1085", 0, "not a callable value")), + }; + let value = Evaluator::new(host, &VimRegex).invoke( + callable, + vec![snapshot[kept].clone(), snapshot[index].clone()], + scope, + )?; + // The item is dropped when the comparator answers false: + // `uniq([1,2,3], {a,b -> a < b})` keeps every item while + // `{a,b -> a > b}` keeps only the first, and both follow from + // that rule. + match value { + Typval::Number(number) => number == 0, + Typval::Float(number) => number == 0.0, + Typval::Bool(value) => !value, + _ => return Err(EvalError::new("E474", 0, "Invalid argument")), + } + } + }; + if duplicate { + removals.push(index); + } else { + kept = index; + } + } + let mut data = reference + .try_borrow_mut() + .map_err(|_| EvalError::new("E742", 0, "Cannot change value"))?; + // `uniq` removes items in place, so a `:lockvar`-locked list fails + // `tv_check_lock` with E741 before any removal. + if data.lock.locked { + return Err(EvalError::new("E741", 0, "Value is locked")); + } + if data.items.len() != snapshot.len() { + return Err(EvalError::new("E741", 0, "Cannot change list")); + } + for index in removals.into_iter().rev() { + data.items.remove(index); + } + drop(data); + Ok(container) +} + +/// `call({func}, {arglist} [, {dict}])` (`eval/funcs.c` `f_call`). +/// +/// The callee may be a Funcref or Partial, which goes through the evaluator's +/// own invocation path, or a function name, which goes through the host so a +/// user function and a builtin both resolve. A third argument binds `self` +/// for the duration of the call, exactly as a dictionary method call does. +fn call_call_builtin( + host: &mut EvalHost<'_, F, E>, + args: &[Typval], + scope: &mut Scope, +) -> ox_eval::Result { + let callee = args[0].clone(); + let Typval::List(reference) = &args[1] else { + return Err(EvalError::new("E714", 0, "List required")); + }; + let call_args = reference + .try_borrow() + .map_err(|_| EvalError::new("E742", 0, "Cannot change value"))? + .items + .clone(); + // A third argument binds `self`: upstream turns the call into + // `function({func}, {dict})` and runs that partial, which is the only + // path whose frame the callee reads `self` from. + let callable = match args.get(2) { + None | Some(Typval::Special(Special::Null)) => callee, + Some(value @ Typval::Dict(_)) => match &callee { + Typval::String(_) => { + let function = resolve_function_reference(host.runtime, None, &callee)?; + let function = bind_function_arguments(function, std::slice::from_ref(value))?; + Typval::Partial(function) + } + Typval::Funcref(funcref) | Typval::Partial(funcref) => { + let function = + bind_function_arguments(funcref.clone(), std::slice::from_ref(value))?; + Typval::Partial(function) + } + _ => return Err(EvalError::new("E1085", 0, "not a callable value")), + }, + Some(_) => return Err(EvalError::new("E731", 0, "Using a List as a Dict")), + }; + match callable { + Typval::Funcref(_) | Typval::Partial(_) => { + Evaluator::new(host, &VimRegex).invoke(callable, call_args, scope) + } + Typval::String(name) => { + let name = OxStr(name.as_bytes().into()); + host.call(&name, call_args, scope) + } + _ => Err(EvalError::new("E1085", 0, "not a callable value")), + } +} + fn call_eval_builtin( host: &mut EvalHost<'_, F, E>, args: &[Typval], @@ -153,7 +330,12 @@ fn call_execute_builtin( let Object::String(text) = &message.content else { continue; }; - if message.leading_newline || !output.is_empty() { + // Upstream captures raw redirected output: a message contributes its + // separator only when it starts a new line (`:echo`/`:echomsg` do, + // `:echon` continues the current one). A continuation appends + // directly even after earlier output (`echo "a" | echon "b"` captures + // `"\nab"`, not `"\na\nb"`). + if message.leading_newline { output.push('\n'); } output.push_str(&text.to_string_lossy()); @@ -242,49 +424,218 @@ fn call_luaeval_builtin( /// Leading cmdline `sp_token` table for `expand()` (`find_cmdline_var`, /// `ex_docmd.c:7488`), limited to the tokens this port resolves. Ordered /// longest-first so a later ``-style addition cannot be shadowed. -const EXPAND_SPECIAL_TOKENS: &[&str] = &["", "", "", "", "%"]; +const EXPAND_SPECIAL_TOKENS: &[&str] = &["", "", "", "", "%", "#"]; + +const VALID_PATH: u32 = 1; +const VALID_HEAD: u32 = 2; + +fn skip_substitution(bytes: &[u8]) -> usize { + let offset = if bytes.starts_with(b":gs") { + 3 + } else if bytes.starts_with(b":s") { + 2 + } else { + return 0; + }; + let Some(&sep) = bytes.get(offset) else { + return 0; + }; + let rest = &bytes[offset + 1..]; + let Some(first) = rest.iter().position(|&b| b == sep) else { + return 0; + }; + let after_pat = &rest[first + 1..]; + let Some(second) = after_pat.iter().position(|&b| b == sep) else { + return 0; + }; + offset + 1 + first + 1 + second + 1 +} + +fn modifier_validity(modifiers: &[u8]) -> u32 { + let mut valid = 0; + let mut cursor = 0; + let mut can_accept_p = true; + + while cursor < modifiers.len() { + let rest = &modifiers[cursor..]; + if rest.starts_with(b":p") { + if !can_accept_p { + break; + } + valid |= VALID_PATH; + can_accept_p = false; + cursor += 2; + continue; + } + if rest.starts_with(b":h") { + valid |= VALID_HEAD; + can_accept_p = false; + cursor += 2; + continue; + } + if rest.starts_with(b":.") + || rest.starts_with(b":~") + || rest.starts_with(b":8") + || rest.starts_with(b":t") + || rest.starts_with(b":e") + || rest.starts_with(b":r") + || rest.starts_with(b":S") + { + can_accept_p = false; + cursor += 2; + continue; + } + let sub_len = skip_substitution(rest); + if sub_len > 0 { + can_accept_p = true; + cursor += sub_len; + continue; + } + break; + } + + valid +} + +enum ExpandTarget { + Absent, + UnnamedBuffer, + Named(OxStr), +} + +fn alternate_window_buffer(editor: &crate::Editor) -> Option<&crate::BufferState> { + let window = editor.current_window()?; + let alternate = editor.window(window).ok()?.alternate_buffer?; + editor.buffer(alternate).ok() +} + +fn classify_buffer(buffer: Option<&crate::BufferState>) -> ExpandTarget { + match buffer { + None => ExpandTarget::Absent, + Some(buffer) if buffer.name().as_bytes().is_empty() => ExpandTarget::UnnamedBuffer, + Some(buffer) => { + let name = buffer.name().to_string_lossy(); + ExpandTarget::Named(OxStr::from(name.as_ref())) + } + } +} + +fn resolve_buffer_target(editor: &crate::Editor, token: &str) -> ExpandTarget { + let digits = token.strip_prefix('#').unwrap_or_default(); + let number = if digits.is_empty() { + 0 + } else { + digits.parse::().unwrap_or(-1) + }; + let buffer = if number == 0 { + alternate_window_buffer(editor) + } else if number > 0 { + BufHandle::try_from(number) + .ok() + .and_then(|handle| editor.buffer(handle).ok()) + } else { + None + }; + classify_buffer(buffer) +} + +fn autocmd_text_target(value: &OxStr) -> ExpandTarget { + if value.as_bytes().is_empty() { + ExpandTarget::Absent + } else { + ExpandTarget::Named(value.clone()) + } +} -/// The `eval_vars` (`ex_docmd.c:7551`) bases behind `expand()`'s special -/// tokens. A token this port does not know yields an empty base. -fn expand_special_base( +fn expand_special_target( runtime: &ExRuntime, access: &E, token: &str, -) -> OxStr { +) -> ExpandTarget { match token { "%" => access.with_ex_editor(|editor| { - editor - .current_buffer() - .and_then(|buffer| editor.buffer(buffer).ok()) - .map_or_else(|| OxStr(Vec::new()), |buffer| { - let name = buffer.name().to_string_lossy(); - OxStr::from(name.as_ref()) - }) + let current = editor.current_buffer().and_then(|h| editor.buffer(h).ok()); + classify_buffer(current) }), - "" => runtime.scripts.current_sid().map_or_else( - || OxStr(Vec::new()), + "" => runtime.scripts.current_sid().map_or( + ExpandTarget::Absent, |sid| { let name = format!("{sid}_"); - OxStr::from(name.as_str()) + ExpandTarget::Named(OxStr::from(name.as_str())) }, ), - "" => runtime.active_autocmd.matched.clone(), - "" => runtime.active_autocmd.file.clone(), - "" => runtime.active_autocmd.buffer.map_or_else(|| OxStr(Vec::new()), |buffer| { - let number = i64::from(buffer).to_string(); - OxStr::from(number.as_str()) - }), - _ => OxStr(Vec::new()), + "" => autocmd_text_target(&runtime.active_autocmd.matched), + "" => autocmd_text_target(&runtime.active_autocmd.file), + "" => runtime.active_autocmd.buffer.map_or( + ExpandTarget::Absent, + |buffer| { + let number = i64::from(buffer).to_string(); + ExpandTarget::Named(OxStr::from(number.as_str())) + }, + ), + token if token.starts_with('#') => { + access.with_ex_editor(|editor| resolve_buffer_target(editor, token)) + } + _ => ExpandTarget::Absent, + } +} + +fn find_expand_special<'a>(text: &'a str) -> Option<(&'a str, &'a str)> { + if let Some(rest) = text.strip_prefix('#') { + let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count(); + let (token, remaining) = text.split_at(1 + digits); + return Some((token, remaining)); } + EXPAND_SPECIAL_TOKENS + .iter() + .find_map(|token| text.strip_prefix(token).map(|rest| (*token, rest))) } +fn apply_unnamed_modifiers(rest: &str) -> ox_eval::Result { + if rest.is_empty() { + return Ok(OxStr(Vec::new())); + } + let modified = ox_eval::apply_filename_modifiers(Some(&VimRegex), "", rest.as_bytes())?; + let valid = modifier_validity(rest.as_bytes()); + if modified.is_empty() || valid != (VALID_HEAD | VALID_PATH) { + Ok(OxStr(Vec::new())) + } else { + Ok(OxStr::from(modified.as_str())) + } +} + +fn apply_named_modifiers(name: OxStr, rest: &str) -> ox_eval::Result { + if rest.is_empty() { + return Ok(name); + } + let base_text = name.to_string_lossy(); + let modified = ox_eval::apply_filename_modifiers( + Some(&VimRegex), + base_text.as_ref(), + rest.as_bytes(), + )?; + if modified.is_empty() { + Ok(OxStr(Vec::new())) + } else { + Ok(OxStr::from(modified.as_str())) + } +} + +fn apply_expand_target(target: ExpandTarget, rest: &str) -> ox_eval::Result { + match target { + ExpandTarget::Absent => Ok(OxStr(Vec::new())), + ExpandTarget::UnnamedBuffer => apply_unnamed_modifiers(rest), + ExpandTarget::Named(name) => apply_named_modifiers(name, rest), + } +} /// `expand()` (`f_expand`). /// -/// `%`, `#` and a `<...>` keyword go to `eval_vars`; this port resolves -/// `%`, ``, ``, `` and `` as leading tokens with -/// an optional trailing `:`-modifier chain applied through +/// `%`, `#`, `#N` and a `<...>` keyword go to `eval_vars`; this port resolves +/// `%`, `#`, `#N`, ``, ``, `` and `` as leading tokens +/// with an optional trailing `:`-modifier chain applied through /// `ox_eval::apply_filename_modifiers` (`modify_fname` parity), so -/// `expand('%:p')` is the absolute current-buffer name. Anything else is a +/// `expand('%:p')` is the absolute current-buffer name and `expand('#:p')` +/// is the absolute alternate-buffer name. Anything else is a /// file pattern handed to `ExpandOne`, which resolves `~` and `$NAME` /// through `expand_env_esc` before matching. Returning such a pattern /// verbatim leaves `expand('~')` as the literal `~`, and callers that hand @@ -293,7 +644,7 @@ fn expand_special_base( /// /// Named gap: the wildcard half of `ExpandOne` is not here, so a pattern /// with `*` or `?` still comes back as itself; `glob()` is where this port -/// matches files. `#` and ``/``-style tokens are likewise +/// matches files. ``/``-style tokens are likewise /// not recognized and pass through verbatim, and the `%<` extension-strip /// form is not implemented. fn call_expand_builtin( @@ -306,27 +657,13 @@ fn call_expand_builtin( }; let text = value.to_string_lossy(); let text: &str = text.as_ref(); - let special = EXPAND_SPECIAL_TOKENS - .iter() - .find_map(|token| text.strip_prefix(token).map(|rest| (*token, rest))); + let special = find_expand_special(text); let expanded = match special { // Exact token or token-plus-`:`-modifier chain (`eval_vars` + // `modify_fname`). Some((token, rest)) if rest.is_empty() || rest.starts_with(':') => { - let base = expand_special_base(runtime, access, token); - // `expand()` on an unnamed source yields "" (upstream f_expand: - // eval_vars marks the result invalid and f_expand returns ""). - if rest.is_empty() || base.as_bytes().is_empty() { - base - } else { - let base_text = base.to_string_lossy(); - let modified = ox_eval::apply_filename_modifiers( - Some(&VimRegex), - base_text.as_ref(), - rest.as_bytes(), - )?; - OxStr::from(modified.as_str()) - } + let target = expand_special_target(runtime, access, token); + apply_expand_target(target, rest)? } _ => { let expanded = expand_env_esc(text); @@ -371,11 +708,22 @@ fn resolve_function_reference( } } } else { + // `common_function` validates the *translated* name + // (`funcs.c:1573-1577` → `translated_function_exists`), and + // `save_function_name` strips a leading `g:` for a plain + // string (`eval/userfunc.c:2117`). A script-local `s:` (or + // ``) is expanded to its `{sid}_` spelling so the + // reference stays callable from other scripts + // (`get_scriptlocal_funcname`, `userfunc.c:1559`); anything + // else keeps the spelling the caller wrote, as upstream + // stores `s`, not `trans_name`: `function('g:Fn')` prints + // `function('g:Fn')`. + let lookup = text.strip_prefix("g:").unwrap_or(&text); let sid = runtime.scripts.current_sid().unwrap_or(0); - let known = builtin_spec(&text).is_some() - || runtime.functions.contains(&text, sid) - || text.contains('#') - || crate::builtins::route(&text).is_some(); + let known = builtin_spec(lookup).is_some() + || runtime.functions.contains(lookup, sid) + || lookup.contains('#') + || crate::builtins::route(lookup).is_some(); if !known { return Err(EvalError::new( "E700", @@ -383,6 +731,21 @@ fn resolve_function_reference( format!("Unknown function: {text}"), )); } + let stored = if sid == 0 { + name.clone() + } else if let Some(local) = text.strip_prefix("s:") { + OxStr::from(format!("{sid}_{local}").as_str()) + } else if let Some(local) = text.strip_prefix("") { + OxStr::from(format!("{sid}_{local}").as_str()) + } else { + name.clone() + }; + return Ok(Funcref { + name: stored, + args: Vec::new(), + dict: None, + registry: lambda_registry, + }); } Ok(Funcref { name: name.clone(), @@ -417,15 +780,7 @@ fn bind_function_arguments(mut function: Funcref, args: &[Typval]) -> ox_eval::R ); } Typval::Dict(reference) if args.len() == 1 => { - dictionary = Some( - reference - .try_borrow() - .map_err(|_| EvalError::new("E742", 0, "Cannot change value"))? - .entries - .iter() - .map(|entry| (entry.key.clone(), entry.value.clone())) - .collect(), - ); + dictionary = Some(reference.clone()); } _ => { return Err(EvalError::new( @@ -440,15 +795,7 @@ fn bind_function_arguments(mut function: Funcref, args: &[Typval]) -> ox_eval::R let Typval::Dict(reference) = third else { return Err(EvalError::new("E922", 0, "Expected a dict")); }; - dictionary = Some( - reference - .try_borrow() - .map_err(|_| EvalError::new("E742", 0, "Cannot change value"))? - .entries - .iter() - .map(|entry| (entry.key.clone(), entry.value.clone())) - .collect(), - ); + dictionary = Some(reference.clone()); } if let Some(mut values) = bound { function.args.append(&mut values); @@ -531,7 +878,10 @@ fn call_feedkeys_builtin( .map_err(|error| EvalError::new("E475", 0, error.to_string())) })?; if execute { - let machine = std::rc::Rc::new(std::cell::RefCell::new(ModeMachine::default())); + let machine = runtime + .mode_machine + .clone() + .unwrap_or_else(|| std::rc::Rc::new(std::cell::RefCell::new(ModeMachine::default()))); let flow = drain_typeahead(runtime, access, scope, lua, &machine, usize::MAX); if !matches!(flow, Flow::Normal) { return Err(flow_to_eval_error(flow, "feedkeys")); @@ -643,6 +993,7 @@ const DISPATCHED_COMMANDS: &[&str] = &[ "cd", "cexpr", "cfirst", + "checkhealth", "clearjumps", "close", "cmap", @@ -723,6 +1074,7 @@ const DISPATCHED_COMMANDS: &[&str] = &[ "ounmap", "previous", "print", + "iput", "put", "qall", "quit", @@ -739,7 +1091,9 @@ const DISPATCHED_COMMANDS: &[&str] = &[ "rshada", "rviminfo", "scriptencoding", + "scriptnames", "set", + "setfiletype", "setglobal", "setlocal", "sleep", @@ -837,3 +1191,266 @@ fn is_executed_command(name: &str) -> bool { || PROGRAM_COMMANDS.contains(&name) || COMMAND_MODIFIERS.contains(&name) } + +#[cfg(test)] +mod tests { + use ox_types::{OxStr, Typval}; + + use crate::script::RealFileIO; + use crate::{Editor, ExExecutor, ExecError, TestEditorAccess}; + + fn text(value: &str) -> Typval { + Typval::String(OxStr::from(value)) + } + + fn executor() -> ExExecutor { + ExExecutor::with_io(RealFileIO) + } + + fn script(exec: &mut ExExecutor, editor: &TestEditorAccess, source: &str) { + exec.execute_script(editor, "", source) + .expect("script runs"); + } + + fn eval( + exec: &mut ExExecutor, + editor: &TestEditorAccess, + expression: &str, + ) -> Typval { + exec.evaluate_expression(editor, expression) + .expect("expression evaluates") + } + + fn failure( + exec: &mut ExExecutor, + editor: &TestEditorAccess, + expression: &str, + ) -> String { + match exec.evaluate_expression(editor, expression) { + Err(ExecError::Vim(exception)) => exception.message(), + other => panic!("expected a Vim error, got {other:?}"), + } + } + + /// `call()` reaches a builtin by name, a Funcref, and a lambda, which are + /// the three callee shapes `f_call` accepts (`eval/funcs.c`). + #[test] + fn call_dispatches_names_funcrefs_and_lambdas() { + let editor = TestEditorAccess::new(Editor::new()); + let mut exec = executor(); + script(&mut exec, &editor, "let g:a = call('toupper', ['a'])"); + script( + &mut exec, + &editor, + "let g:b = call(function('toupper'), ['abc'])", + ); + script(&mut exec, &editor, "let g:c = call({x -> x * 2}, [21])"); + assert_eq!(eval(&mut exec, &editor, "g:a"), text("A")); + assert_eq!(eval(&mut exec, &editor, "g:b"), text("ABC")); + assert_eq!(eval(&mut exec, &editor, "g:c"), Typval::Number(42)); + } + + /// A dictionary argument binds `self`, both through `call()` and through a + /// Partial built by `function({name}, {dict})`. + #[test] + fn a_dictionary_argument_binds_self() { + let editor = TestEditorAccess::new(Editor::new()); + let mut exec = executor(); + script( + &mut exec, + &editor, + "let d = {'v': 5}\nfunction! Get() dict abort\nreturn self.v\nendfunction", + ); + script(&mut exec, &editor, "let g:direct = call('Get', [], d)"); + script(&mut exec, &editor, "let g:partial = function('Get', d)()"); + assert_eq!(eval(&mut exec, &editor, "g:direct"), Typval::Number(5)); + assert_eq!(eval(&mut exec, &editor, "g:partial"), Typval::Number(5)); + } + + /// The argument errors `f_call` reports: unknown callee, too few + /// arguments for the callee, and a third argument that is not a + /// dictionary. + #[test] + fn call_reports_upstream_argument_errors() { + let editor = TestEditorAccess::new(Editor::new()); + let mut exec = executor(); + script( + &mut exec, + &editor, + "function! Add(a, b) abort\nreturn a:a + a:b\nendfunction", + ); + assert!(failure(&mut exec, &editor, "call('Nosuch', [])").contains("E117")); + assert!(failure(&mut exec, &editor, "call('Add', [1])").contains("E119")); + assert!(failure(&mut exec, &editor, "call('Add', [1, 2], [3])").contains("E731")); + } + + /// `uniq()` without a comparator drops adjacent equal items. + #[test] + fn uniq_without_a_comparator_drops_adjacent_duplicates() { + let editor = TestEditorAccess::new(Editor::new()); + let mut exec = executor(); + assert_eq!( + eval(&mut exec, &editor, "uniq([1, 1, 2, 2, 1])"), + eval(&mut exec, &editor, "[1, 2, 1]") + ); + assert_eq!( + eval(&mut exec, &editor, "uniq(['a', 'a'])"), + eval(&mut exec, &editor, "['a']") + ); + } + + /// With a comparator, the walk compares the last kept item with the next + /// original one, and the item is dropped when the comparator is false. + /// These six cases are the oracle's answers. + #[test] + fn uniq_honours_the_comparator() { + let editor = TestEditorAccess::new(Editor::new()); + let mut exec = executor(); + let cases = [ + ("[1,1,2]", "{a,b -> a == b}", "[1, 1]"), + ("[3,1,2]", "{a,b -> b > a}", "[3]"), + ("[1,2,1]", "{a,b -> a == b}", "[1, 1]"), + ("[1,2,3]", "{a,b -> a < b}", "[1, 2, 3]"), + ("[1,2,3]", "{a,b -> a > b}", "[1]"), + ]; + for (list, comparator, expected) in cases { + assert_eq!( + eval(&mut exec, &editor, &format!("uniq({list}, {comparator})")), + eval(&mut exec, &editor, expected), + "uniq({list}, {comparator})" + ); + } + } + + /// A comparator can be a named function bound to a dictionary, which is + /// the `uniq(list, 'Name', dict)` form. + #[test] + fn uniq_comparator_can_be_a_dictionary_method() { + let editor = TestEditorAccess::new(Editor::new()); + let mut exec = executor(); + script( + &mut exec, + &editor, + "let g:d = {'same': 1}\nfunction! Same(a, b) dict abort\nreturn self.same && a:a == a:b\nendfunction", + ); + assert_eq!( + eval(&mut exec, &editor, "uniq([5, 5, 5], 'Same', g:d)"), + eval(&mut exec, &editor, "[5, 5, 5]") + ); + } + + /// `uniq()` refuses a non-list argument with upstream's error. + #[test] + fn uniq_rejects_a_non_list() { + let editor = TestEditorAccess::new(Editor::new()); + let mut exec = executor(); + assert!(failure(&mut exec, &editor, "uniq(1)").contains("E686")); + } + + #[test] + fn expand_resolves_alternate_and_numbered_buffers_with_modifiers() { + let editor = TestEditorAccess::new(Editor::new()); + let buf1 = editor.editor_mut().create_buffer(true).unwrap(); + editor + .editor_mut() + .buffer_mut(buf1) + .unwrap() + .set_name(OxStr::from("foo.txt")); + editor + .editor_mut() + .create_tabpage(buf1, crate::Geometry::new(0, 0, 80, 24).unwrap()) + .unwrap(); + + let buf2 = editor.editor_mut().create_buffer(true).unwrap(); + editor + .editor_mut() + .buffer_mut(buf2) + .unwrap() + .set_name(OxStr::from("bar.txt")); + + let buf3 = editor.editor_mut().create_buffer(true).unwrap(); + + let mut exec = executor(); + + assert_eq!(eval(&mut exec, &editor, "expand('#')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('#:p')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('#:h')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('#:p:h')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('#99')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('#99:p')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('#99:h')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('#99:p:h')"), text("")); + + let win = editor.editor().current_window().unwrap(); + editor.editor_mut().window_mut(win).unwrap().buffer = buf2; + editor.editor_mut().window_mut(win).unwrap().alternate_buffer = Some(buf1); + + assert_eq!(eval(&mut exec, &editor, "expand('#')"), text("foo.txt")); + assert_eq!(eval(&mut exec, &editor, "expand('#2')"), text("bar.txt")); + assert_eq!(eval(&mut exec, &editor, "expand('#2:h')"), text(".")); + assert_eq!(eval(&mut exec, &editor, "expand('#2:gs?a?b?')"), text("bbr.txt")); + + let bar_p = eval(&mut exec, &editor, "expand('#2:p')"); + let Typval::String(bar_str) = &bar_p else { panic!("expected string") }; + assert!(bar_str.to_string_lossy().ends_with("bar.txt")); + + let bar_p_h = eval(&mut exec, &editor, "expand('#2:p:h')"); + let Typval::String(bar_dir) = &bar_p_h else { panic!("expected string") }; + assert!(!bar_dir.to_string_lossy().is_empty()); + + assert_eq!(eval(&mut exec, &editor, "expand('#3')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('#3:p')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('#3:h')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('#3:gs?a?b?')"), text("")); + + let unnamed_p_h = eval(&mut exec, &editor, "expand('#3:p:h')"); + let Typval::String(unnamed_dir) = &unnamed_p_h else { panic!("expected string") }; + assert_eq!(unnamed_dir, bar_dir); + + let foo_p_gs = eval(&mut exec, &editor, "expand('#1:p:gs?\\?/?')"); + let Typval::String(foo_str) = &foo_p_gs else { panic!("expected string") }; + let foo_path = foo_str.to_string_lossy(); + assert!(foo_path.ends_with("foo.txt")); + assert!(!foo_path.contains('\\')); + + assert_eq!(eval(&mut exec, &editor, "expand('1#')"), text("1#")); + + editor.editor_mut().window_mut(win).unwrap().alternate_buffer = Some(buf3); + assert_eq!(eval(&mut exec, &editor, "expand('#')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('#:p')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('#:h')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('#:p:h')"), Typval::String(unnamed_dir.clone())); + + editor.editor_mut().window_mut(win).unwrap().buffer = buf3; + assert_eq!(eval(&mut exec, &editor, "expand('%')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('%:p')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('%:h')"), text("")); + assert_eq!(eval(&mut exec, &editor, "expand('%:p:h')"), Typval::String(unnamed_dir.clone())); + } + + /// The `eval.lua` arity table gates this family the way the old + /// `ox-eval` dispatch did: too few arguments report E119 instead of + /// reaching a body that indexes past `args`. + #[test] + fn missing_arguments_report_e119_not_a_panic() { + let editor = TestEditorAccess::new(Editor::new()); + let mut exec = executor(); + assert!(failure(&mut exec, &editor, "uniq()").contains("E119")); + assert!(failure(&mut exec, &editor, "call('toupper')").contains("E119")); + assert!(failure(&mut exec, &editor, "execute()").contains("E119")); + } + + /// `uniq` removes items in place, so a `:lockvar`-locked list reports + /// E741 before any removal and keeps its contents. + #[test] + fn uniq_refuses_a_locked_list_with_e741() { + let editor = TestEditorAccess::new(Editor::new()); + let mut exec = executor(); + script(&mut exec, &editor, "let l = [1, 1, 2]\nlockvar l"); + assert!(failure(&mut exec, &editor, "uniq(l)").contains("E741")); + assert_eq!(eval(&mut exec, &editor, "len(l)"), Typval::Number(3)); + script(&mut exec, &editor, "unlockvar l"); + script(&mut exec, &editor, "call uniq(l)"); + assert_eq!(eval(&mut exec, &editor, "len(l)"), Typval::Number(2)); + } +} diff --git a/crates/ox-editor/src/builtins/filesystem.rs b/crates/ox-editor/src/builtins/filesystem.rs index 88de98e8..3f48116e 100644 --- a/crates/ox-editor/src/builtins/filesystem.rs +++ b/crates/ox-editor/src/builtins/filesystem.rs @@ -65,5 +65,93 @@ pub(crate) fn call( } return result; } + if name == "glob" && !args.is_empty() { + if let Some(result) = glob_with_brace_expansion(host.runtime.scripts.io(), args)? { + return Ok(result); + } + } crate::fs_builtins::call(host.runtime.scripts.io(), name, args) } + +fn expand_braces(pattern: &str) -> Vec { + let bytes = pattern.as_bytes(); + let Some(open) = bytes.iter().position(|b| *b == b'{') else { + return vec![pattern.to_string()]; + }; + let Some(close) = bytes[open..].iter().position(|b| *b == b'}').map(|p| open + p) else { + return vec![pattern.to_string()]; + }; + let inner = &pattern[open + 1..close]; + if !inner.contains(',') { + return vec![pattern.to_string()]; + } + let prefix = &pattern[..open]; + let suffix = &pattern[close + 1..]; + let mut results = Vec::new(); + for alt in inner.split(',') { + let expanded = format!("{prefix}{alt}{suffix}"); + results.extend(expand_braces(&expanded)); + } + results +} + +fn glob_with_brace_expansion( + io: &F, + args: &[Typval], +) -> ox_eval::Result> { + let Ok(pattern_str) = crate::builtins::input_string_arg(&args[0]) else { + return Ok(None); + }; + let pattern = pattern_str.to_string_lossy(); + if !pattern.contains('{') || !pattern.contains('}') { + return Ok(None); + } + let patterns = expand_braces(&pattern); + if patterns.len() <= 1 { + return Ok(None); + } + let list = args.get(2).is_some_and(Typval::is_truthy); + let mut all_matches = Vec::new(); + for pat in patterns { + collect_glob_matches(io, args, &pat, &mut all_matches); + } + all_matches.sort(); + all_matches.dedup(); + if list { + let items = all_matches + .into_iter() + .map(|s| Typval::String(ox_types::OxStr::from(s.as_str()))) + .collect(); + Ok(Some(Typval::list(items))) + } else { + Ok(Some(Typval::String(ox_types::OxStr::from( + all_matches.join("\n").as_str(), + )))) + } +} + +fn collect_glob_matches( + io: &F, + args: &[Typval], + pat: &str, + all_matches: &mut Vec, +) { + let mut call_args = args.to_vec(); + call_args[0] = Typval::String(ox_types::OxStr::from(pat)); + call_args.truncate(4); + if call_args.len() < 3 { + call_args.resize(3, Typval::Number(0)); + } + call_args[2] = Typval::Number(1); + let Ok(Typval::List(matches)) = crate::fs_builtins::call(io, "glob", &call_args) else { + return; + }; + let Ok(matches) = matches.try_borrow() else { + return; + }; + for item in &matches.items { + if let Typval::String(s) = item { + all_matches.push(s.to_string_lossy().into_owned()); + } + } +} diff --git a/crates/ox-editor/src/builtins/fold.rs b/crates/ox-editor/src/builtins/fold.rs index 12d05744..2af04148 100644 --- a/crates/ox-editor/src/builtins/fold.rs +++ b/crates/ox-editor/src/builtins/fold.rs @@ -19,7 +19,7 @@ use crate::excmd_exec::ExEditorAccess; use ox_eval::EvalError; -use ox_types::{BufHandle, Typval}; +use ox_types::{BufHandle, Typval, WinHandle}; use crate::Editor; use crate::excmd_exec::{EvalHost, buffer_lines}; @@ -35,7 +35,7 @@ pub(crate) fn call( ) -> ox_eval::Result { check_arity(name, args.len())?; host.access.with_ex_editor(|editor| { - // `tv_get_lnum` runs before the range check in every one of the three, so + // `tv_get_lnum` runs before the range check in every one of the four, so // an argument with no numeric value raises its error whatever the folds // hold. let lnum = super::position::current_lnum_arg(editor, &args[0])?; @@ -43,6 +43,7 @@ pub(crate) fn call( "foldclosed" => closed_line(editor, lnum, false), "foldclosedend" => closed_line(editor, lnum, true), "foldlevel" => level(editor, lnum), + "diff_filler" => diff_filler(editor, lnum), _ => unreachable!("fold builtin route and dispatcher disagree"), }; Ok(Typval::Number(value)) @@ -96,6 +97,18 @@ fn closed_line(editor: &mut Editor, lnum: i64, end: bool) -> i64 { i64::try_from(result).unwrap_or(i64::MAX) } +/// `f_diff_filler` (`diff.c:4359-4361`): the filler lines above `{lnum}` in +/// the current window, through the tabpage's computed diff blocks +/// (`diff_check_fill`, `diff.c:2414`). A window outside diff mode, filler +/// display disabled, or a line outside `1..=line_count + 1` all answer 0 — +/// never a negative number. +fn diff_filler(editor: &mut Editor, lnum: i64) -> i64 { + let Some(window) = editor.current_window() else { + return 0; + }; + crate::diffmode::diff_filler_for(editor, window, lnum) +} + /// `f_foldlevel` (`fold.c:3206-3212`): the nesting level at `lnum`, and `0` /// when no fold covers it or it lies outside the buffer. fn level(editor: &mut Editor, lnum: i64) -> i64 { @@ -116,18 +129,30 @@ fn level(editor: &mut Editor, lnum: i64) -> i64 { /// buffer's line count. `None` reproduces `hasAnyFolding` being false: no /// window, no buffer, or `'foldenable'` off. /// -/// Named gaps, most of them upstream behaviour this port has no seam for: -/// `'foldmethod'` of `expr` or `syntax` needs a host computation -/// [`Folds::refresh`] can only request, so those methods answer from whatever -/// a host last applied — nothing, by default. `diff` is computed by the host -/// module [`crate::diffmode`] from the tabpage's diff blocks. -/// `'foldnestmax'` does not cap -/// computed levels and `'foldignore'` does not exclude lines, so an indent -/// fold deeper than `'foldnestmax'` or starting at an ignored line is reported -/// where upstream would not report it. `'foldlevel'` does not close computed -/// folds by level either; a computed fold starts closed, which is what -/// `'foldlevel'` at its default of zero produces. +/// Named gaps: `'foldmethod'` of `expr` or `syntax` needs a per-line host +/// evaluation [`Folds::refresh`] can only request, so those methods answer +/// from whatever a host last applied — nothing, by default. `diff` is +/// computed by the host module [`crate::diffmode`] from the tabpage's diff +/// blocks. `'foldlevel'` does not close computed folds by level either; a +/// computed fold starts closed, which is what `'foldlevel'` at its default +/// of zero produces. fn synced_folds(editor: &mut Editor) -> Option<(&Folds, usize)> { + let line_count = refresh_for_window(editor)?; + let buffer = editor.current_buffer()?; + let state = editor.buffer(buffer).ok()?; + Some((&state.folds, line_count)) +} + +/// Brings the current buffer's folds up to date under the current window's +/// fold options, answering the buffer's line count, or `None` when +/// `hasAnyFolding` (`fold.h:23-25`) is false. +/// +/// This is the [`synced_folds`] mutation half, exposed so Ex command handlers +/// (`:foldopen`, `:foldclose`, yank over closed folds) can refresh computed +/// folds before operating on them — upstream's `checkupdate` +/// (`fold.c:1113-1122`) runs on the same paths, and without it every computed +/// method answers `E490: No fold found` from a stale empty cache. +pub(crate) fn refresh_for_window(editor: &mut Editor) -> Option { let window = editor.current_window()?; let buffer = editor.current_buffer()?; if !matches!( @@ -141,6 +166,15 @@ fn synced_folds(editor: &mut Editor) -> Option<(&Folds, usize)> { _ => FoldMethod::Manual, }; let shift_width = effective_shift_width(editor, buffer); + // Upstream defaults (`src/nvim/options.lua`): 'tabstop' 8, 'foldnestmax' + // 20, 'foldminlines' 1. The `max` calls below mirror `get_indent_buf` + // (a zero tab width falls back to 8) and `foldlevelIndent`/`checkSmall` + // (a negative cap or floor reports no deeper/smaller than nothing). + let tab_width = buffer_number(editor, buffer, "tabstop", 8).max(1); + let nest_max = window_number(editor, window, "foldnestmax", 20); + let min_lines = window_number(editor, window, "foldminlines", 1).max(0); + let ignore = window_text(editor, window, "foldignore", "#"); + let markers = window_text(editor, window, "foldmarker", "{{{,}}}"); let lines = buffer_lines(editor, buffer).ok()?; let line_count = lines.len(); // `'foldmethod'` = `diff` delegates to the host: `refresh` can only @@ -156,6 +190,19 @@ fn synced_folds(editor: &mut Editor) -> Option<(&Folds, usize)> { let changedtick = state.changedtick(); state.folds.set_method(method); let _ = state.folds.set_shift_width(shift_width); + if let Ok(tab_width) = usize::try_from(tab_width) { + let _ = state.folds.set_tab_width(tab_width); + } + state.folds.set_fold_ignore(ignore.into_bytes()); + state.folds.set_nest_max(nest_max); + if let Ok(min_lines) = usize::try_from(min_lines) { + state.folds.set_fold_min_lines(min_lines); + } + if let Some((start, end)) = markers.split_once(',') { + let _ = state + .folds + .set_markers(start.as_bytes().to_vec(), end.as_bytes().to_vec()); + } // A host request or a computation error leaves the cached set answering, // which is the closest honest reading of folds this port cannot compute. match state.folds.refresh(changedtick, &lines) { @@ -167,7 +214,31 @@ fn synced_folds(editor: &mut Editor) -> Option<(&Folds, usize)> { } _ => {} } - Some((&state.folds, line_count)) + Some(line_count) +} + +/// Reads a numeric buffer option, answering `default` when it is missing. +fn buffer_number(editor: &Editor, buffer: BufHandle, name: &str, default: i64) -> i64 { + match editor.options().get_buffer(buffer, name) { + Ok(OptionValue::Number(value)) => *value, + _ => default, + } +} + +/// Reads a numeric window option, answering `default` when it is missing. +fn window_number(editor: &Editor, window: WinHandle, name: &str, default: i64) -> i64 { + match editor.options().get_window(window, name) { + Ok(OptionValue::Number(value)) => *value, + _ => default, + } +} + +/// Reads a string window option, answering `default` when it is missing. +fn window_text(editor: &Editor, window: WinHandle, name: &str, default: &str) -> String { + match editor.options().get_window(window, name) { + Ok(OptionValue::String(value)) => value.clone(), + _ => default.to_owned(), + } } /// `get_sw_value_col` (`indent.c:362-366`): `'shiftwidth'`, or `'tabstop'` @@ -453,4 +524,64 @@ mod tests { assert_eq!(error_code("let g:a = foldclosedend(1, 2)"), "E118"); assert_eq!(error_code("let g:a = foldlevel(1, 2)"), "E118"); } + + /// `diff_filler({lnum})` (`diff.c:4359-4361`). + /// + /// Oracle ground truth (headless `nvim -u NONE -S`, left window holding + /// the 7-line side of a 2-line insertion after line 2, right window the + /// 5-line side): every line of the insertion side answers 0, and the + /// other side answers 2 above its line 3 and 0 elsewhere; outside diff + /// mode the answer is always 0. + #[test] + fn diff_filler_reports_insertion_filler() { + let editor = TestEditorAccess::new(editor()); + let mut exec = ExExecutor::new(); + exec.execute_script( + &editor, + "diff_filler.vim", + r#" +call setline(1, ['one','two','three','four','five']) +let g:plain = diff_filler(2) +split | enew +call setline(1, ['one','two','AAA','BBB','three','four','five']) +windo diffthis +let g:here = win_getid() +wincmd w +let g:a_f3 = diff_filler(3) +let g:a_end = diff_filler(line('$') + 1) +let g:a_lines = line('$') +call win_gotoid(g:here) +let g:b_f3 = diff_filler(3) +let g:b_lines = line('$') +"#, + ) + .unwrap(); + let values = ["a_f3", "a_end", "a_lines", "b_f3", "b_lines", "plain"] + .into_iter() + .map(|name| { + match exec + .scope() + .get_scoped(ScopeKind::Global, name.as_bytes(), 0) + .unwrap_or_else(|error| panic!("no g:{name}: {error:?}")) + { + Typval::Number(value) => *value, + other => panic!("expected a Number in g:{name}, got {other:?}"), + } + }) + .collect::>(); + let (a_f3, a_end, a_lines, b_f3, b_lines, plain) = + (values[0], values[1], values[2], values[3], values[4], values[5]); + assert_eq!(plain, 0, "outside diff mode diff_filler answers 0"); + // One window holds 5 lines, the other 7; whichever holds 5 answers + // the 2-line insertion filler above its line 3. + assert!((a_lines, b_lines) == (5, 7) || (a_lines, b_lines) == (7, 5)); + let (five_f3, seven_f3) = if a_lines == 5 { + (a_f3, b_f3) + } else { + (b_f3, a_f3) + }; + assert_eq!(five_f3, 2, "filler above the insertion point"); + assert_eq!(seven_f3, 0, "no filler inside the insertion"); + assert_eq!(a_end, 0, "nothing past the last hunk"); + } } diff --git a/crates/ox-editor/src/builtins/input.rs b/crates/ox-editor/src/builtins/input.rs index 555a54f1..633ca06e 100644 --- a/crates/ox-editor/src/builtins/input.rs +++ b/crates/ox-editor/src/builtins/input.rs @@ -392,7 +392,7 @@ fn call_getchar_builtin( let options = options .try_borrow() .map_err(|_| EvalError::new("E742", 0, "Cannot change value"))?; - for entry in &options.entries { + for entry in options.iter() { match entry.key.as_bytes() { b"number" if name == "getcharstr" => { return Err(EvalError::new( diff --git a/crates/ox-editor/src/builtins/mapping.rs b/crates/ox-editor/src/builtins/mapping.rs index 7ccf5aab..e5dcb989 100644 --- a/crates/ox-editor/src/builtins/mapping.rs +++ b/crates/ox-editor/src/builtins/mapping.rs @@ -1,4 +1,4 @@ -//! Mapping query builtins: `maparg`. +//! Mapping and abbreviation query builtins: `maparg`, `mapcheck`, `hasmapto`. //! //! `f_maparg` is `get_maparg(argvars, rettv, exact = 1)` (`mapping.c:2148-2227`), //! which resolves the left-hand side through `replace_termcodes`, finds the @@ -11,40 +11,41 @@ //! is deliberately absent: `mapblock_fill_dict` adds it only when `compatible` //! is false, which is the `nvim_get_keymap` path and not this one. //! +//! With `abbr` set the same entry points read the abbreviation table instead +//! (`check_map`'s `abbr` leg, `mapping.c:2030-2061`): the trigger replaces the +//! left-hand side, `mode` still reports `map_mode_to_chars`, and `abbr` is 1. +//! [`crate::mapping::Abbreviation`] now carries the mode set, `nowait`, +//! `silent` and original right-hand side those answers need, because the +//! `:abbreviate` family in this module writes through it. +//! //! Named gaps, each one a thing that would have to exist first: //! -//! * **`callback`** needs a Funcref. [`MappingAction::Callback`] is a `u64` -//! host-callback identity, not a callable value, and upstream's key holds -//! the `LuaRef` itself (`mapping.c:2111-2112`). Nothing script-reachable -//! creates one — `:map` cannot — so a mapping with a callback answers the -//! empty dictionary rather than an invented Funcref. //! * **`lhsrawalt`** needs key simplification. Upstream emits it only when //! `replace_termcodes` reported `did_simplify` (`mapping.c:2124-2127`), that //! is when the written left-hand side has a second, simplified byte form //! (`` and ``). This port has no `m_simplified` model and no //! `REPTERM_NO_SIMPLIFY` pass, so it never emits the key — which is also //! what upstream does for every left-hand side that does not simplify. -//! * **`abbr = 1`** needs the abbreviation table to be script-reachable and to -//! carry mapping flags. `:abbreviate` is not an executed Ex command here, so -//! no script can add an entry, and [`crate::mapping::Abbreviation`] records -//! no mode set, `nowait`, `silent` or original right-hand side. The query -//! therefore answers nothing rather than filling a dictionary from data that -//! does not exist. //! //! `sid`, `lnum`, `script` and `replace_keycodes` are *not* gaps. The first two //! come from the script stack, which [`crate::script::Scripts`] tracks; `script` -//! comes from the `