From 1d0358a77e58a10a081137a87711d2500023cf53 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:50:46 +0900 Subject: [PATCH 001/263] fix(api): drop nvim_mcursor and keep scoped Lua bindings alive Commit fc59163 added an nvim_mcursor API function and edited the canonical metadata blob so nvim_get_api_info advertised it. Upstream at the reference commit has no such function: only internal multicursor plumbing exists, in input_cmdatom.c, and the oracle's --api-info is 32701 bytes without it. So just apidiff failed on the one semantic difference the criterion forbids. The blob is restored from the oracle's own --api-info output, api_function_names.rs is regenerated from it, and the handler, its registration, its tests, and the dead Editor::add_mcursor and mcursor_count are removed. The registry and metadata tests now pin 262 functions. The same file carries the scoped Lua dispatch fix: with_scoped_editor_api installs functions that outlive the scope instead of borrowing it, so Lua code that captures vim.fn or a vim.api entry at module load keeps a working binding after the originals are restored. BREAKING CHANGE: the nvim_mcursor RPC method is gone. It was never upstream API and no in-tree consumer called it. --- crates/ox-api/src/api_function_names.rs | 13 --- crates/ox-api/src/global.rs | 56 ------------- crates/ox-api/src/tests.rs | 73 +---------------- crates/ox-editor/src/editor.rs | 59 -------------- crates/ox-rpc/src/api_metadata.msgpack | Bin 32802 -> 32701 bytes crates/ox-rpc/src/metadata.rs | 2 +- crates/oxvim/src/server.rs | 102 +++++++++++++----------- 7 files changed, 61 insertions(+), 244 deletions(-) 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/global.rs b/crates/ox-api/src/global.rs index 4149617e..4cc6f1b9 100644 --- a/crates/ox-api/src/global.rs +++ b/crates/ox-api/src/global.rs @@ -3951,61 +3951,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 +3960,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, diff --git a/crates/ox-api/src/tests.rs b/crates/ox-api/src/tests.rs index a8f551a5..9685f7aa 100644 --- a/crates/ox-api/src/tests.rs +++ b/crates/ox-api/src/tests.rs @@ -285,7 +285,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 +1421,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)), @@ -1731,65 +1731,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>>>); @@ -9759,10 +9700,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 +9820,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] diff --git a/crates/ox-editor/src/editor.rs b/crates/ox-editor/src/editor.rs index ddda86be..c1c6fcb1 100644 --- a/crates/ox-editor/src/editor.rs +++ b/crates/ox-editor/src/editor.rs @@ -1008,65 +1008,6 @@ impl Editor { .ok_or(EditorError::UnknownBuffer(resolved)) } - /// Places a multicursor at `position` in `buffer` (`mc_add`, - /// src/nvim/mcursor.c): the cursor rides the extmark store so it tracks - /// later edits and dies with the buffer, and an existing cursor at the - /// position is left alone rather than duplicated. The namespace is the - /// session-global `nvim.multicursor` id (`mc_ns`): one id shared by - /// every buffer, so a buffer store only `ensure`s it locally. - /// - /// # Errors - /// - /// Returns [`EditorError::NoCurrentTabpage`] or - /// [`EditorError::UnknownBuffer`] like [`Self::buffer`], plus extmark - /// store failures. - /// - /// Returns whether a cursor was placed. - pub fn add_mcursor( - &mut self, - buffer: BufHandle, - position: Position, - namespace: NamespaceId, - ) -> Result { - let state = self.buffer_mut(buffer)?; - state.extmarks.ensure_namespace(namespace)?; - let point = ExtmarkPosition::new(position.lnum.saturating_sub(1), position.col); - if !state - .extmarks - .query(namespace, point, point, Some(1))? - .is_empty() - { - return Ok(false); - } - state - .extmarks - .set(namespace, None, crate::ExtmarkPlacement::new(point))?; - Ok(true) - } - - /// Counts multicursors across every live buffer (`mc_count`, - /// src/nvim/mcursor.c) under the session-global `nvim.multicursor` - /// namespace id. - #[must_use] - pub fn mcursor_count(&self, namespace: NamespaceId) -> usize { - self.buffers() - .iter() - .filter_map(|handle| self.buffer(*handle).ok()) - .filter_map(|state| { - state - .extmarks - .query( - namespace, - ExtmarkPosition::new(0, 0), - ExtmarkPosition::new(usize::MAX, usize::MAX), - None, - ) - .ok() - }) - .map(|marks| marks.len()) - .sum() - } - /// Renames a live buffer and preserves its old name as the alternate buffer. /// /// # Errors diff --git a/crates/ox-rpc/src/api_metadata.msgpack b/crates/ox-rpc/src/api_metadata.msgpack index 6c92553f84f0a8a38f38821e2b9ec7b0e5db58f8..63661c3557484c85053248ddbffe29f28dc36ab2 100644 GIT binary patch delta 5904 zcmaJ^d2G}76`wIloWss#=W-k$aU3VH6FY}P0%;C%aD+RQ5CV?l{E`?P+wqZb1}c=a zTQv%OtJI2?l?v!+T8_3`Hx()bm1s+~hE46(iLRBpigk>Jshd{Xwd}p$WqY9g_x-%@ zyT0%HzVE&73kMclyeB!_Uobo3s2Q%e4K>ue9H)mPK6lvT_l0gq5~q*MYGJ>m;>dFf z$u!6%`Xkrca4{e$f+~p}S7#-9+_xE~PxxG8%xk_0&zRF4WL#m!>57a5{gJ?_F(y3f zAG!?JC9NpHEHLt-7Z#Xcyif;<^ejCm{wgcZ>}1?19QM1%hC+h$k!y`Gupj{yOiJ}| zJxL3{u9w55Y#CDKr5c#;GQgYCT12f2HSE4+p(;-8U}V@i!i1e(kB<@5UIFLAFv{#n z(82Ds0#GE3A*xT*!m|lSadnGb4a3F6N<{05l=9)>5EJH|x&q@#B$Eu4NqQ}Jgmdf> z1X~lXU<@xEKS5?f@5oPARyI5;Q=kq5G$$v(2ALkI-^ujo2~o1)n#_ijNU|QiEPxME zWJq<~X9xXp#@dmwLv4REgbVerFO!CAN6MoOoG<=gP7?;DHRJzi> zG%W_k3Ulo$L|-7LKYXApWb1LZM=p3W$i%s_Ew8SBklM_fO3 zS2n=C>>#dgEA23=Y{QjPg_06AG^$DveWkL3CSL>RR1YB<%&9^bm0-)MM)Vg%+@1{H z$gv`&H&@uN0Nv&Uq-5vSp@kCgFfx#Y`Dq%>xmz@N7hQ6Rq(b!@=Z=eXi@Cxd2F zDN>k%O7Qn~L!dti(H{}%S16%KTL`vIbn1R}6;zbB!kJz<@g#8}ItzEobKr;kY_Rut zrf{@bEJQF~U|xyb( z{LSX6A0btf-wfVziu7u24ezrJ(eDxYdxgD5oeh$7HAp|E!;|BE?$>E0Gf(Pm0^slb zj1O*aHc8}Q*W06{?mnJ`3JeAtZQ|;bp#rl~2_uGPM9N|^UjTRda=?>74*j^eny1LX zyG0J8O_X^*6yux4%%3Z!!q#Kt^0?VZukBf*9(Ee(edL#zF{6v&o+%yf7%AglFDd2= zWinEez+18msU0Rm3^g56OpB5F8>G&6sUbZ<#%ovz$4qM6kz}i&uQZd_-HDW!OUuDq z+JdWGb1{0>02$^aL_a~qy}3q2zcZJ@88dmdsEoKQaI=F>^IVw`<`^R!DAOQnsmKLw zc`6()Z-$GDNXt~Y0n=s!uD-z4@$z<-%fQRXWv4cId_(?8V$6k$6(p*IgB8Wdw!l&% zu<_l-WhoXZT(SCtMT-Y*g3A^P-%4vCJa5l|DQg~-SWOvRKAw(voUU-#Hl` zoK?+;?p9HkRl&zqIz*E;BNW*-AC0U+(Z=dRwvhF&k&`%59!Cn9yjZP8bfa1?ptx#0%rHYcm|a2xzRcck z&o(Qx)MVvzKD@;G;PC|_LZiKmKC9qh4V|T_wg@iNJR;+Gf8~QiP7P?C8*v_BI~TH4 z)TuErR2wab_97zj{yGxBT2}(+8_aO2juK;$gVIP1G6!|qfyKG0Lmr`HU&aTff};Z| zu*X43_7U>7;$?l{AW2I-JsB0G*Hg-c5ph@aemw=4tr%*~#h2OR#CK^LhS-aXi9@k*NHNs58ZlrK;nLT)1 zP6S*bagd51#uvH7BSXa{PqY&MEP#8HtC;;TORU^pMm>Jr*L?(j6c(_F;n@|Q9N zPswl5!p-JtRI#;14Gk^ExS@ug2j=jYD>~a!DjaH|cUa!4NqZ^a3gWC3W`d!g!C6^% zoHX6?|437X+uG`gn{UX7E8uZ@e8bUOJl{$_h1v`tZ42P)-8LQj%4XY&5alk_z~9=e zaqrFR35A^zPxQSZ1G1i9%GHNkOY@RWwg!W)UDJ@#u4a|pT1pT2(RMSK+FfzV$n}_c zi_1`ML~H3#gQSB>&odp=bZMw6i@Q7;>#N0lWSdV%SzP|R{bOS;Uv%uE_gC6k7@yP= zu4rMRsLze0!c?ac4S(6m^|ZG-D-dn!DuI+PDpl9IxE}IkR|BG!Woj(e>u_~+nI7zG zw6JFx6^5@6Wh~dijb&MJWAmiP$9@}dr01Q#f_yb7@%-{4wzKYCPPO^oa+0?Ij-QCU zEA+UxGVWU>Xc}caBcnoTBV`u8v*fMR#h~3QIrX}39pA=oBITKttM79!zHVG$oziue zpc^N;i({PnPdEEqV;tl5ThOY2%YTZ^3Q@lUW( zhLoFYZ8$M(g7?;H5dD0ejh{KTz)#oFq?5Ux#z`wbDu&kMAcx;X=Xzy2KPKW8VySb6 z;F0xumUC`B$#rfJIOU-AlBKti!u~$c_PGr-`c?E)V&XXqJrS$=gEK zX^J$Xy~nrM#QXAY!GRUN-%^7$+78VF$wGISfyw?Vn!j=T)BbAO7RIU>Sh>~4@^jIB zerp+wGg{v7Ozi#dZS6ua*k<9U0h(n0wGFxOeSf%3hv+e99X5FnuD*6!SbBe5D*V|= zv0pRbVD&W*WFUHDpq6jkh5Yy6b@;@A!%9vT%e60)z81S&wXBx(`cx>x&jfy5MD9KH zQEu)_vv{xqx#^>Dbdapm>feJHZ#)IY0PYNu&heofxaQUoRcNa-@JqKtOk+-&dPo$E z25)?bJwr5PA0vTYpWGu{a0`Rq1TS0e7OB^7F*Qc z>db{_&?WAJc45>mI2k>0rAHv@QQ<5i{fY(@VxQ=CVNZC}liO{A3f|iEy6C5*mGg4h5^@MYrGQW89+Kpz-Ai-YKBTXDy3!=*3VZ6kvQq56~wC z@RbRoQh3`}Du5*TI=ZtEjQ)CICk@kV5CUJ_`KfeNk|IT(rFK$>2_5BLNNshXs%bUj;?Y z31AJa5Oxw^f2ceP&d2UZ!|v#gFFYlHMEGae5(VbSL=>Egd>#cyCYD6OhZE)~NSG9( znh1_bQFRhrm^>-4rGaPXZULmgt)1fnz}(+8Vv7s$nSehK35+qmh;!64#5^$8-|ec6 zYN+2Wo=yrc?4F74*!DC;!T6rlQE+vScpxdH?v1&h_PsSxw*7m>1d_t+UNKfu_%xQS zV4qfyNrcvYk43@tePU8zu=j}y62UxWjk0Z>60a^1o|sxH?4-e;rYx_?_p2oT1p~U3 Al>h($ delta 5846 zcmaJ_dr+HI7GGK*p(z1ELU@HF1PYXp@GPa!QlO=jXDP*EX&XWakTwZ{1ndKAU8cIe zYU@wC&e~da`>-lL*PUIhThLj@adljrac6z3vg5ewxU22V?$pPsd(XZ3_=4{4f8Xz% zbI(1md(KH-_&(~P!%L3sOgTSL;jVO5x-Sfl`&}W=h(GvVl;Q|F6SMGvI{x^K`uKz~ z>d5iS^~c{ZM=^R-iSdlz>2p8npYZq`18%RwIUX8u`3BDU+@ax-ffuE-8cape33Gv! zG{L?^iMKd~>A>nGIgICAZGtgTgQH7U&>d^=MpO(wUScAaMvKF1QgKY9#HOXB=;(?J z%xqHNy`|+iwbZa6qe5G>4oT5OnPaEE%Sk|Wj16~giN!n71~WJJX>M-+_@HCR9ddX* zes?fz@JmN9tJmTUWh}nk(u+G{H25-_EVvYNA0CKd&aEd`4KQ{KdsCMWE5@Ao+a zvZOGv6TeVWD1@7_m{ut97iBW?>nt$Gi6EsjYCPuS@^D{V7S|}*6OX=SF;FfeYB;=# zb6ue^kADb*%Yz;t|0CQsk=Li$`zuU8IBNv0M>8}-<$ zj?X@`!Q%>X>BqTrz&RFlI~G-Dy?U*lOFYaaB6)Y!>F~UI6%MN@4dKF6bKz&XFe`N& zSL+y4jy}bid8#fzQ^HCf7ZE?I=|3do5hbyB8CUWYDZ#Ns?Gkz+Jbo7ZG|>`u6UYfu zQZYVEEMRE1%Ysju)Un~jbS9N?5goEBQjx~$`(jctqc}B{R4Ay^m>8PVcrm|1hsQOf zu&R4M{*kQ1y5v<1&FXXE)V5)%wu+&7Z9SgQmawmq%naC9#o>T16BZra(vfDs=~N{? z)vdzMbw!La$0>2}>CUI$Y2Zr4|^ud419>^&Or z`_x{vr4pqhtqbNf8~d6|+kjv4uj-yw97|UqC7tMX#$wE{u-wa0=gK9^b={>VS0f%T(7(*=^j&pYoXD=l_H1%xbWb|$y-E~XO!!kaT`DL#Oj|T)wUn}|qKf&a#fBLR zZE;Tx8U-7vLAh!vPxpZPNCjrGPs_|ZK zHsc&!(FyMg`Vw`Yh1a*`VppDq6&yX4d2I@w%v*^-9=!mm+rQ3i`7S?2J>m3@yTkAG z#UtX8#lxSWz-{>}P@7M8zn!0hr}OExmt#ifi=o}iZt z$d=BPU9hjDFF_Z*?JTFzSx2D)%L@hHgbRfhCauSzLKW9t%4797c*Y?}6I9YU|FG&5|^?WIQa zE<%r$vYMhKX6!D4qfDdVS5Eseze~#wnJaZTU1mz-)JM1>9)Dn58jj44_sed?Oc@QJ zFLrn1?Q$*p%E`@=OzLk|d|sXr)^o7Jgq8|IvlVunte_x?j#Z2+6_l=XL0VVn zR+~|Au6}%Q&^?BzHQaH&EE!lB^vjv7TzsAjZq2cQ)nYI<8zIV1xKXK zDx4Ww9q(R8<>j=QqG>z8=j-_TQd=Ckno-JJBmR(kUqq!oSX*l1spo^+;T>@f{8;FV z+C}>bDe{dBL^xBjzB-F*d1B#NF52vvrMIr{fp0wv_+51^k+QT}|4^1?9=FydqqUBD z&rDqa_t#PWJL}6(QctdZR$mmUgjbJi*y)cbA0~o7*3-+Prn9wyJ^cm>l0d0QWIOg~ z1Mxp?aALNB1_w1BTH#tY@b}49`VTiG<0MC72P?qU4P=T~W@2&6MoVOp-TU0GQn@ba z?Y_Iwfa#6&=%{bxHX2iTq%QDCx!K^5#YyS@#zJgtBp+XGY{S_`))1(ewrr|G>L$5; zufnaH$VM8PrA@iGx{1c%>1HGDZPK8pNg(UO1A4THy3y#K46NF0!M7aItDr@6$7Yfy zO; zwmdx5Mo*Yr-`Jjmgmx)x_TD&5w`XBbyKpWY$_@)&Yo`l0ckG6>!@wpY{VKS;H4QV} z3Ow9F3+3V;Ulyph&}t-Y7T<31;TcX5`#$?=$JV{QowOVYU8#JD?Sv_jF=e57DE{W~{qbgFAX?VV2P6 zJ!BV^c~!OwyLV{Nd9C2flqY{gN!URV6Z;-*fNF02=nlF^Q1kE+m+7$P;PGAqw)fJ8 zNGtJcy>z3X$1Bcl_xJ}!u0#D!ib|LY^)4l*ck(+1O?P!iL%)lDa?lcS(PqHCJJm=? zkB#N;a2m&LIvn1mp#_yDel6bFWo6Q0%CouKp+oO(I{(FP51!jin?3OkRtVm;4wAm& zu;Y0Lg>#zt~SENp(lHV001XibD*{n_b}(@!?2v z%YKb3Ym)K3i|U_RO3|KpULi(T3jXaP8^|phh>s3Xlo?W<-{$NOIEUO=?yh7cDHU|( zUQQ8a$Kz7B5^uYCst0qhevrOM9X0$DgQQM$O%*SSuLkMI0>v9cMqD$L&yZ-7dL#^M zMGl`rYhxN-H6$%z+_1q5A;=k~8zeXAG2Ox`qTd<$E#E}Q=&;WtpU~lbk0{nUH0+^= z6RGK$Jye#0qE<)|$lFWB7^Wg&mjXxk+SM1>((Ima`$NI^ezI{2vEGeEXM-NU%e{1p z2mR6Uz<@L4cKF=>@kiK?diLj2$UPQ3CJAAmH&eL)PI@zBaLJo4gTF>{qJ1?tNN4yh zXZqb1Q7^D_% zpAU`)18)C-B)-Vy3V(s@*q}d82KOv*6jONFUzseFMhm59UBd&OF^BnQAW-M zU1K9&FGfdn>a(tqaeqi+Pv*#=ZBhyfJ9pz`gxJMNqb&B} bool { | "nvim_input_mouse" | "nvim_feedkeys" | "nvim_paste" - | "nvim_mcursor" | "nvim_put" | "nvim_ui_set_option" ) @@ -5740,13 +5739,9 @@ fn dispatch_scoped_nvim_command( ) -> Result<(), ApiError> { let command = std::str::from_utf8(command.as_bytes()) .map_err(|_| ApiError::validation("Command must be valid UTF-8"))?; - execute_scoped_ex( - session, - context, - ex, - nested_ex, - |executor| executor.execute_line(session, command), - ) + execute_scoped_ex(session, context, ex, nested_ex, |executor| { + executor.execute_line(session, command) + }) .map(|_| ()) } @@ -5780,13 +5775,9 @@ fn dispatch_scoped_nvim_exec2( let source = std::str::from_utf8(source.as_bytes()) .map_err(|_| ApiError::validation("Command must be valid UTF-8"))?; let message_start = session.with_editor(|editor| editor.messages().len()); - let result = execute_scoped_ex( - session, - context, - ex, - nested_ex, - |executor| executor.execute_script(session, "", source), - ); + let result = execute_scoped_ex(session, context, ex, nested_ex, |executor| { + executor.execute_script(session, "", source) + }); let captured = result.map(|_| { session.with_editor(|editor| { editor.messages()[message_start..] @@ -5863,9 +5854,6 @@ fn with_scoped_editor_api( // exec_lua harness never observe an mlua `WrappedFailure` userdata. let shim: Function = error_shim(lua).map_err(|error| LuaExecError::Runtime(error.to_string()))?; - // Shared by reference so every scope closure copies the borrow instead - // of the first closure moving the shim away from the rest. - let shim = &shim; let context = lua .app_data_ref::() .map(|context| context.clone()) @@ -5877,19 +5865,22 @@ fn with_scoped_editor_api( // Every scoped Lua chunk/callback is an in-process API call, even when // the surrounding request arrived over RPC. let _caller = session.enter_internal_call(); - // The caller's real session drives every scoped binding: scope closures - // accept the non-'static `&ApiSession` borrow, and no throwaway session - // is ever constructed here. - let result = lua.scope(|scope| { + // The caller's real session drives every scoped binding through the + // owned dispatch context: every installed function is `'static`, so Lua + // code that captures `vim.fn`, `vim.call`, or a `vim.api` entry during + // the scoped window (a module top level's `local fn = vim.fn`) keeps a + // working binding after the originals are restored. + let result = (|| -> mlua::Result> { + let getvar_ctx = context.clone(); vim.set( "_getvar", - shim.call::(scope.create_function_mut( + shim.call::(lua.create_function( move |lua, (scope, handle, name): (mlua::LuaString, i64, mlua::LuaString)| { let Ok(scope) = parse_variable_scope(&scope) else { return scoped_failure(lua, "unknown variable scope"); }; let name = OxStr(name.as_bytes().to_vec()); - session.with_editor(|editor| { + getvar_ctx.session().with_editor(|editor| { let values = match variables(editor, scope, handle) { Ok(values) => values, Err(error) => return scoped_failure(lua, error), @@ -5905,9 +5896,10 @@ fn with_scoped_editor_api( }, )?)?, )?; + let setvar_ctx = context.clone(); vim.set( "_setvar", - shim.call::(scope.create_function_mut( + shim.call::(lua.create_function( move |lua, (scope, handle, name, value): ( mlua::LuaString, @@ -5936,7 +5928,7 @@ fn with_scoped_editor_api( Err(error) => return scoped_failure(lua, error), } }; - session.with_editor_mut(|editor| { + setvar_ctx.session().with_editor_mut(|editor| { let variables = match variables_mut(editor, scope, handle) { Ok(variables) => variables, Err(error) => return scoped_failure(lua, error), @@ -5959,13 +5951,14 @@ fn with_scoped_editor_api( // Ex code can never be running inside a fast callback. let call_ex = ex.clone(); let call_nested = nested_ex.clone(); + let call_ctx = context.clone(); vim.set( "call", - shim.call::(scope.create_function_mut( + shim.call::(lua.create_function( move |lua, (name, args): (mlua::LuaString, Variadic)| { dispatch_scoped_builtin( lua, - session, + call_ctx.session(), &call_ex, &call_nested, &name.as_bytes(), @@ -5978,16 +5971,26 @@ fn with_scoped_editor_api( let fn_nested = nested_ex.clone(); let fn_table = lua.create_table()?; let fn_metatable = lua.create_table()?; + let fn_shim = shim.clone(); + let fn_ctx = context.clone(); fn_metatable.set( "__index", - scope.create_function_mut(move |_lua, (_table, name): (Table, mlua::LuaString)| { + lua.create_function(move |lua, (_table, name): (Table, mlua::LuaString)| { let ex = fn_ex.clone(); let nested_ex = fn_nested.clone(); let name = name.as_bytes().to_vec(); - let native = scope.create_function_mut(move |lua, args: Variadic| { - dispatch_scoped_builtin(lua, session, &ex, &nested_ex, &name, args.as_slice()) + let native_ctx = fn_ctx.clone(); + let native = lua.create_function(move |lua, args: Variadic| { + dispatch_scoped_builtin( + lua, + native_ctx.session(), + &ex, + &nested_ex, + &name, + args.as_slice(), + ) })?; - shim.call::(native) + fn_shim.call::(native) })?, )?; fn_table.set_metatable(Some(fn_metatable))?; @@ -5999,7 +6002,7 @@ fn with_scoped_editor_api( let command_context = context.clone(); api.set( metadata.name, - shim.call::(scope.create_function_mut( + shim.call::(lua.create_function( move |lua, args: Variadic| { let mut converted = Vec::with_capacity(args.len()); for value in args.iter() { @@ -6024,7 +6027,7 @@ fn with_scoped_editor_api( ); }; if let Err(error) = dispatch_scoped_nvim_command( - session, + command_context.session(), &command_context, &command_ex, &command_nested, @@ -6048,7 +6051,7 @@ fn with_scoped_editor_api( let params = metadata.params; api.set( metadata.name, - shim.call::(scope.create_function_mut( + shim.call::(lua.create_function( move |lua, args: Variadic| { let mut converted = Vec::with_capacity(args.len()); for value in args.iter() { @@ -6073,7 +6076,7 @@ fn with_scoped_editor_api( ); }; let result = match dispatch_scoped_nvim_exec2( - session, + exec_context.session(), &exec_context, &exec_ex, &exec_nested, @@ -6098,9 +6101,10 @@ fn with_scoped_editor_api( if metadata.name == "nvim_cmd" { let cmd_ex = ex.clone(); let nested_ex = nested_ex.clone(); + let cmd_ctx = context.clone(); api.set( metadata.name, - shim.call::(scope.create_function_mut( + shim.call::(lua.create_function( move |lua, args: Variadic| { let mut converted = Vec::with_capacity(args.len()); for value in args.iter() { @@ -6114,7 +6118,11 @@ fn with_scoped_editor_api( Err(error) => return scoped_failure_multi(lua, error), }; let result = match dispatch_scoped_nvim_cmd( - session, &cmd_ex, &nested_ex, cmd, opts, + cmd_ctx.session(), + &cmd_ex, + &nested_ex, + cmd, + opts, ) { Ok(result) => result, Err(error) => return scoped_failure_multi(lua, error), @@ -6134,9 +6142,10 @@ fn with_scoped_editor_api( // it for the server's redraw pass. A well-formed call keeps // the level-15 success even with no eligible UI; a malformed // one fails with the generated dispatcher's validation error. + let ui_send_ctx = context.clone(); api.set( metadata.name, - shim.call::(scope.create_function_mut( + shim.call::(lua.create_function( move |lua, args: Variadic| { let mut converted = Vec::with_capacity(args.len()); for value in args.iter() { @@ -6145,7 +6154,7 @@ fn with_scoped_editor_api( Err(error) => return scoped_failure_multi(lua, error), } } - match queue_ui_send(session, &converted) { + match queue_ui_send(ui_send_ctx.session(), &converted) { Ok(_) => Ok(MultiValue::new()), Err(error) => scoped_failure_multi(lua, error), } @@ -6157,9 +6166,10 @@ fn with_scoped_editor_api( let textlock = metadata.textlock; let context = context.clone(); let params = metadata.params; + let name = metadata.name; api.set( metadata.name, - shim.call::(scope.create_function_mut( + shim.call::(lua.create_function( move |lua, args: Variadic| { if textlock && let Err(error) = context.ensure_textlock_allows() @@ -6180,12 +6190,12 @@ fn with_scoped_editor_api( } converted.push(Object::Dict(Dict(Vec::new()))); } - let result = if metadata.name == "nvim_echo" { + let result = if name == "nvim_echo" { // `nvim_echo` brackets the frozen handler so the // returned message-id reaches the editor sink. - dispatch_echo(session, dispatch, &converted) + dispatch_echo(context.session(), dispatch, &converted) } else { - dispatch(session, &converted) + dispatch(context.session(), &converted) }; let result = match result { Ok(result) => result, @@ -6196,7 +6206,7 @@ fn with_scoped_editor_api( // Object::Array result is the multi-value marker and // expands; every other function keeps single-value // returns untouched. - if matches!(metadata.name, "nvim_buf_call" | "nvim_win_call") { + if matches!(name, "nvim_buf_call" | "nvim_win_call") { let items: &[Object] = match &result { Object::Array(items) => items, _ => std::slice::from_ref(&result), @@ -6229,7 +6239,7 @@ fn with_scoped_editor_api( )?; } Ok(run()) - }); + })(); let run_result = match result { Ok(run_result) => run_result, Err(error) => Err(LuaExecError::Runtime(error.to_string())), From 99a171999724a2374f262a5509f8b07f23f61f41 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:50:54 +0900 Subject: [PATCH 002/263] fix(regex): make an unclosed bracket literal and carry typed error codes The collection parser treated every `[` as an opener and raised E54 when no `]` followed, while upstream decides with a pre-scan and otherwise falls through to a literal `[`. A pattern such as `foo[` now compiles, and the strict entry point still reports E769 for vim.regex, which is the only caller that turns a lone bracket into an error. The parser gains a close scan that mirrors skip_anyof: a leading `^`, `]`, or `-` is a member, a `\` escape consumes two bytes, a POSIX class is one unit, and a nested `[` after `\_.` no longer swallows the opener. Reversed ranges report E944 through CompileError::code(), and the display no longer embeds a code, so callers can render `E54: Unmatched \(` without a doubled prefix. Covered by eleven behaviour tests that pin the oracle matrix, plus the strict-mode and range-error cases. --- crates/ox-regex/src/lib.rs | 71 ++++++++++- crates/ox-regex/src/parser.rs | 201 ++++++++++++++++++++++++++---- crates/ox-regex/tests/behavior.rs | 105 +++++++++++++++- 3 files changed, 345 insertions(+), 32 deletions(-) diff --git a/crates/ox-regex/src/lib.rs b/crates/ox-regex/src/lib.rs index 85b3fef9..80096d49 100644 --- a/crates/ox-regex/src/lib.rs +++ b/crates/ox-regex/src/lib.rs @@ -14,7 +14,7 @@ mod parser; use std::collections::BTreeMap; use std::ops::RangeInclusive; -use parser::Parser; +use parser::{Parsed, Parser}; use thiserror::Error; const DEFAULT_STEP_LIMIT: usize = 1_000_000; @@ -84,7 +84,9 @@ impl Prog { #[derive(Debug, Error, Eq, PartialEq)] pub enum CompileError { /// The grammar is malformed at the given byte offset. - #[error("regex syntax error at byte {offset}: {message}")] + /// + /// Upstream reports these failures with the generic `E54` code. + #[error("{message}")] Syntax { /// Pattern byte offset at which parsing failed. offset: usize, @@ -102,6 +104,39 @@ pub enum CompileError { /// The `\%#=` engine prefix contains a value other than zero, one, or two. #[error("invalid engine selector; expected \\%#=0, \\%#=1, or \\%#=2")] InvalidEngine, + /// A character range runs backwards (`[z-a]`). + /// + /// Upstream reports the dedicated `E944` code for this failure instead of + /// the generic `E54`. See [`CompileError::code`]. + #[error("Reverse range in character class")] + ReversedRange, + /// A `[` with no matching `]` while compiling strict patterns. + /// + /// Upstream only rejects this when `RE_STRICT` is in effect + /// (`vim.regex()`, spell files, `'indentexpr'`); everywhere else the + /// lone `[` is a literal character. See [`CompileError::code`]. + /// + /// The `%s` in the message is literal upstream text (regexp.c passes a + /// non-string argument to the `%s` slot); it is preserved verbatim. + #[error("Missing ] after %s[")] + MissingBracket, +} + +impl CompileError { + /// Vim error code upstream reports for this failure. + /// + /// Most compile failures surface as the generic `E54` pattern error; + /// reversed ranges and strict missing brackets carry the dedicated + /// `E944` and `E769` codes. Callers that render the error with its + /// upstream code should use this instead of hard-coding `E54`. + #[must_use] + pub const fn code(&self) -> &'static str { + match self { + Self::ReversedRange => "E944", + Self::MissingBracket => "E769", + Self::Syntax { .. } | Self::InvalidEscape { .. } | Self::InvalidEngine => "E54", + } + } } /// Bounded matcher failure. @@ -288,11 +323,35 @@ impl From<&str> for Text { /// /// Returns [`CompileError::InvalidEngine`] for a malformed `\%#=` engine /// selector and the parser's [`CompileError`] (invalid escape, unterminated -/// group or collection, backref before a closed group, …) for a -/// syntactically invalid pattern. +/// group, backref before a closed group, …) for a syntactically invalid +/// pattern. An unclosed `[` collection is a literal `[`, matching Vim's +/// non-strict compilation. pub fn compile(pattern: &str, magic: Magic) -> Result { let (selection, body) = parse_engine_selector(pattern)?; let parsed = Parser::new(body, magic).parse()?; + Ok(finish(selection, parsed)) +} + +/// Parses a Vim pattern like [`compile`], but rejects a `[` collection with +/// no matching `]`. +/// +/// Upstream compiles with `RE_STRICT` for `vim.regex()`, spell files, and +/// `'indentexpr'`; everything else (search, `:substitute`, `=~`) compiles +/// non-strict and treats an unclosed `[` as a literal bracket. This entry +/// point mirrors the strict mode so strict consumers can keep upstream's +/// `E769` diagnostic instead of silently accepting the pattern. +/// +/// # Errors +/// +/// Returns the same errors as [`compile`], plus +/// [`CompileError::MissingBracket`] for an unclosed `[` collection. +pub fn compile_strict(pattern: &str, magic: Magic) -> Result { + let (selection, body) = parse_engine_selector(pattern)?; + let parsed = Parser::new_strict(body, magic).parse()?; + Ok(finish(selection, parsed)) +} + +fn finish(selection: Option, parsed: Parsed) -> Prog { let complex = parsed.features.backref || parsed.features.lookbehind || parsed.features.complex_repeat; let engine = match selection { @@ -300,7 +359,7 @@ pub fn compile(pattern: &str, magic: Magic) -> Result { None if complex => Engine::Backtracking, None => Engine::Nfa, }; - Ok(Prog { + Prog { expr: parsed.expr, capture_count: parsed.captures, ignore_case: parsed.ignore_case, @@ -308,7 +367,7 @@ pub fn compile(pattern: &str, magic: Magic) -> Result { has_backref: parsed.features.backref, step_limit: DEFAULT_STEP_LIMIT, depth_limit: DEFAULT_DEPTH_LIMIT, - }) + } } /// Searches the complete input, suppressing a bounded-execution error as no match. diff --git a/crates/ox-regex/src/parser.rs b/crates/ox-regex/src/parser.rs index cc85c9ca..26aa3185 100644 --- a/crates/ox-regex/src/parser.rs +++ b/crates/ox-regex/src/parser.rs @@ -134,6 +134,11 @@ pub(crate) struct Parser<'a> { pattern: &'a str, pos: usize, mode: Magic, + /// Mirror of upstream `RE_STRICT`: an unclosed `[` collection is an + /// error instead of a literal `[`. Only strict consumers (`vim.regex()`, + /// spell files, `'indentexpr'`) set this; the editor compiles + /// non-strict and treats `[` without a matching `]` as plain text. + strict: bool, captures: usize, closed_captures: usize, ignore_case: bool, @@ -146,6 +151,7 @@ impl<'a> Parser<'a> { pattern, pos: 0, mode, + strict: false, captures: 0, closed_captures: 0, ignore_case: false, @@ -153,6 +159,15 @@ impl<'a> Parser<'a> { } } + /// Non-strict [`Parser::new`] plus upstream `RE_STRICT` handling: an + /// unclosed `[` collection fails to compile with `E769` instead of + /// becoming a literal `[`. + pub(crate) fn new_strict(pattern: &'a str, mode: Magic) -> Self { + let mut parser = Self::new(pattern, mode); + parser.strict = true; + parser + } + pub(crate) fn parse(mut self) -> Result { let expr = self.parse_pattern(None)?; if self.pos != self.pattern.len() { @@ -297,7 +312,14 @@ impl<'a> Parser<'a> { } let index = self.captures; let expr = self.parse_pattern(Some(Terminator::Group))?; - self.expect_group_close("unclosed capture group")?; + // regexp.c `e_unmatched_str_open` interpolates the opener: `\(` in + // magic modes, bare `(` in very magic mode. + let unmatched = if self.mode == Magic::VeryMagic { + "Unmatched (" + } else { + "Unmatched \\(" + }; + self.expect_group_close(unmatched)?; self.closed_captures = self.closed_captures.max(index); return Ok(Expr::Group { index: Some(index), @@ -354,8 +376,8 @@ impl<'a> Parser<'a> { return Ok(Expr::Any { newline: true }); } if self.starts_with("\\_[") { - self.pos += 2; - return self.parse_collection(true); + self.pos += 3; + return self.parse_multiline_collection(); } if self.starts_with("\\_") { self.pos += 2; @@ -374,7 +396,7 @@ impl<'a> Parser<'a> { } if self.at_collection_open() { self.consume_collection_open(); - return self.parse_collection(false); + return self.parse_collection(); } if self.at_dot() { self.consume_dot(); @@ -496,38 +518,51 @@ impl<'a> Parser<'a> { .ok_or_else(|| self.error("numeric character atom is not a Unicode scalar")) } - fn parse_collection(&mut self, include_newline: bool) -> Result { - let negated = self.consume_str("^"); + /// Parses a `[...]` collection (regexp.c `nfa_regatom` `collection:`). + fn parse_collection(&mut self) -> Result { + self.parse_collection_impl(false) + } + + /// Parses a `\_[...]` collection, which additionally matches a line break. + /// An unclosed `\_[` stays a literal `[` with no newline attached. + fn parse_multiline_collection(&mut self) -> Result { + self.parse_collection_impl(true) + } + + fn parse_collection_impl(&mut self, include_newline: bool) -> Result { + let Some(end) = self.skip_collection_end(self.pos) else { + if self.strict { + return Err(CompileError::MissingBracket); + } + return Ok(Expr::Literal('[')); + }; let mut items = Vec::new(); + let negated = self.consume_str("^"); if self.consume_str("]") { items.push(ClassItem::Char(']')); } - while !self.at_end() && !self.starts_with("]") { - if self.starts_with("[:") { - let class_start = self.pos + 2; - if let Some(relative_end) = self.pattern[class_start..].find(":]") { - let name = &self.pattern[class_start..class_start + relative_end]; - let kind = posix_class(name) - .ok_or_else(|| self.error("unknown POSIX character class"))?; - self.pos = class_start + relative_end + 2; - items.push(ClassItem::Kind(kind)); - continue; - } + while self.pos < end { + if let Some(kind) = self.take_posix_kind(end)? { + items.push(ClassItem::Kind(kind)); + continue; } let first = self.parse_collection_char()?; - if self.consume_str("-") && !self.starts_with("]") { - let last = self.parse_collection_char()?; - if first > last { - return Err(self.error("reversed character range")); + if self.consume_str("-") { + if self.starts_with("]") { + items.push(ClassItem::Char(first)); + items.push(ClassItem::Char('-')); + } else { + let last = self.parse_collection_char()?; + if first > last { + return Err(CompileError::ReversedRange); + } + items.push(ClassItem::Range(first, last)); } - items.push(ClassItem::Range(first, last)); } else { items.push(ClassItem::Char(first)); } } - if !self.consume_str("]") { - return Err(self.error("unclosed character collection")); - } + self.pos = end + 1; Ok(Expr::Class(CharClass { negated, include_newline, @@ -535,6 +570,26 @@ impl<'a> Parser<'a> { })) } + /// Parses a `[:name:]` item when the body starts with one before `end`. + fn take_posix_kind(&mut self, end: usize) -> Result, CompileError> { + if !self.starts_with("[:") { + return Ok(None); + } + let class_start = self.pos + 2; + if class_start > end { + return Ok(None); + } + let search_end = (end + 1).min(self.pattern.len()); + let Some(relative_end) = self.pattern[class_start..search_end].find(":]") else { + return Ok(None); + }; + let name = &self.pattern[class_start..class_start + relative_end]; + let kind = + posix_class(name).ok_or_else(|| self.error("unknown POSIX character class"))?; + self.pos = class_start + relative_end + 2; + Ok(Some(kind)) + } + fn parse_collection_char(&mut self) -> Result { if self.consume_str("\\") { let escaped = self @@ -553,6 +608,97 @@ impl<'a> Parser<'a> { .ok_or_else(|| self.error("missing collection character")) } + /// Ports `skip_anyof()` (regexp.c:710). + fn skip_collection_end(&self, mut pos: usize) -> Option { + let bytes = self.pattern.as_bytes(); + if bytes.get(pos) == Some(&b'^') { + pos += 1; + } + if matches!(bytes.get(pos), Some(b']' | b'-')) { + pos += 1; + } + while pos < bytes.len() && bytes[pos] != b']' { + let Some(ch) = self.pattern.get(pos..).and_then(|rest| rest.chars().next()) else { + break; + }; + if ch.len_utf8() > 1 { + pos += ch.len_utf8(); + continue; + } + match ch { + '-' => { + pos += 1; + if pos < bytes.len() && bytes[pos] != b']' { + pos += self + .pattern + .get(pos..) + .and_then(|rest| rest.chars().next()) + .map_or(1, char::len_utf8); + } + } + '\\' => { + let skips_two = matches!( + bytes.get(pos + 1), + Some(b']' | b'^' | b'-' | b'n' | b'\\' | b'r' | b't' | b'e' | b'b' | b'd' + | b'o' | b'x' | b'u' | b'U') + ); + pos += if skips_two { 2 } else { 1 }; + } + '[' => { + let next = self + .class_name_end(pos) + .or_else(|| self.bracketed_class_end(pos, b'=')) + .or_else(|| self.bracketed_class_end(pos, b'.')); + if let Some(next) = next { + pos = next; + } else if pos + 1 < bytes.len() { + pos += 1; + } else { + break; + } + } + _ => { + pos += 1; + } + } + } + (pos < bytes.len() && bytes[pos] == b']').then_some(pos) + } + + /// End offset of the `[:name:]` class itself (regexp.c:459 scan rule). + fn class_name_end(&self, pos: usize) -> Option { + let body = self.pattern.get(pos..)?.strip_prefix("[:")?; + POSIX_CLASS_NAMES + .iter() + .find(|name| { + body.strip_prefix(**name) + .is_some_and(|tail| tail.starts_with(":]")) + }) + .map(|name| pos + "[:".len() + name.len() + ":]".len()) + } + + /// Ports `get_equi_class()` / `get_coll_element()` (regexp.c:662,683): + /// recognizes `[=c=]` / `[.c.]` at `pos`, returning the single character + /// and the index just past the closing `]`. + fn take_bracketed_class(&self, pos: usize, delimiter: u8) -> Option<(char, usize)> { + let rest = self.pattern.get(pos..)?; + let prefix = if delimiter == b'=' { "[=" } else { "[." }; + let rest = rest.strip_prefix(prefix)?; + let ch = rest.chars().next()?; + let after = pos + prefix.len() + ch.len_utf8(); + let tail = self.pattern.get(after..)?; + let closing = if delimiter == b'=' { "=]" } else { ".]" }; + tail.starts_with(closing) + .then(|| (ch, after + closing.len())) + } + + /// End offset of a `[[=c=]]` / `[[.c.]]` opener for the scan, mirroring + /// the shared `posix` advance rule in `skip_anyof`. + fn bracketed_class_end(&self, pos: usize, delimiter: u8) -> Option { + self.take_bracketed_class(pos, delimiter) + .map(|(_, next)| next) + } + fn parse_multiplier(&mut self) -> Result, bool)>, CompileError> { if self.consume_operator('*') { return Ok(Some((0, None, true))); @@ -898,6 +1044,11 @@ fn class_escape(code: char) -> Option<(ClassKind, bool)> { Some((kind, negated)) } +const POSIX_CLASS_NAMES: &[&str] = &[ + "alnum", "alpha", "backspace", "blank", "cntrl", "digit", "escape", "fname", "graph", "ident", + "keyword", "lower", "print", "punct", "return", "space", "tab", "upper", "xdigit", +]; + fn posix_class(name: &str) -> Option { Some(match name { "alnum" => ClassKind::Alnum, diff --git a/crates/ox-regex/tests/behavior.rs b/crates/ox-regex/tests/behavior.rs index 52bcac9f..0835f4f3 100644 --- a/crates/ox-regex/tests/behavior.rs +++ b/crates/ox-regex/tests/behavior.rs @@ -8,7 +8,10 @@ clippy::no_effect_underscore_binding )] -use ox_regex::{Engine, ExecError, Magic, Match, Position, Text, compile, exec, exec_at, try_exec}; +use ox_regex::{ + Engine, ExecError, Magic, Match, Position, Text, compile, compile_strict, exec, exec_at, + try_exec, +}; fn matched(pattern: &str, magic: Magic, text: &Text) -> Option { let prog = @@ -1104,3 +1107,103 @@ fn explicit_recursion_limit_is_typed() { Err(ExecError::RecursionLimit) ); } + +case!( + unclosed_bracket_is_literal, + "regexp.c:10845-10911 nfa_regatom collection fallthrough", + "[", + Magic::Magic, + "a[b", + Some("[") +); +case!( + unclosed_empty_collection_is_literal, + "regexp.c:10845-10911 skip_anyof finds no terminator", + "[]", + Magic::Magic, + "a[]b", + Some("[]") +); +case!( + unclosed_negated_collection_is_literal, + "regexp.c:10845-10911 negated collection without terminator", + "[^]", + Magic::Magic, + "a[^]b", + Some("[^]") +); +case!( + unclosed_prefix_is_literal, + "regexp.c:10845-10911 missing ] after character list", + "[a", + Magic::Magic, + "x[a", + Some("[a") +); +case!( + trailing_bracket_after_literal, + "regexp.c:10845-10911 bracket after atom without terminator", + "a[", + Magic::Magic, + "xa[", + Some("a[") +); +case!( + unclosed_slash_class_is_literal, + "regexp.c:10845-10911 class-like prefix without terminator", + "[/", + Magic::Magic, + "x[/y", + Some("[/") +); +case!( + unclosed_posix_prefix_scans_past_inner_bracket, + "regexp.c:10845-10911 skip_anyof skips the [:alpha:] opener", + "[[:alpha:]", + Magic::Magic, + "x[:a", + Some("[:") +); +case!( + closed_multiline_collection_excludes_bracket, + "regexp.c:10845-10911 NFA_ADD_NL members exclude the opener", + "\\_[a]", + Magic::Magic, + "[", + None +); +case!( + unclosed_multiline_bracket_is_literal, + "regexp.c:10845-10911 extra dropped when the scan fails", + "\\_[", + Magic::Magic, + "a[b", + Some("[") +); +case!( + trailing_dash_is_literal_member, + "regexp.c:10845-10911 dash before the terminator is literal", + "[a-]", + Magic::Magic, + "-", + Some("-") +); + +#[test] +fn reversed_range_reports_e944() { + let _spec = "regexp.c:10921-10926 E944 Reverse range in character class"; + let error = compile("[z-a]", Magic::Magic).unwrap_err(); + assert_eq!(error.code(), "E944"); + assert_eq!( + error.to_string(), + "Reverse range in character class" + ); +} + +#[test] +fn strict_unclosed_bracket_reports_e769() { + let _spec = "regexp.c:10869 E769 Missing ] after %s["; + let error = compile_strict("[", Magic::Magic).unwrap_err(); + assert_eq!(error.code(), "E769"); + assert_eq!(error.to_string(), "Missing ] after %s["); +} From 1a385cb2c23ce9f430a71a280e4d0401a9743da3 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:50:58 +0900 Subject: [PATCH 003/263] fix(excmd): converge the Ex command layer on upstream semantics The modifier loop now re-skips space, tab, and `:` at the top of every iteration, as parse_command_modifiers does, so `:unsilent :norm! n` and `:silent :silent :echo 1` parse as modifiers over a command instead of one unknown name. `:put =expr` takes its expression through the register path, so the six shapes the oldtests exercise agree with the oracle and one dead parse helper is gone. `:scriptnames` lists sourced scripts as `%3d: name` with home replacement, edits a script given a count or a range, and reports upstream's invalid-argument error; the startup noise it used to cause is gone because upstream rplugin.vim calls it on every start. A substitute replacement expression now holds the text lock, so a nested `:s` inside `\=` raises E565 instead of recursing without bound, and the first non-normal flow from `:tabdo` stops the loop while restoring the original tabpage, with the check against the tab table happening immediately before each switch. Command dispatch reports a resolved but unimplemented builtin command as E492 rather than E117, and the regex call sites use CompileError::code() instead of a fixed E54 prefix. --- crates/ox-editor/src/excmd_exec.rs | 1004 +++++++++++++---- .../ox-editor/src/excmd_exec_editor_tests.rs | 162 ++- .../src/excmd_exec_function_tests.rs | 88 +- .../ox-editor/src/excmd_exec_regex_tests.rs | 8 +- .../ox-editor/src/excmd_exec_state_tests.rs | 202 +++- crates/ox-excmd/src/lib.rs | 4 +- crates/ox-excmd/src/parser.rs | 224 +++- crates/ox-excmd/src/tests.rs | 182 ++- 8 files changed, 1590 insertions(+), 284 deletions(-) diff --git a/crates/ox-editor/src/excmd_exec.rs b/crates/ox-editor/src/excmd_exec.rs index 6eed30fb..0535084a 100644 --- a/crates/ox-editor/src/excmd_exec.rs +++ b/crates/ox-editor/src/excmd_exec.rs @@ -837,6 +837,9 @@ pub(crate) struct ExRuntime { /// (`cause_errthrow` returns false, `should_abort` returns false). When /// nonzero, errors become catchable exceptions. pub(crate) try_depth: usize, + /// `text_locked()` depth: non-zero while an expression that must not + /// change text (a substitute replacement) is being evaluated. + pub(crate) textlock: usize, /// Monotonic counter giving every script-source and function-call /// frame a push order; `throwpoint` merges both stacks through it, /// matching upstream's single `estack` list. @@ -870,6 +873,7 @@ impl ExRuntime { closures: ClosureRegistry::new(), pending_edit_mode: None, try_depth: 0, + textlock: 0, mode_machine: None, swap_names: Rc::new(RefCell::new(HashMap::new())), swap_written: Rc::new(RefCell::new(BTreeSet::new())), @@ -968,7 +972,11 @@ impl ExRuntime { // The `nvim_exec2` pseudo-source is named `nvim_exec2()` // upstream (`do_source_str` names the estack entry); oxvim // stores the internal `` tag, so rename at display. - let name = if *name == "" { "nvim_exec2()" } else { name }; + let name = if *name == "" { + "nvim_exec2()" + } else { + name + }; if index == last { let _ = write!(result, "{name}, line {current_line}"); } else { @@ -1188,7 +1196,8 @@ pub(crate) fn drain_typeahead( return flow; } } - let drained = before.saturating_sub(access.with_ex_editor(|editor| editor.typeahead().len())); + let drained = + before.saturating_sub(access.with_ex_editor(|editor| editor.typeahead().len())); consumed += drained.max(1); } Flow::Normal @@ -2449,36 +2458,15 @@ fn append_line_instructions( Ok(None) => break, // A line that does not parse is stored deferred: the slot keeps // no command and `run_instructions` re-resolves it against the - // live user-command view when its turn comes. + // live user-command view when its turn comes. (`:put ={expr}` + // parses natively now that `parse_register` consumes the `=` + // expression in the parser, so no whole-line rescan is needed.) Err(_) => { - if cursor == 0 - && let Some(commands) = parse_put_expression(parser, command_text) - { - let mut read_from = 0_usize; - for command in commands { - let next_from = command.span.end.min(command_text.len()); - push_instruction( - program, - command, - command_text, - read_from, - 0, - heredoc_body, - first_line, - cmdline, - ); - read_from = next_from; - if matches!(bytes.get(read_from), Some(b'|' | b'\n')) { - read_from += 1; - } - } - } else { - program.push(deferred_instruction( - &command_text[raw_start..], - first_line, - cmdline, - )); - } + program.push(deferred_instruction( + &command_text[raw_start..], + first_line, + cmdline, + )); return; } }; @@ -2504,9 +2492,7 @@ fn append_line_instructions( // (ex_docmd.c:4170-4178). `ex_append`/`ex_insert` still read // their input lines after the newline the comment ends at. Some(b'"') => { - if reads_lines - && let Some(relative) = command_text[end..].find('\n') - { + if reads_lines && let Some(relative) = command_text[end..].find('\n') { cursor = consume_append_lines( program, command_text, @@ -2520,13 +2506,8 @@ fn append_line_instructions( } Some(b'|' | b'\n') => { if reads_lines && bytes[end] == b'\n' { - cursor = consume_append_lines( - program, - command_text, - end + 1, - first_line, - cmdline, - ); + cursor = + consume_append_lines(program, command_text, end + 1, first_line, cmdline); continue; } cursor = end + 1; @@ -2593,8 +2574,8 @@ fn push_instruction( cmdline: bool, ) { let raw = command_text[raw_start..].to_owned(); - let typed = command_text[parse_base + command.span.start - ..(parse_base + command.span.end).min(command_text.len())] + let typed = command_text + [parse_base + command.span.start..(parse_base + command.span.end).min(command_text.len())] .to_owned(); if let Some(body) = heredoc_body { command.args.push('\n'); @@ -2611,31 +2592,6 @@ fn push_instruction( }); } -fn parse_put_expression( - parser: &ExParser<'_, P>, - line: &str, -) -> Option> { - for (offset, _) in line.match_indices('=') { - let Ok(mut commands) = parser.parse(&line[..=offset]) else { - continue; - }; - if commands.len() != 1 - || commands[0].command.name() != "put" - || commands[0].register != Some('=') - { - continue; - } - let expression = line[offset + 1..].trim(); - if expression.is_empty() { - return None; - } - expression.clone_into(&mut commands[0].args); - commands[0].span.end = line.len(); - return Some(commands); - } - None -} - /// Upstream `emsg()` displays the error and sets `did_emsg`, which then /// ends the `do_cmdline` loop (ex_docmd.c:745-753) — the message and the /// abort are separate. When `trylevel == 0` (no active `:try` block and no @@ -2931,21 +2887,16 @@ fn run_instructions( lua, skipwhite_trim(&command.args), ) { - Ok(true) => match run_program( - runtime, - access, - scope, - lua, - program, - pc + 1, - end, - ) { - Flow::Normal | Flow::Continue | Flow::Break => {} - flow => { - display_error_message(runtime, access, &flow); - return flow; + Ok(true) => { + match run_program(runtime, access, scope, lua, program, pc + 1, end) + { + Flow::Normal | Flow::Continue | Flow::Break => {} + flow => { + display_error_message(runtime, access, &flow); + return flow; + } } - }, + } Ok(false) => {} Err(flow) => return flow, } @@ -3001,15 +2952,7 @@ fn run_instructions( { return flow; } - match run_program( - runtime, - access, - scope, - lua, - program, - pc + 1, - end, - ) { + match run_program(runtime, access, scope, lua, program, pc + 1, end) { Flow::Normal | Flow::Continue | Flow::Break => {} flow => { display_error_message(runtime, access, &flow); @@ -3062,8 +3005,7 @@ fn run_instructions( // string that simply ends discards the open `:try` // silently. runtime.try_depth += 1; - let flow = - run_program(runtime, access, scope, lua, program, pc + 1, end); + let flow = run_program(runtime, access, scope, lua, program, pc + 1, end); runtime.try_depth -= 1; if !matches!(flow, Flow::Normal) { display_error_message(runtime, access, &flow); @@ -3574,6 +3516,7 @@ fn dispatch( "setglobal" => command_set(runtime, access, scope, lua, &command.args, SetLayer::Global), "syntax" if matches!(command.args.trim(), "on" | "off") => Flow::Normal, "filetype" => command_filetype(runtime, access, scope, lua, command), + "setfiletype" => command_setfiletype(runtime, access, scope, lua, command), "insert" => Flow::Normal, "startinsert" => { runtime.pending_edit_mode = Some(if command.bang { @@ -3667,17 +3610,8 @@ fn dispatch( "preserve" => command_preserve(runtime, access), "wshada" | "wviminfo" => command_wshada(runtime, access, command), "rshada" | "rviminfo" => command_rshada(runtime, access, command), - "iabbrev" => command_iabbrev(runtime, access, scope, command), - "abclear" => { - // `:abclear` removes abbreviations in both scopes (ex_cmds.lua - // `abclear`, `map_clear` over abbrs). - access.with_ex_editor(|editor| { - editor.mappings_mut().abbrevclear(MapScope::Global); - editor - .mappings_mut() - .abbrevclear(MapScope::Buffer(BufHandle::CURRENT)); - }); - Flow::Normal + "iabbrev" | "abclear" => { + crate::builtins::mapping::command_abbrev(runtime, access, scope, command) } "clearjumps" => access.with_ex_editor(command_clearjumps), "argadd" => access.with_ex_editor(|editor| command_argadd(runtime, editor, command)), @@ -3756,6 +3690,7 @@ fn dispatch( "hide" => access.with_ex_editor(|editor| command_hide(runtime, editor, command)), "sleep" => access.with_ex_editor(|editor| command_sleep(runtime, editor, command)), "scriptencoding" => command_scriptencoding(runtime, command), + "scriptnames" => command_scriptnames(runtime, access, scope, lua, command), "argdelete" => access.with_ex_editor(|editor| command_argdelete(runtime, editor, command)), "z" => access.with_ex_editor(|editor| command_z(runtime, editor, command)), "lockvar" => command_lockvar(runtime, access, scope, command, true), @@ -3767,6 +3702,8 @@ fn dispatch( "diffthis" => access.with_ex_editor(|editor| command_diffthis(runtime, editor)), "diffoff" => access.with_ex_editor(|editor| command_diffoff(runtime, editor, command)), "diffupdate" => access.with_ex_editor(|editor| command_diffupdate(runtime, editor)), + "diffget" => command_diffgetput(runtime, access, command, false), + "diffput" => command_diffgetput(runtime, access, command, true), "resize" => access.with_ex_editor(|editor| command_resize(runtime, editor, command)), "wincmd" => access.with_ex_editor(|editor| command_wincmd(runtime, editor, command)), "echohl" => access.with_ex_editor(|editor| command_echohl(runtime, editor, command)), @@ -3829,9 +3766,20 @@ fn dispatch( flow } } + // `ex_wnext` with the previous-file direction (CMD_wNext): same + // write-then-move shape as `:wprevious`. + "wNext" => { + let flow = command_write(runtime, access, scope, lua, command); + if matches!(flow, Flow::Normal) && !runtime.write_silent_fail { + command_previous(runtime, access, scope, lua, command) + } else { + flow + } + } "!" => command_bang(runtime, access, scope, lua, command), "argdo" => command_argdo(runtime, access, scope, lua, command), "windo" => command_windo(runtime, access, scope, lua, command), + "tabdo" => command_tabdo(runtime, access, scope, lua, command), "put" => command_put(runtime, access, scope, lua, command), "print" => access.with_ex_editor(|editor| command_print(runtime, editor, command)), "delete" => access.with_ex_editor(|editor| command_delete(runtime, editor, command)), @@ -3880,6 +3828,9 @@ fn dispatch( "cexpr" | "cgetexpr" | "laddexpr" | "caddexpr" | "lexpr" | "lgetexpr" | "lcexpr" => { command_quickfix_expr(runtime, access, scope, lua, command) } + "vimgrep" | "vimgrepadd" | "lvimgrep" | "lvimgrepadd" => { + command_vimgrep(runtime, access, scope, lua, command) + } "cbuffer" | "cgetbuffer" | "lbuffer" | "lgetbuffer" | "caddbuffer" | "laddbuffer" => access .with_ex_editor(|editor| command_quickfix_buffer(runtime, editor, scope, lua, command)), "cfile" | "cgetfile" | "lfile" | "lgetfile" | "caddfile" | "laddfile" => { @@ -3910,8 +3861,17 @@ fn dispatch( | "inoremap" | "cnoremap" | "lnoremap" | "tnoremap" | "unmap" | "nunmap" | "vunmap" | "xunmap" | "sunmap" | "ounmap" | "iunmap" | "cunmap" | "lunmap" | "tunmap" | "mapclear" | "nmapclear" | "vmapclear" | "xmapclear" | "smapclear" | "omapclear" - | "imapclear" | "cmapclear" | "lmapclear" | "tmapclear" => { - access.with_ex_editor(|editor| command_map(runtime, editor, scope, command)) + | "imapclear" | "cmapclear" | "lmapclear" | "tmapclear" | "abbreviate" | "cabbrev" + | "cabclear" | "cnoreabbrev" | "cunabbrev" | "iabclear" | "inoreabbrev" + | "iunabbrev" | "noreabbrev" | "unabbreviate" => { + // The abbreviation family rides the mapping engine + // (`mapping.c:do_exmap`); `"iabbrev"` and `"abclear"` route + // through their own arm to the same `command_abbrev` path. + if crate::mapping::is_abbrev_command(command.command.name()) { + crate::builtins::mapping::command_abbrev(runtime, access, scope, command) + } else { + access.with_ex_editor(|editor| command_map(runtime, editor, scope, command)) + } } "tag" | "tjump" | "tselect" | "tnext" | "tprevious" | "tNext" | "tfirst" | "trewind" | "tlast" | "pop" | "ltag" | "ptag" | "ptjump" | "ptnext" | "ptprevious" | "ptNext" @@ -3930,7 +3890,17 @@ fn dispatch( ResolvedCommand::User(user_info) => { command_invoke_user(runtime, access, scope, lua, &user_info.name, command) } - ResolvedCommand::Builtin(spec) => Flow::NotImplemented(spec.name.to_owned()), + // A builtin without a handler arm is not silently skipped: the + // command line names a command the editor does not carry, so it + // reports the standard unknown-command error rather than the + // function-call error (`swallow_error_message` renders a + // surviving `NotImplemented` as E117, which is reserved for + // genuine function-call and feature-scoped flows). + ResolvedCommand::Builtin(spec) => error_flow( + runtime, + "E492", + format!("Not an editor command: {}", spec.name), + ), }, } } @@ -4724,7 +4694,7 @@ impl RegexEngine for VimRegex { pattern.to_string_lossy().into_owned() }; let program = compile_regex(&source, Magic::Magic) - .map_err(|error| EvalError::new("E54", 0, error.to_string()))?; + .map_err(|error| EvalError::new(error.code(), 0, error.to_string()))?; Ok(ox_regex::exec( &program, &RegexText::new(text.to_string_lossy().into_owned()), @@ -4740,7 +4710,7 @@ impl RegexEngine for VimRegex { ) -> ox_eval::Result> { let source = text.to_string_lossy().into_owned(); let program = compile_regex(&pattern.to_string_lossy(), Magic::Magic) - .map_err(|error| EvalError::new("E54", 0, error.to_string()))?; + .map_err(|error| EvalError::new(error.code(), 0, error.to_string()))?; let text = RegexText::new(source); let Some(position) = text.position(start) else { return Ok(None); @@ -4763,7 +4733,7 @@ impl RegexEngine for VimRegex { message: "lookaround suffix follows nothing", .. } => EvalError::new("E866", 0, "(NFA regexp) Misplaced @"), - other => EvalError::new("E54", 0, other.to_string()), + other => EvalError::new(other.code(), 0, other.to_string()), }, )?; let text = RegexText::new(source); @@ -4791,7 +4761,7 @@ impl RegexEngine for VimRegex { ) -> ox_eval::Result> { let source = text.to_string_lossy().into_owned(); let program = compile_regex(&pattern.to_string_lossy(), Magic::Magic) - .map_err(|error| EvalError::new("E54", 0, error.to_string()))?; + .map_err(|error| EvalError::new(error.code(), 0, error.to_string()))?; let regex_text = RegexText::new(source.clone()); let mut result = Vec::new(); let mut previous = 0; @@ -4942,8 +4912,8 @@ pub(crate) fn call_user_function_with_self( // frame's lifetime. runtime.deferred_ops.push(Vec::new()); let sid = function.context.sid; - let switched_script = sid != 0 && runtime.scripts.current_sid() != Some(sid); - let caller_script = scope.script.clone(); + let caller_sid = runtime.scripts.current_sid(); + let switched_script = sid != 0 && caller_sid != Some(sid); if sid != 0 { let name = runtime .scripts @@ -4958,13 +4928,23 @@ pub(crate) fn call_user_function_with_self( ); } if switched_script { - runtime.scripts.load_script_scope(sid, scope); + // Flush the outgoing frame's live `s:` map before replacing it: the + // defining script may still be executing further down the stack, in + // which case the registry copy predates its assignments and loading + // it would hide them (`s:has_ffi` going missing in `Ntest_override`). + runtime.scripts.switch_script_scope(caller_sid, sid, scope); } let flow = run_program(runtime, access, scope, lua, &parsed, 0, parsed.len()); let deferred_flow = drain_frame_defers(runtime, access, scope, lua, first_line, last_line); if switched_script { runtime.scripts.store_script_scope(sid, scope); - scope.script = caller_script; + // Read the caller's map back from its SID instead of restoring a + // pre-call clone: a nested call into the caller's own script stores + // that script's updates in the registry on the way out. + match caller_sid { + Some(from) => runtime.scripts.load_script_scope(from, scope), + None => scope.script = ScopeMap::new(), + } } let flow = match flow { Flow::NotImplemented(name) => { @@ -5067,7 +5047,9 @@ fn source_path( .to_string(); let caller_script = scope.script.clone(); let caller_augroup = runtime.current_augroup; - let sid = runtime.scripts.push_source(name, runtime.next_frame_order()); + let sid = runtime + .scripts + .push_source(name, runtime.next_frame_order()); let lines = expand_script_lines(&runtime.scripts, lines, sid); runtime.scripts.load_script_scope(sid, scope); if load_once { @@ -5249,7 +5231,10 @@ fn command_let( let mut lines: Vec<&str> = if body.is_empty() { Vec::new() } else { - body.strip_suffix('\n').unwrap_or(body).split('\n').collect() + body.strip_suffix('\n') + .unwrap_or(body) + .split('\n') + .collect() }; // Terminator (eval/vars.c:853-857): the bare marker, or under // `trim` the marker preceded by exactly the command chunk's own @@ -5264,7 +5249,8 @@ fn command_let( let mut items = Vec::new(); for line in lines { if trim && text_indent.is_none() && !line.is_empty() { - text_indent = Some(&line[..line.len() - line.trim_start_matches([' ', '\t']).len()]); + text_indent = + Some(&line[..line.len() - line.trim_start_matches([' ', '\t']).len()]); } let content = match text_indent { Some(indent) => { @@ -5908,7 +5894,7 @@ fn command_function_list( } let compiled = match compile_regex(&pattern, Magic::Magic) { Ok(compiled) => compiled, - Err(error) => return error_flow(runtime, "E54", error.to_string()), + Err(error) => return error_flow(runtime, error.code(), error.to_string()), }; Some(compiled) }; @@ -6600,7 +6586,7 @@ fn command_global( }; let program_regex = match compile_regex(&pattern, Magic::Magic) { Ok(program) => program, - Err(error) => return error_flow(runtime, "E54", error.to_string()), + Err(error) => return error_flow(runtime, error.code(), error.to_string()), }; let lines = match access.with_ex_editor(|editor| buffer_lines(editor, buffer)) { Ok(lines) => lines, @@ -6723,6 +6709,12 @@ fn command_substitute( scope: &mut Scope, command: &ExCommand, ) -> Flow { + // `text_locked()` (`ex_docmd.c`): a substitute running inside another + // substitute's replacement expression must not change text. Without this + // guard a self-recursive replacement never terminates. + if runtime.textlock > 0 { + return error_flow(runtime, "E565", "Not allowed to change text or change window"); + } let args = command.args.trim_start(); let Some(delimiter) = args.chars().next() else { return error_flow(runtime, "E33", "No previous substitute regular expression"); @@ -6776,7 +6768,7 @@ fn command_substitute( }; let program_regex = match compile_regex(&compiled_pattern, Magic::Magic) { Ok(program) => program, - Err(error) => return error_flow(runtime, "E54", error.to_string()), + Err(error) => return error_flow(runtime, error.code(), error.to_string()), }; let mut substitutions = Vec::new(); let mut changed = false; @@ -6967,10 +6959,13 @@ fn eval_substitute_expression( submatches: Some(groups), escaped_exception: None, }; - Evaluator::new(&mut host, ®ex) + host.runtime.textlock += 1; + let result = Evaluator::new(&mut host, ®ex) .eval(&parsed, scope) .map(|value| typval_to_text(&value)) - .map_err(|error| eval_error_flow(host.runtime, error)) + .map_err(|error| eval_error_flow(host.runtime, error)); + host.runtime.textlock = host.runtime.textlock.saturating_sub(1); + result } /// The tabpage geometry every command that has to create one uses. @@ -7127,41 +7122,6 @@ fn command_runtime( } Flow::Normal } -/// `:iabbrev {lhs} {rhs}` (`ex_cmds.lua` `abbreviate`, insert-mode): define -/// one insert-mode abbreviation. The other `:abbreviate` mode prefixes and -/// `:unabbreviate` remain unrouted until the abbreviation command family -/// gets its full parser. -fn command_iabbrev( - runtime: &mut ExRuntime, - access: &E, - scope: &mut Scope, - command: &ExCommand, -) -> Flow { - let args = command.args.trim(); - let Some((lhs, rhs)) = args.split_once(char::is_whitespace) else { - return error_flow(runtime, "E471", "Argument required"); - }; - let (lhs, rhs) = (lhs.trim(), rhs.trim()); - let action = match MappingAction::parse_rhs( - rhs, - &map_leader(scope, "mapleader"), - &map_leader(scope, "maplocalleader"), - ) { - Ok(action) => action, - Err(error) => return error_flow(runtime, "E224", error.to_string()), - }; - let defined = access.with_ex_editor(|editor| { - editor - .mappings_mut() - .abbreviate(lhs, action, MapScope::Global, !command.bang) - .is_ok() - }); - if defined { - Flow::Normal - } else { - error_flow(runtime, "E736", "Invalid abbreviation") - } -} /// `:packadd[!] {name}` (`runtime.c` `ex_packadd`): search `'packpath'` for /// `pack/*/{start,opt}/{name}`, insert each found directory into /// `'runtimepath'` before its first `after` entry (skipped when already @@ -7468,6 +7428,99 @@ fn edit_winfixbuf_guard( access.with_ex_editor(|editor| winfixbuf_blocks(runtime, editor, command.bang)) } +/// Displays a sourced-script path the way `:scriptnames` lists it: +/// `home_replace` (`path.c`) swaps a `$HOME` prefix for `~`, so a script +/// under the home directory lists as `~/home_script.vim`. +fn home_replaced_name(name: &str) -> String { + let Some(home) = std::env::var_os("HOME") else { + return name.to_owned(); + }; + let home = home.to_string_lossy(); + let Some(rest) = name.strip_prefix(home.as_ref()) else { + return name.to_owned(); + }; + if rest.is_empty() { + return "~".to_owned(); + } + if rest.starts_with('/') { + return format!("~{rest}"); + } + name.to_owned() +} + +/// `:scriptnames` (`runtime.c` `ex_scriptnames`): with no range and no +/// argument list every sourced script as `%3d: name` in SID order, replacing +/// a `$HOME` prefix with `~` and skipping nameless entries; with a count +/// (`:scriptnames {id}`) or a range (`:{id}scriptnames`) edit that script, +/// and with a bare argument edit that file. Upstream edits through +/// `do_exedit`, the same path as `:edit`, so both edit arms delegate to +/// [`command_edit`] with the resolved name as its argument. +fn command_scriptnames( + runtime: &mut ExRuntime, + access: &E, + scope: &mut Scope, + lua: Option<&Rc>, + command: &ExCommand, +) -> Flow { + // No message-kind concept exists here (`:registers` and `:ls` both emit + // plain echo lines), so the listing uses the same text-message path + // rather than inventing a `list_cmd` kind. + if command.range.is_none() && command.count.is_none() && command.args.trim().is_empty() { + let entries: Vec<(Sid, String)> = runtime + .scripts + .script_names() + .into_iter() + .filter(|(_, name)| !name.is_empty()) + .map(|(sid, name)| (sid, home_replaced_name(name))) + .collect(); + for (sid, name) in entries { + access.with_ex_editor(|editor| { + push_text_message(editor, format!("{sid:3}: {name}"), false, false); + }); + } + return Flow::Normal; + } + // `set_cmd_count` (`ex_docmd.c:1373-1393`) folds a post-command count + // into the address for non-line domains, so `:scriptnames {id}` arrives + // as `count` while `:{id}scriptnames` arrives as `range`; the count + // wins over the range, the same merge `command_sleep` applies. + if command.range.is_none() && command.count.is_none() { + // A bare argument names a file, exactly what `:edit` would open. + let edit = ExCommand { + args: command.args.clone(), + ..command.clone() + }; + return command_edit(runtime, access, scope, lua, &edit); + } + let sid = match (command.range.is_some(), command.count) { + (_, Some(count)) => count, + (true, None) => match access.with_ex_editor(|editor| resolve_range_raw(editor, command)) { + Ok((_, end)) => match u64::try_from(end) { + Ok(sid) => sid, + Err(_) => return error_flow(runtime, "E16", "Invalid range"), + }, + Err(message) => return error_flow(runtime, "E16", message), + }, + (false, None) => return error_flow(runtime, "E474", "Invalid argument"), + }; + // `correct_range` (`ex_docmd.c:3843-3853`): without `ZEROR` an address + // of 0 counts as 1, so `:0scriptnames` edits the first script. + let sid = if sid == 0 { 1 } else { sid }; + let Some(name) = runtime + .scripts + .script_name(sid) + .filter(|name| !name.is_empty()) + .map(str::to_owned) + else { + return error_flow(runtime, "E474", "Invalid argument"); + }; + let edit = ExCommand { + args: name, + ..command.clone() + }; + command_edit(runtime, access, scope, lua, &edit) +} + fn command_edit( runtime: &mut ExRuntime, access: &E, @@ -9962,6 +10015,17 @@ fn fire_filetype_autocmd( if runtime.autocmd_busy > 0 && !force && !runtime.active_autocmd.nested { return Flow::Normal; } + // `do_filetype_autocmd` (`autocmd.c:2595-2613`) marks the buffer before + // applying, so `did_filetype()` already answers true while the `FileType` + // handlers run; an empty value never marks. The mark survives an empty + // plan the way the upstream mark survives an `apply_autocmds` bypass. + if !filetype.is_empty() { + access.with_ex_editor(|editor| { + if let Ok(state) = editor.buffer_mut(buffer) { + state.mark_did_filetype(); + } + }); + } let name = access.with_ex_editor(|editor| { editor .buffer(buffer) @@ -10156,16 +10220,10 @@ fn read_file_format(bytes: &[u8], formats: &str, fallback: &str, binary: bool) - /// 'mac' with CR, 'unix' with LF; the trailing EOL is written whenever /// 'endofline' is set or 'fixeol' fixes it, but never under 'binary' /// without 'endofline'. -fn buffer_file_bytes( - editor: &Editor, - buffer: BufHandle, - bytes: &[u8], -) -> Vec { - let bool_option = |name: &str, default: bool| { - match editor.options().get_buffer(buffer, name) { - Ok(OptionValue::Boolean(value)) => *value, - _ => default, - } +fn buffer_file_bytes(editor: &Editor, buffer: BufHandle, bytes: &[u8]) -> Vec { + let bool_option = |name: &str, default: bool| match editor.options().get_buffer(buffer, name) { + Ok(OptionValue::Boolean(value)) => *value, + _ => default, }; let fileformat = match editor.options().get_buffer(buffer, "fileformat") { Ok(OptionValue::String(value)) => value.clone(), @@ -10189,7 +10247,11 @@ fn buffer_file_bytes( out.push(b'\r'); } } else { - let separator = if fileformat == "dos" { b"\r\n".as_slice() } else { b"\n".as_slice() }; + let separator = if fileformat == "dos" { + b"\r\n".as_slice() + } else { + b"\n".as_slice() + }; let mut parts = body.split(|byte| *byte == b'\n').peekable(); while let Some(part) = parts.next() { out.extend_from_slice(part); @@ -10232,9 +10294,10 @@ pub fn detect_file_format( "fileformat", OptionValue::String(read.fileformat.to_owned()), ); - let _ = editor - .options_mut() - .set_buffer(buffer, "endofline", OptionValue::Boolean(read.eol)); + let _ = + editor + .options_mut() + .set_buffer(buffer, "endofline", OptionValue::Boolean(read.eol)); } read } @@ -10249,9 +10312,7 @@ fn read_file_text( path: &Path, ) -> std::io::Result> { let bytes = runtime.scripts.io().read_bytes(path)?; - Ok(access.with_ex_editor(|editor| { - detect_file_format(editor, Some(buffer), &bytes).text - })) + Ok(access.with_ex_editor(|editor| detect_file_format(editor, Some(buffer), &bytes).text)) } /// Splits read bytes into buffer lines. A trailing newline terminates the last @@ -12328,6 +12389,10 @@ fn command_foldopen( let opening = command.command.name() == "foldopen"; let recurse = command.bang; let method = current_fold_method(runtime, editor); + // Refresh the computed folds before opening/closing: method/shiftwidth + // changes leave a stale or empty cache, which misreports E490 + // (builtins/fold.rs `refresh_for_window` mirrors `synced_folds`). + crate::builtins::fold::refresh_for_window(editor); let mut found = false; for lnum in first..=last { let position = FoldPosition::new(lnum - 1, 0); @@ -12382,6 +12447,88 @@ fn command_diffupdate(_runtime: &mut ExRuntime, editor: &mut Edito Flow::Normal } +/// `:diffget [buf]` / `:diffput [buf]` (`ex_diffgetput`, diff.c:3679-3800): +/// copy hunks from the other diff buffer into the current one (`get`) or +/// from the current one into it (`put`). An all-digit argument is a buffer +/// number; anything else resolves by name with the `:buffer` rules (E94 +/// when nothing matches, E93 when several do). E99-E103/E21/E793 come +/// from the diff layer itself. +fn command_diffgetput( + runtime: &mut ExRuntime, + access: &E, + command: &ExCommand, + put: bool, +) -> Flow { + let (first, last, addr_count) = match &command.range { + None => { + let line = access.with_ex_editor(|editor| { + editor + .current_window() + .and_then(|window| editor.window(window).ok()) + .map(|window| window.cursor.lnum) + .unwrap_or(1) + }); + (line, line, 0_usize) + } + Some(range) => { + // Unclamped: upstream lets `:diffget` address one line past the + // end (`:3,4diffget` on three lines fetches the trailing + // insertion); the diff layer bounds the range itself. + let (start, end) = + match access.with_ex_editor(|editor| resolve_range_raw(editor, command)) { + Ok(range) => range, + Err(message) => return error_flow(runtime, "E16", message), + }; + let count = match range.kind { + RangeKind::Single => 1_usize, + RangeKind::WholeBuffer | RangeKind::Pair { .. } => 2_usize, + }; + (start, end, count) + } + }; + let argument = command.args.trim(); + let target = if argument.is_empty() { + None + } else if let Ok(number) = argument.parse::() + && let Ok(handle) = BufHandle::try_from(number) + { + Some(handle) + } else if argument.parse::().is_ok() { + return error_flow( + runtime, + "E102", + format!("Can't find buffer \"{argument}\""), + ); + } else { + let found = access.with_ex_editor(|editor| { + editor + .buffers() + .into_iter() + .filter(|handle| { + editor + .buffer(*handle) + .is_ok_and(|buffer| buffer_name_matches(buffer.name(), argument)) + }) + .collect::>() + }); + match found.as_slice() { + [handle] => Some(*handle), + [] => { + return error_flow(runtime, "E94", format!("No matching buffer for {argument}")); + } + _ => { + return error_flow(runtime, "E93", format!("More than one match for {argument}")); + } + } + }; + match access.with_ex_editor(|editor| { + crate::diffmode::diffgetput(editor, put, target, first, last, addr_count) + }) { + Ok(()) => Flow::Normal, + Err(error) => error_flow(runtime, error.code, error.message), + } +} + /// `:hide` (`ex_docmd.c` `ex_hide`:5369): close a window without freeing its /// buffer. /// @@ -13173,14 +13320,7 @@ fn load_buffer_for_switch( }); } - let flow = fire_buffer_lifecycle( - runtime, - access, - scope, - lua, - &[Event::BufReadPost], - buffer, - ); + let flow = fire_buffer_lifecycle(runtime, access, scope, lua, &[Event::BufReadPost], buffer); if !matches!(flow, Flow::Normal) { rollback_buffer_switch(access, buffer, &mut restore, false); return Err(LoadSwitchError::Flow(flow)); @@ -14031,9 +14171,10 @@ fn edit_argument_file( "fileformat", OptionValue::String(read.fileformat.to_owned()), ); - let _ = editor - .options_mut() - .set_buffer(handle, "endofline", OptionValue::Boolean(read.eol)); + let _ = + editor + .options_mut() + .set_buffer(handle, "endofline", OptionValue::Boolean(read.eol)); let state = editor.buffer_mut(handle)?; state.set_name(OxStr::from(name)); state.mark_saved(); @@ -14185,6 +14326,55 @@ fn command_windo( Flow::Normal } +/// Runs one Ex command for each tabpage in range (`ex_listdo` `CMD_tabdo`, +/// ex_cmds2.c:461). Like `:windo`, tab visits never switch a window's +/// buffer, so the 'winfixbuf' preamble does not apply (ex_listdo skips it +/// for `CMD_windo` and `CMD_tabdo` alike). +fn command_tabdo( + runtime: &mut ExRuntime, + access: &E, + scope: &mut Scope, + lua: Option<&Rc>, + command: &ExCommand, +) -> Flow { + let nested = command.args.trim(); + if nested.is_empty() { + return error_flow(runtime, "E471", "Argument required"); + } + let tabs = access.with_ex_editor(|editor| editor.tabpages()); + if tabs.is_empty() { + return Flow::Normal; + } + let (start, end) = match access.with_ex_editor(|editor| resolve_range(editor, command)) { + Ok(range) => range, + Err(message) if message == "Invalid range" => { + return error_flow(runtime, "E493", "Backwards range given"); + } + Err(message) => return error_flow(runtime, "E16", message), + }; + let program = parse_cmdline_program( + &runtime.user_commands, + access.with_ex_editor(|editor| editor.current_buffer()), + nested, + runtime.scripts.current_line(), + ); + for &tab in tabs + .iter() + .skip(start.saturating_sub(1)) + .take(end.saturating_sub(start).saturating_add(1)) + { + if access.with_ex_editor(|editor| editor.tabpage(tab).is_err()) { + break; + } + switch_current_tabpage(runtime, access, scope, lua, tab); + let flow = run_program(runtime, access, scope, lua, &program, 0, program.len()); + if !matches!(flow, Flow::Normal) { + return flow; + } + } + Flow::Normal +} + /// Resolves a `:argdo` range against the argument list itself (entries, /// not buffer lines); without a range the whole list is addressed. fn resolve_arg_range(editor: &Editor, command: &ExCommand) -> Result<(usize, usize), String> { @@ -14226,8 +14416,36 @@ fn command_put( return error_flow(runtime, "E749", "Empty buffer"); }; let register = command.register.unwrap_or('"'); - if register == '=' && !command.args.is_empty() { - let value = match eval_text(runtime, access, scope, lua, &command.args) { + // `set_expr_line` (ex_docmd.c:1363-1366) keeps the last `=` source text, + // so a bare `:put =` re-evaluates it — and with nothing stored yet it is + // a silent no-op (`get_spec_reg` returns NULL and `do_put` returns + // before any error). + let args = if register == '=' && command.args.is_empty() { + match access + .with_ex_editor(|editor| editor.registers().expression_source().map(<[u8]>::to_vec)) + { + Some(source) => match String::from_utf8(source) { + Ok(source) => source, + Err(_) => return Flow::Normal, + }, + None => return Flow::Normal, + } + } else { + command.args.clone() + }; + if register == '=' { + // Store the source text the way `set_expr_line` does, so `getreg('=')` + // and the next bare `:put =` see this same expression. An empty + // expression stores nothing: upstream only calls `set_expr_line` + // when text follows the `=`, leaving the previous source in place. + if !args.is_empty() + && let Ok(content) = RegisterContent::characterwise(args.as_bytes()) + { + access.with_ex_editor(|editor| { + let _ = editor.registers_mut().set('=', content); + }); + } + let value = match eval_text(runtime, access, scope, lua, &args) { Ok(value) => value, Err(flow) => return flow, }; @@ -14277,6 +14495,8 @@ fn command_delete( Ok(lines) => lines, Err(message) => return error_flow(runtime, "E749", message), }; + let (start, end) = fold_line_count(start, end, command.count); + let end = end.min(lines.len().max(1)); let selected = lines[start.saturating_sub(1)..end.min(lines.len())].to_vec(); let content = match RegisterContent::linewise(selected) { Ok(content) => content, @@ -14330,6 +14550,8 @@ fn command_yank( Ok(lines) => lines, Err(message) => return error_flow(runtime, "E749", message), }; + let (start, end) = fold_line_count(start, end, command.count); + let end = end.min(lines.len().max(1)); let content = match RegisterContent::linewise( lines[start.saturating_sub(1)..end.min(lines.len())].to_vec(), ) { @@ -14520,7 +14742,7 @@ fn command_sort( let compiled = pattern_with_case(pat, ignorecase); match compile_regex(&compiled, Magic::Magic) { Ok(program) => Some(program), - Err(error) => return error_flow(runtime, "E54", error.to_string()), + Err(error) => return error_flow(runtime, error.code(), error.to_string()), } } }; @@ -15376,6 +15598,71 @@ fn command_filetype( } } +/// `:setfiletype` (`ex_docmd.c` `ex_setfiletype`). +/// +/// A silent no-op while the buffer's `did_filetype` flag is set; otherwise a +/// buffer-local `filetype` write through the `:set` path, so the same +/// `FileType` autocmd firing applies. A `FALLBACK ` prefix leaves the flag +/// clear afterwards so a later plain `:setfiletype` still applies; any other +/// value must satisfy upstream `valid_filetype` or the write fails with +/// `E474` exactly like the option layer rejects it. +fn command_setfiletype( + runtime: &mut ExRuntime, + access: &E, + scope: &mut Scope, + lua: Option<&Rc>, + command: &ExCommand, +) -> Flow { + let buffer = access.with_ex_editor(|editor| editor.current_buffer()); + if let Some(buffer) = buffer + && access.with_ex_editor(|editor| { + editor + .buffer(buffer) + .is_ok_and(|state| state.did_filetype()) + }) + { + return Flow::Normal; + } + let mut arg = command.args.trim(); + let fallback = match arg.strip_prefix("FALLBACK ") { + Some(rest) => { + arg = rest; + true + } + None => false, + }; + if !valid_filetype_name(arg) { + return error_flow(runtime, "E474", "Invalid argument"); + } + let flow = command_set( + runtime, + access, + scope, + lua, + &format!("filetype={arg}"), + SetLayer::Local, + ); + if fallback + && matches!(flow, Flow::Normal) + && let Some(buffer) = buffer + { + access.with_ex_editor(|editor| { + if let Ok(state) = editor.buffer_mut(buffer) { + state.clear_did_filetype(); + } + }); + } + flow +} + +/// Upstream `valid_filetype` (`optionstr.c`): a filetype name is ASCII +/// alphanumeric plus `.`, `-`, `_`. `ex_setfiletype` reaches the option layer +/// only with such a name, otherwise it fails with `E474`. +fn valid_filetype_name(name: &str) -> bool { + name.bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) +} + /// `do_doautocmd("filetypedetect BufRead", true, NULL)` from `ex_filetype`: /// re-fire the `filetypedetect` augroup's `BufRead` autocommands against the /// current buffer. An absent group has nothing to fire. @@ -15567,7 +15854,24 @@ pub(crate) fn run_autocmd_plan( // inside the callee, so the steady-state cost is flag checks. // `autocmd_busy` (`autocmd.c:1657`): one plan counts as one busy span, // matching `apply_autocmds` which sets and restores it around the group. + // `apply_autocmds` entry/exit (`autocmd.c:1697-1704,1814-1821`): the + // outermost batch that actually runs resets the current buffer's + // `did_filetype` mark. A `FileType` fire keeps its mark: `fire` marked + // before running this plan, and upstream re-marks for the event right + // after its own entry reset. + let outermost = runtime.autocmd_busy == 0; + let runs = !plan.ready.is_empty(); + let in_filetype_fire = runtime.filetype_autocmd_depth > 0; runtime.autocmd_busy += 1; + if outermost && runs && !in_filetype_fire { + access.with_ex_editor(|editor| { + if let Some(buffer) = editor.current_buffer() + && let Ok(state) = editor.buffer_mut(buffer) + { + state.clear_did_filetype(); + } + }); + } let mut flow = Flow::Normal; for action in plan.ready { if !access.with_ex_editor(|editor| editor.autocmds().is_entry_live(action.entry_id)) { @@ -15649,7 +15953,8 @@ pub(crate) fn run_autocmd_plan( // An uncaught `:throw` displays E605 *and* still aborts // (`bufwrite.c:1861-1866`); other displayed errors are // swallowed and the remaining autocmds run. - if matches!(&action_flow, Flow::Exception(exception) if matches!(exception.kind, VimExceptionKind::Throw)) { + if matches!(&action_flow, Flow::Exception(exception) if matches!(exception.kind, VimExceptionKind::Throw)) + { flow = action_flow; break; } @@ -15660,6 +15965,20 @@ pub(crate) fn run_autocmd_plan( } } runtime.autocmd_busy -= 1; + // Upstream resets unconditionally here (`autocmd.c:1814-1821`), with no + // event carve-out: a top-level `:setf` whose `FileType` handlers ran + // answers `did_filetype() == 0` afterwards, while the no-handler bypass + // (`runs == false`) keeps the mark. The entry carve-out above is what + // lets the handlers themselves still observe the mark. + if runtime.autocmd_busy == 0 && runs { + access.with_ex_editor(|editor| { + if let Some(buffer) = editor.current_buffer() + && let Ok(state) = editor.buffer_mut(buffer) + { + state.clear_did_filetype(); + } + }); + } flow } @@ -16979,15 +17298,13 @@ fn command_invoke_user( let caller_script = scope.script.clone(); if !definition.keepscript && sid != 0 { let name = format!("command {name}"); - runtime - .scripts - .push_alias_source( - sid, - runtime.scripts.current_seq(), - 0, - name, - runtime.next_frame_order(), - ); + runtime.scripts.push_alias_source( + sid, + runtime.scripts.current_seq(), + 0, + name, + runtime.next_frame_order(), + ); if switched { runtime.scripts.load_script_scope(sid, scope); } @@ -19063,6 +19380,18 @@ fn resolve_range_raw(editor: &Editor, command: &ExCommand) -> Result<(usize, usi Ok((start, end)) } +/// `set_cmd_count` (`ex_docmd.c:1373-1393`) for line-addressed commands: a +/// post-command count starts at the range end and extends it, so `:delete 2` +/// covers two lines from the cursor and `:1,2delete 3` covers three from +/// line 2. Callers clamp the result to the buffer like the range itself. +fn fold_line_count(start: usize, end: usize, count: Option) -> (usize, usize) { + let Some(count) = count else { + return (start, end); + }; + let extra = usize::try_from(count).unwrap_or(usize::MAX).saturating_sub(1); + (end, end.saturating_add(extra)) +} + /// The `.` and `$` values for one address domain. /// /// `get_address` resolves both per `addr_type` (`ex_docmd.c:3435-3470` for @@ -19193,6 +19522,14 @@ fn check_address_domain(editor: &Editor, command: &ExCommand) -> Result<(), Stri return Err("Invalid range".to_owned()); } let (_, limit) = address_domain_bounds(editor, addr_type); + // `invalid_range` (ex_docmd.c:3762-3764): `:diffget`/`:diffput` accept + // one line past the end of the buffer (`:3,4diffget` on three lines + // fetches the trailing insertion); every other line command stops at + // the last line. + let limit = match command.command.name() { + "diffget" | "diffput" => limit.saturating_add(1), + _ => limit, + }; if end > limit { return Err("Invalid range".to_owned()); } @@ -19311,11 +19648,7 @@ struct IfBranch { /// the depth-0 `endif` position. `terminated == None` means the program /// ended inside the block — upstream still picks and runs a branch from /// the markers before reporting the missing closer. -fn if_markers( - program: &[Instruction], - open: usize, - limit: usize, -) -> (Vec, Option) { +fn if_markers(program: &[Instruction], open: usize, limit: usize) -> (Vec, Option) { let mut depth = 0usize; let mut markers = Vec::new(); let mut index = open + 1; @@ -19463,6 +19796,19 @@ pub(crate) fn render_command(command: &ExCommand) -> String { if command.bang { text.push('!'); } + // The register is part of the command text: function bodies store the + // rendered `source`, so dropping it would re-parse `:put =expr` as the + // unnamed register with trailing text. A bare `=` register renders its + // `=` directly against its expression (`:put =1+1` re-parses to the same + // register and argument); any other register renders with a separator. + if let Some(register) = command.register { + if register == '=' { + text.push_str(" ="); + } else { + text.push(' '); + text.push(register); + } + } if !command.args.is_empty() { text.push(' '); text.push_str(&command.args); @@ -21092,6 +21438,256 @@ fn command_quickfix_expr( }) } +/// Lines of the file `name` for `:vimgrep`: the loaded buffer's text when a +/// buffer already carries that name (upstream searches the loaded buffer), +/// otherwise the file on disk (`load_dummy_buffer` + `readfile`). `None` +/// when the file cannot be read at all. +fn read_vimgrep_file_lines( + runtime: &ExRuntime, + access: &E, + name: &str, +) -> Option>> { + if let Some(handle) = access.with_ex_editor(|editor| { + editor.buffers().into_iter().find(|handle| { + editor + .buffer(*handle) + .is_ok_and(|state| state.name().as_bytes() == name.as_bytes()) + }) + }) && let Ok(lines) = access.with_ex_editor(|editor| buffer_lines(editor, handle)) { + return Some(lines); + } + let bytes = runtime + .scripts + .io() + .read_bytes(std::path::Path::new(name)) + .ok()?; + Some( + bytes + .split(|byte| *byte == b'\n') + .map(|line| { + line.strip_suffix(b"\r").map_or_else( + || line.to_vec(), + <[u8]>::to_vec, + ) + }) + .collect(), + ) +} + +/// Finds the buffer named `name`, creating an unloaded one when absent so a +/// later jump can load it on demand. Mirrors the `buflist_findnr` / +/// `buflist_new` half of `parse_items_efm`'s `EditorEfmContext`, which lives +/// in the quickfix module and stays the single owner of that logic. +fn ensure_vimgrep_buffer(access: &E, name: &str) -> Option { + access.with_ex_editor(|editor| { + if let Some(handle) = editor.buffers().into_iter().find(|handle| { + editor + .buffer(*handle) + .is_ok_and(|state| state.name().as_bytes() == name.as_bytes()) + }) { + return Some(handle); + } + let handle = editor.create_buffer(true).ok()?; + editor + .buffer_mut(handle) + .ok() + .map(|state| state.set_name(OxStr::from(name)))?; + Some(handle) + }) +} + +/// `:vimgrep` / `:vimgrepadd` (`:lvimgrep` / `:lvimgrepadd` for the location +/// list): search files for a pattern and collect the matches (`ex_vimgrep`, +/// quickfix.c:5836-5927). The `f` flag currently matches as a regular +/// expression (upstream runs a fuzzy matcher): the flag parses, only that +/// flag's matching differs. Without `!`, a 'winfixbuf' window refuses +/// outright (`check_can_set_curbuf_forceit`, window.c:216-224); the jump at +/// the end goes through the usual quickfix jump, which carries its own +/// winfixbuf handling. +#[expect( + clippy::too_many_lines, + reason = "the vimgrep driver mirrors ex_vimgrep's phase order in one flow" +)] +fn command_vimgrep( + runtime: &mut ExRuntime, + access: &E, + _scope: &mut Scope, + _lua: Option<&Rc>, + command: &ExCommand, +) -> Flow { + let name = command.command.name(); + let qf_scope = access.with_ex_editor(|editor| quickfix_scope(editor, name)); + let add = name.contains("add"); + if !command.bang + && access.with_ex_editor(|editor| editor.current_window_fixed_to_buffer()) + { + return error_flow( + runtime, + "E1513", + "Cannot switch buffer. 'winfixbuf' is enabled", + ); + } + let Some(split) = ox_excmd::split_vimgrep_args(&command.args) else { + return error_flow(runtime, "E682", "Invalid search pattern or delimiter"); + }; + // An empty pattern reuses the last search pattern (`vgr_init_regmatch`); + // with none recorded that is E35, exactly like `:put /` with no history. + let pattern = if split.pattern.is_empty() { + match access.with_ex_editor(|editor| { + editor + .registers() + .get('/') + .map(|content| content.cloned()) + }) { + Ok(Some(content)) => { + let bytes = content.getreg_bytes(); + match String::from_utf8(bytes.to_vec()) { + Ok(text) if !text.is_empty() => text, + _ => { + return error_flow(runtime, "E35", "No previous regular expression"); + } + } + } + _ => return error_flow(runtime, "E35", "No previous regular expression"), + } + } else { + split.pattern.to_owned() + }; + if split.files.is_empty() { + return error_flow(runtime, "E683", "File name missing or invalid pattern"); + } + let program = match compile_regex(&pattern, Magic::Magic) { + Ok(program) => program, + Err(error) => return error_flow(runtime, error.code(), error.to_string()), + }; + let mut tomatch = match &command.range { + None => usize::MAX, + Some(_) => match access.with_ex_editor(|editor| resolve_range(editor, command)) { + Ok((_, end)) => end.max(1), + Err(message) => return error_flow(runtime, "E16", message), + }, + }; + // A replacing command opens a fresh list up front; an `add` command + // appends to the current one, creating it when the stack is empty + // (`ex_vimgrep`: a new list unless adding onto a non-empty one) — so a + // no-match search still leaves the new empty list current. + access.with_ex_editor(|editor| { + let stack = qf_scope.stack_mut(editor); + if !add || stack.current().is_none() { + stack.push(OxStr::from(format!(":{name}").as_str())); + } + }); + let mut entries: Vec = Vec::new(); + for file in crate::arglist::split_file_list(split.files) { + if tomatch == 0 { + break; + } + let mut names = + crate::fs_builtins::expand_glob(runtime.scripts.io(), &file, false); + if names.is_empty() { + names.push(file); + } + for name in names { + if tomatch == 0 { + break; + } + let Some(lines) = read_vimgrep_file_lines(runtime, access, &name) else { + // `vgr_process_files`: a file that cannot be opened is + // reported and skipped, not fatal (`smsg("Cannot open + // file")`; the trailing E480 covers a fully empty list). + access.with_ex_editor(|editor| { + push_text_message(editor, format!("Cannot open file \"{name}\""), false, true); + }); + continue; + }; + let mut handle: Option = None; + for (index, line) in lines.iter().enumerate() { + if tomatch == 0 { + break; + } + let text = RegexText::new(String::from_utf8_lossy(line).into_owned()); + let lnum = index + 1; + let mut cursor = 0_usize; + loop { + let Some(position) = text.position(cursor) else { + break; + }; + let Some(found) = regex_exec_at(&program, &text, position) else { + break; + }; + if handle.is_none() { + handle = ensure_vimgrep_buffer(access, &name); + } + entries.push(crate::quickfix::QuickfixItem { + bufnr: handle.map_or(0, i64::from), + module: OxStr::from(""), + lnum: i64::try_from(lnum).unwrap_or(i64::MAX), + col: i64::try_from(found.start.byte.saturating_add(1)) + .unwrap_or(i64::MAX), + end_lnum: i64::try_from(lnum).unwrap_or(i64::MAX), + end_col: i64::try_from(found.end.byte.saturating_add(1)) + .unwrap_or(i64::MAX), + vcol: 0, + nr: 0, + pattern: OxStr::from(""), + text: OxStr::from(line.as_slice()), + item_type: OxStr::from(""), + valid: true, + user_data: Typval::Special(Special::Null), + }); + tomatch -= 1; + if tomatch == 0 { + break; + } + // Without `g` only the first match per line is listed; + // `col == endcol` steps one byte past a zero-width match + // so `^`/`$` cannot loop forever (vgr_match_buflines). + if !split.global { + break; + } + cursor = found.end.byte + usize::from(found.end.byte == found.start.byte); + if cursor > line.len() { + break; + } + } + } + } + } + access.with_ex_editor(|editor| { + if let Some(list) = qf_scope.stack_mut(editor).current_mut() { + if add { + list.append_items(entries); + } else { + list.set_items(entries); + } + } + }); + let empty = access.with_ex_editor(|editor| { + qf_scope + .stack(editor) + .and_then(crate::quickfix::QuickfixStack::current) + .is_none_or(|list| list.items().is_empty()) + }); + if empty { + return error_flow(runtime, "E480", format!("No match: {pattern}")); + } + if split.nojump { + return Flow::Normal; + } + match access.with_ex_editor(|editor| { + crate::quickfix::jump( + editor, + runtime.scripts.io(), + qf_scope, + crate::quickfix::QuickfixMove::First, + command.bang, + ) + }) { + Ok(()) => Flow::Normal, + Err(error) => error_flow(runtime, error.code, error.message), + } +} + /// `:cbuffer`, `:cgetbuffer`, `:caddbuffer`: load entries from lines of a /// buffer (`qf_init_from_buffer`). fn command_quickfix_buffer( diff --git a/crates/ox-editor/src/excmd_exec_editor_tests.rs b/crates/ox-editor/src/excmd_exec_editor_tests.rs index 7f6d73d9..773904eb 100644 --- a/crates/ox-editor/src/excmd_exec_editor_tests.rs +++ b/crates/ox-editor/src/excmd_exec_editor_tests.rs @@ -2823,6 +2823,163 @@ fn a_quit_inside_a_sourced_script_runs_the_exit_sequence() { assert!(global_flag(&executor, "early")); } +// --------------------------------------------------------------------------- +// :scriptnames +// Citations: runtime.c ex_scriptnames (listing loop, SID edit, E474); +// ex_docmd.c set_cmd_count (post-command count folds into the address), +// correct_range (address 0 counts as 1 without ZEROR). +// --------------------------------------------------------------------------- + +/// `:scriptnames` lists every sourced script as `%3d: name` in SID order. +/// Upstream: `runtime.c` `ex_scriptnames` listing loop. +#[test] +fn scriptnames_lists_sourced_scripts_in_sid_order() { + let (editor, mut executor) = setup(); + executor + .execute_script(&editor, "a.vim", "let g:xa = 1") + .unwrap(); + executor + .execute_script(&editor, "b.vim", "let g:xb = 1") + .unwrap(); + executor.execute_line(&editor, "scriptnames").unwrap(); + assert_eq!(echo_messages(&editor), vec![" 1: a.vim", " 2: b.vim"]); +} + +/// `:scriptnames` replaces a `$HOME` prefix with `~` (`home_replace` in +/// `path.c`); without `$HOME` the path lists unchanged. +#[test] +fn scriptnames_replaces_home_prefix_with_tilde() { + let (editor, mut executor) = setup(); + let home = std::env::var("HOME").unwrap_or_default(); + let (name, expected) = if home.is_empty() { + ("sn_home.vim".to_owned(), "sn_home.vim".to_owned()) + } else { + (format!("{home}/sn_home.vim"), "~/sn_home.vim".to_owned()) + }; + executor + .execute_script(&editor, &name, "let g:xh = 1") + .unwrap(); + executor.execute_line(&editor, "scriptnames").unwrap(); + assert_eq!(echo_messages(&editor), vec![format!(" 1: {expected}")]); +} + +/// `:scriptnames {id}` and `:{id}scriptnames` edit that script through the +/// `:edit` path (`do_exedit`). +#[test] +fn scriptnames_count_and_range_edit_indexed_script() { + let (editor, mut executor) = setup(); + executor.scripts().io().insert("a.vim", "echo 'a'"); + executor.scripts().io().insert("b.vim", "echo 'b'"); + executor + .execute_script(&editor, "a.vim", "let g:xa = 1") + .unwrap(); + executor + .execute_script(&editor, "b.vim", "let g:xb = 1") + .unwrap(); + executor.execute_line(&editor, "scriptnames 2").unwrap(); + let current = editor.editor().current_buffer().unwrap(); + assert_eq!( + editor + .editor() + .buffer(current) + .unwrap() + .name() + .to_string_lossy(), + "b.vim" + ); + executor.execute_line(&editor, "1scriptnames").unwrap(); + let current = editor.editor().current_buffer().unwrap(); + assert_eq!( + editor + .editor() + .buffer(current) + .unwrap() + .name() + .to_string_lossy(), + "a.vim" + ); +} + +/// `:scriptnames {file}` edits that file, exactly what `:edit` would open. +#[test] +fn scriptnames_bare_filename_edits_file() { + let (editor, mut executor) = setup(); + executor.scripts().io().insert("other.vim", "echo 'other'"); + executor + .execute_script(&editor, "a.vim", "let g:xa = 1") + .unwrap(); + executor + .execute_line(&editor, "scriptnames other.vim") + .unwrap(); + let current = editor.editor().current_buffer().unwrap(); + assert_eq!( + editor + .editor() + .buffer(current) + .unwrap() + .name() + .to_string_lossy(), + "other.vim" + ); +} + +// --------------------------------------------------------------------------- +// Script-local scope across nested calls +// Upstream keeps one `s:` dict per script (`svars`), mutated in place, so a +// function always sees its defining script's live variables — even when that +// script is still executing further down the stack. Oxvim mirrors the visible +// map into `scope.script`, so the switch has to flush the outgoing frame into +// its own SID before loading the incoming one; loading the registry copy +// without flushing hides mid-execution assignments (`s:has_ffi` going +// missing inside `Ntest_override`). +// --------------------------------------------------------------------------- + +/// A function defined in a script observes that script's live `s:` map when +/// called from another script's function while the defining script is still +/// executing on the stack. +#[test] +fn nested_function_call_sees_defining_scripts_live_script_locals() { + let (editor, mut executor) = setup(); + executor + .scripts() + .io() + .insert("inner.vim", "func Inner()\nreturn Outer()\nendfunc\n"); + executor + .execute_script( + &editor, + "outer.vim", + "let s:token = 7\nfunc Outer()\nreturn s:token\nendfunc\nsource inner.vim\nlet g:seen = Inner()\n", + ) + .unwrap(); + assert_eq!( + executor + .scope() + .get_scoped(ox_eval::scope::ScopeKind::Global, b"seen", 0) + .unwrap(), + &ox_types::Typval::Number(7) + ); +} + +/// `:scriptnames {id}` past the registry is E474 (`e_invarg`); the oracle +/// prints `E474: Invalid argument` for both the count and the range form. +#[test] +fn scriptnames_out_of_range_id_reports_invalid_argument() { + let (editor, mut executor) = setup(); + executor + .execute_script(&editor, "a.vim", "let g:xa = 1") + .unwrap(); + for line in ["scriptnames 99", "99scriptnames"] { + let err = executor.execute_line(&editor, line).unwrap_err(); + match err { + ExecError::Vim(exception) => { + assert_eq!(exception.kind, VimExceptionKind::Error("E474".to_owned())); + assert_eq!(exception.emsg_text(), "E474: Invalid argument"); + } + other => panic!("expected E474, got {other:?}"), + } + } +} + // --------------------------------------------------------------------------- // :command definition / bang replacement / execution / deletion // Citations: ex_docmd.c ex_command (E174, E183), test_usercommands.vim @@ -9366,10 +9523,7 @@ fn depth_zero_script_error_displays_and_aborts() { "call NoSuchFunc123()\nlet g:after = 1\n", ) .unwrap_err(); - assert!( - error.to_string().contains("NoSuchFunc123"), - "got {error}" - ); + assert!(error.to_string().contains("NoSuchFunc123"), "got {error}"); assert!(!global_flag(&executor, "after")); assert!(executor.did_emsg()); } diff --git a/crates/ox-editor/src/excmd_exec_function_tests.rs b/crates/ox-editor/src/excmd_exec_function_tests.rs index f37fe21f..89b4514e 100644 --- a/crates/ox-editor/src/excmd_exec_function_tests.rs +++ b/crates/ox-editor/src/excmd_exec_function_tests.rs @@ -2071,6 +2071,85 @@ fn window_screen_and_state_builtins_use_editor_state() { assert_eq!(global_string(exec.scope(), "text").as_deref(), Some("口")); } +/// Blank screen cells are spaces, not holes: upstream's default grid answers +/// every in-range coordinate (`eval/funcs.c:5711-5729`), so a 24x80 screen +/// reports 32 for window rows past the buffer's last line and for grid rows +/// no window covers, and -1 only outside the grid. `Screenline(&lines - 1)` +/// in the oldtest suite feeds those cells straight into `nr2char()`, where +/// a -1 surfaces as `E5070: Character number must not be less than zero`. +#[test] +fn screen_builtins_answer_the_whole_grid_with_blanks() { + let editor = TestEditorAccess::new(Editor::new()); + let buffer = editor.editor_mut().create_buffer(true).unwrap(); + let _tabpage = editor + .editor_mut() + .create_tabpage(buffer, Geometry::new(0, 0, 20, 6).unwrap()) + .unwrap(); + let mut exec = ExExecutor::new(); + + exec.execute_script( + &editor, + "builtins.vim", + concat!( + "set lines=24 columns=80\n", + "call setline(1, 'ab')\n", + // Inside the window, past the end of the line and past the last + // buffer line: blanks, never -1. + "let g:past_eol = screenchar(1, 3)\n", + "let g:past_eof = screenchar(2, 1)\n", + "let g:window_edge = screenchar(6, 20)\n", + // In-grid rows no window covers (status-line and message rows): + // blanks with default attributes. + "let g:uncovered = screenchar(23, 1)\n", + "let g:last_cell = screenchar(24, 80)\n", + "let g:uncovered_attr = screenattr(23, 1)\n", + "let g:uncovered_string = screenstring(23, 1)\n", + "let g:uncovered_chars = screenchars(23, 1)\n", + // Outside the grid: no cell at all. + "let g:beyond_rows = screenchar(25, 1)\n", + "let g:beyond_cols = screenchar(1, 81)\n", + "let g:row_zero = screenchar(0, 1)\n", + "let g:col_zero = screenchar(1, 0)\n", + "let g:beyond_attr = screenattr(25, 1)\n", + "let g:beyond_string = screenstring(25, 1)\n", + "let g:beyond_chars = screenchars(25, 1)\n", + ), + ) + .unwrap(); + + assert_eq!(global_number(exec.scope(), "past_eol"), Some(32)); + assert_eq!(global_number(exec.scope(), "past_eof"), Some(32)); + assert_eq!(global_number(exec.scope(), "window_edge"), Some(32)); + assert_eq!(global_number(exec.scope(), "uncovered"), Some(32)); + assert_eq!(global_number(exec.scope(), "last_cell"), Some(32)); + assert_eq!(global_number(exec.scope(), "uncovered_attr"), Some(0)); + assert_eq!( + global_string(exec.scope(), "uncovered_string").as_deref(), + Some(" ") + ); + assert_eq!( + exec.scope() + .get_scoped(ScopeKind::Global, b"uncovered_chars", 0) + .unwrap(), + &Typval::list(vec![Typval::Number(32)]) + ); + assert_eq!(global_number(exec.scope(), "beyond_rows"), Some(-1)); + assert_eq!(global_number(exec.scope(), "beyond_cols"), Some(-1)); + assert_eq!(global_number(exec.scope(), "row_zero"), Some(-1)); + assert_eq!(global_number(exec.scope(), "col_zero"), Some(-1)); + assert_eq!(global_number(exec.scope(), "beyond_attr"), Some(-1)); + assert_eq!( + global_string(exec.scope(), "beyond_string").as_deref(), + Some("") + ); + assert_eq!( + exec.scope() + .get_scoped(ScopeKind::Global, b"beyond_chars", 0) + .unwrap(), + &Typval::list(vec![]) + ); +} + #[test] fn higher_order_callbacks_preserve_editor_host() { let editor = TestEditorAccess::new(Editor::new()); @@ -3511,11 +3590,10 @@ fn sourced_script_error_displays_and_aborts() { ); let editor = TestEditorAccess::new(Editor::new()); let mut exec = ExExecutor::with_io(io); - let error = exec.source_file(&editor, "/inner.vim".as_ref()).unwrap_err(); - assert!( - error.to_string().contains("NoSuchFunc123"), - "got {error}" - ); + let error = exec + .source_file(&editor, "/inner.vim".as_ref()) + .unwrap_err(); + assert!(error.to_string().contains("NoSuchFunc123"), "got {error}"); assert_eq!(global_number(exec.scope(), "inner_after"), None); assert!(exec.did_emsg()); } diff --git a/crates/ox-editor/src/excmd_exec_regex_tests.rs b/crates/ox-editor/src/excmd_exec_regex_tests.rs index ef699d02..a750530d 100755 --- a/crates/ox-editor/src/excmd_exec_regex_tests.rs +++ b/crates/ox-editor/src/excmd_exec_regex_tests.rs @@ -101,10 +101,14 @@ fn global_missing_pattern_reports_e148() { #[test] fn global_invalid_regex_reports_e54() { - // Unbalanced `[` fails regex compilation (`regexp.c`). + // An unmatched `\(` fails regex compilation (`regexp.c`); a lone `[` is a + // literal bracket and must not fail. let (editor, _buffer, _window, mut executor) = setup("foo\nbar"); - let error = executor.execute_line(&editor, r"g/[/d").unwrap_err(); + let error = executor.execute_line(&editor, r"g/\(/d").unwrap_err(); assert!(error.to_string().contains("E54")); + let (editor, _buffer, _window, mut executor) = setup("foo\nbar"); + let error = executor.execute_line(&editor, r"g/[z-a]/d").unwrap_err(); + assert!(error.to_string().contains("E944")); } #[test] diff --git a/crates/ox-editor/src/excmd_exec_state_tests.rs b/crates/ox-editor/src/excmd_exec_state_tests.rs index 49217184..d7f5771a 100644 --- a/crates/ox-editor/src/excmd_exec_state_tests.rs +++ b/crates/ox-editor/src/excmd_exec_state_tests.rs @@ -1065,10 +1065,10 @@ fn global_menu_cleanup_succeeds_for_empty_menu_state() { exec.execute_line(&editor, "aunmenu *").unwrap(); exec.execute_line(&editor, "tlunmenu *").unwrap(); - assert!(matches!( - exec.execute_line(&editor, "aunmenu File.Open"), - Err(crate::ExecError::NotImplemented(command)) if command == "aunmenu" - )); + // A builtin without a handler arm reports the standard unknown-command + // error rather than a function-call error. + let result = exec.execute_line(&editor, "aunmenu File.Open"); + assert_vim_error(result, "E492"); } // ex_docmd.c:ex_echo — `:echo "hello"` produces an Echo message without @@ -1882,17 +1882,21 @@ fn highlight_default_and_link_forms_retain_definitions() { // ── unsupported-command NotImplemented identity ──────────────────────── // ex_docmd.c:do_one_cmd dispatch — a builtin command not in the handler -// table returns NotImplemented(name) rather than a silent no-op. +// table reports the standard unknown-command error rather than a silent +// no-op or a function-call error. +// ":sort" used to stand in here; it is dispatched now, so the probe +// moved to ":move", which the handler table still does not carry. #[test] -fn unimplemented_builtin_returns_not_implemented() { +fn unimplemented_builtin_reports_unknown_command_error() { let editor = TestEditorAccess::new(Editor::new()); let mut exec = ExExecutor::new(); - // ":sort" used to stand in here; it is dispatched now, so the probe - // moved to ":move", which the handler table still does not carry. let err = exec.execute_line(&editor, "move").unwrap_err(); match err { - ExecError::NotImplemented(name) => assert_eq!(name, "move"), - other => panic!("expected NotImplemented, got {other:?}"), + ExecError::Vim(exception) => { + assert_eq!(exception.kind, VimExceptionKind::Error("E492".to_owned())); + assert_eq!(exception.emsg_text(), "E492: Not an editor command: move"); + } + other => panic!("expected E492, got {other:?}"), } } @@ -2303,6 +2307,125 @@ fn put_expression_evaluates_and_inserts_expression_register() { assert_eq!(text.line(2).unwrap(), b"screen too small"); } +/// `:put ={expr}` keeps the expression register form anywhere on the line: +/// an indented command and one following a bar both parse the `=` rest as +/// the expression instead of raising `E488: Trailing characters`. +#[test] +fn put_expression_parses_with_indent_and_after_bar() { + let (editor, buffer, _) = editor_with_window(); + let editor = TestEditorAccess::new(editor); + let mut exec = ExExecutor::new(); + exec.execute_line(&editor, "call setline(1, ['do'])") + .unwrap(); + exec.execute_line(&editor, " put =1+1").unwrap(); + exec.execute_line(&editor, "echo 1 | put =2+2").unwrap(); + + let ed = editor.editor(); + let text = ed.buffer(buffer).unwrap().text().unwrap(); + // `:put` inserts after the cursor line, so the second put lands first. + assert_eq!(text.line(1).unwrap(), b"do"); + assert_eq!(text.line(2).unwrap(), b"4"); + assert_eq!(text.line(3).unwrap(), b"2"); +} + +/// A bare `:put =` evaluates the previously stored expression if there is +/// one, and is a silent no-op otherwise (upstream `get_spec_reg` returns +/// NULL instead of raising E353). +#[test] +fn put_empty_expression_reuses_stored_expression() { + let (editor, buffer, _) = editor_with_window(); + let editor = TestEditorAccess::new(editor); + let mut exec = ExExecutor::new(); + exec.execute_line(&editor, "call setline(1, ['do'])") + .unwrap(); + exec.execute_line(&editor, "put =").unwrap(); + exec.execute_line(&editor, "put =40+2").unwrap(); + exec.execute_line(&editor, "put =").unwrap(); + + let ed = editor.editor(); + let text = ed.buffer(buffer).unwrap().text().unwrap(); + assert_eq!(text.line(2).unwrap(), b"42"); + assert_eq!(text.line(3).unwrap(), b"42"); + assert_eq!(text.line_count(), 3); +} + +/// `parse_register` only takes a digit as a register when the command has +/// no count (`ex_docmd.c:1352`): `:delete 2` deletes two lines instead of +/// yanking one line into register `2`. +#[test] +fn delete_leading_digit_is_count_not_register() { + let (editor, buffer, _) = editor_with_window(); + let editor = TestEditorAccess::new(editor); + let mut exec = ExExecutor::new(); + exec.execute_line(&editor, "call setline(1, ['a', 'b', 'c', 'd'])") + .unwrap(); + exec.execute_line(&editor, "delete 2").unwrap(); + + let ed = editor.editor(); + let text = ed.buffer(buffer).unwrap().text().unwrap(); + assert_eq!(text.line_count(), 2); + assert_eq!(text.line(1).unwrap(), b"c"); +} + +/// `:tabdo` runs its nested command once per tabpage and leaves the cursor +/// on the last tabpage visited. +#[test] +fn tabdo_runs_nested_command_in_each_tabpage() { + let (editor, _, _) = editor_with_window(); + let editor = TestEditorAccess::new(editor); + let mut exec = ExExecutor::new(); + exec.execute_line(&editor, "tabnew").unwrap(); + exec.execute_line(&editor, "tabnew").unwrap(); + exec.execute_line(&editor, "tabdo let g:seen = get(g:, 'seen', []) + [tabpagenr()]") + .unwrap(); + + let seen = exec.scope().get_scoped(ScopeKind::Global, b"seen", 0); + let seen = match seen { + Ok(Typval::List(reference)) => reference + .borrow() + .items + .iter() + .filter_map(|item| match item { + Typval::Number(number) => Some(*number), + _ => None, + }) + .collect::>(), + other => panic!("expected g:seen list, got {other:?}"), + }; + assert_eq!(seen, vec![1, 2, 3]); +} + +/// `:vimgrep` collects one entry per matching line and jumps to the first +/// match; without `!` a 'winfixbuf' window refuses with E1513. +#[test] +fn vimgrep_builds_list_and_jumps_to_first_match() { + let _guard = crate::lock_process_state(); + let root = std::env::temp_dir().join(format!("oxvim-vimgrep-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("a.txt"), "one\ntwo\n").unwrap(); + std::fs::write(root.join("b.txt"), "three\n").unwrap(); + + let (editor, _, _) = editor_with_window(); + let editor = TestEditorAccess::new(editor); + let mut exec = ExExecutor::new(); + let pattern = format!("vimgrep /t/ {}/*.txt", root.display()); + exec.execute_line(&editor, &pattern).unwrap(); + + let ed = editor.editor(); + let stack = ed.quickfix(); + let list = stack.current().expect("quickfix list"); + assert_eq!(list.items().len(), 2); + assert_eq!(list.items()[0].lnum, 2); + assert_eq!(list.items()[1].lnum, 1); + assert_eq!( + ed.current_buffer() + .and_then(|buffer| ed.buffer(buffer).ok()) + .map(|state| state.name().to_string_lossy().into_owned()), + Some(root.join("a.txt").to_string_lossy().into_owned()) + ); + std::fs::remove_dir_all(&root).unwrap(); +} + #[test] fn writable_vim_variables_match_upstream_table() { for name in [ @@ -2402,6 +2525,65 @@ fn luaeval_rejects_wrong_argument_counts() { assert!(error.to_string().contains("E118"), "{}", error); } +/// `function('g:Name')` accepts a `g:`-prefixed global function name the way +/// upstream's `common_function` does (`eval/userfunc.c:1732-1735`, `1856`): +/// the existence check runs on the stripped name while the reference keeps +/// the written spelling, so the resulting Funcref calls the global function. +/// `function('g:Nope')` is still `E700`, and a `g:`-prefixed builtin stays +/// `E117` at call time because `call_func` consults the internal table with +/// the original name. +#[test] +fn function_accepts_global_prefixed_function_name() { + let editor = TestEditorAccess::new(Editor::new()); + let mut exec = ExExecutor::new(); + exec.execute_script( + &editor, + "global.vim", + "func GlobalFn(nr)\nreturn a:nr + 1\nendfunc\nlet g:made = function('g:GlobalFn')\nlet g:called = g:made(41)", + ) + .unwrap(); + assert_eq!( + exec.scope() + .get_scoped(ScopeKind::Global, b"called", 0) + .unwrap(), + &Typval::Number(42), + ); + let made = exec + .scope() + .get_scoped(ScopeKind::Global, b"made", 0) + .unwrap(); + let ox_types::Typval::Funcref(made) = made else { + panic!("expected function('g:GlobalFn') to build a Funcref, got {made:?}"); + }; + assert_eq!(made.name.as_bytes(), b"g:GlobalFn"); + assert!(made.args.is_empty()); + assert!(made.dict.is_none()); + assert!(made.registry.is_none()); + let error = exec + .execute_line(&editor, "echo function('g:Nope')") + .unwrap_err(); + assert!(error.to_string().contains("E700"), "{error}"); + assert!( + error.to_string().contains("Unknown function: g:Nope"), + "{error}" + ); +} + +/// A `g:`-prefixed builtin name passes the `function()` existence check but +/// stays uncallable: upstream reports `E117` with the written name, since the +/// internal dispatch never sees the stripped spelling. +#[test] +fn function_of_global_prefixed_builtin_stays_uncallable() { + let editor = TestEditorAccess::new(Editor::new()); + let mut exec = ExExecutor::new(); + exec.execute_script( + &editor, + "global.vim", + "let g:made = function('g:printf')\nlet g:called = g:made('%d', 1)", + ) + .unwrap_err(); +} + #[test] fn luaeval_load_and_runtime_errors_use_upstream_codes() { let editor = TestEditorAccess::new(Editor::new()); diff --git a/crates/ox-excmd/src/lib.rs b/crates/ox-excmd/src/lib.rs index a70b4c1c..b4080e4d 100644 --- a/crates/ox-excmd/src/lib.rs +++ b/crates/ox-excmd/src/lib.rs @@ -15,8 +15,8 @@ pub use command::{ pub use expand::{CmdlineContext, CmdlineSpecial, ExpansionPart, expand_with, scan_expansions}; pub use parser::{ Address, AddressBase, CommandModifier, ErrorCode, ExCommand, ModifierKind, ParseError, Parser, - PreviewMagic, PreviewPattern, Range, RangeKind, RangeSeparator, effective_addr_type, - effective_flags, parse_modifiers, parse_preview_pattern, + PreviewMagic, PreviewPattern, Range, RangeKind, RangeSeparator, VimgrepArgs, effective_addr_type, + effective_flags, parse_modifiers, parse_preview_pattern, split_vimgrep_args, }; impl ExCommand { diff --git a/crates/ox-excmd/src/parser.rs b/crates/ox-excmd/src/parser.rs index 112a8718..6ce6146a 100644 --- a/crates/ox-excmd/src/parser.rs +++ b/crates/ox-excmd/src/parser.rs @@ -372,7 +372,11 @@ impl<'a, P: UserCommandProvider + ?Sized> Parser<'a, P> { cursor = skip_ascii_space(input, cursor); let range_offset = cursor; let range = parse_range(input, &mut cursor)?; - cursor = skip_ascii_space(input, cursor); + // `ea.cmd = skip_colon_white(ea.cmd, true)` (ex_docmd.c:2093-2094): + // after the addresses, any mix of white space and `:` still separates + // the range from the command, so `:1,2:s/x/y/` runs the substitute + // rather than reporting a range with no command. + cursor = skip_space_and_colons(input, cursor); let command_offset = cursor; let unknown_command = || { @@ -516,7 +520,7 @@ impl<'a, P: UserCommandProvider + ?Sized> Parser<'a, P> { args = args.replace("\\\n", "\n"); } let register = if flags.contains(CommandFlags::REGSTR) { - take_register(&mut args) + take_register(&mut args, flags, command) } else { None }; @@ -546,7 +550,12 @@ impl<'a, P: UserCommandProvider + ?Sized> Parser<'a, P> { "Argument required", )); } + // The `=` expression is not trailing text: `parse_register` consumed + // the rest of the argument into the expression, leaving `ea.arg` + // empty — which is why `:put =lineA` never raises E488. + let expression_register = register == Some('='); if !flags.contains(CommandFlags::EXTRA) + && !expression_register && !matches!(command.name(), "append" | "change" | "insert") && !args.trim().is_empty() { @@ -637,7 +646,10 @@ fn is_one_letter_command(bytes: &[u8], start: usize) -> bool { /// # Errors /// /// Returns an error when a modifier is malformed. -pub fn parse_modifiers(input: &str, cursor: &mut usize) -> Result, ParseError> { +pub fn parse_modifiers( + input: &str, + cursor: &mut usize, +) -> Result, ParseError> { let mut modifiers = Vec::new(); loop { let saved = *cursor; @@ -747,7 +759,12 @@ pub fn parse_modifiers(input: &str, cursor: &mut usize) -> Result bool { matches!(name, "vimgrep" | "lvimgrep" | "vimgrepadd" | "lvimgrepadd") } +/// A split `:vimgrep`-family argument: the search pattern, the trailing +/// `g`/`j`/`f` flags, and the file list that follows +/// (`skip_vimgrep_pat` plus the flag scan, ex_cmds.c:4972-5040). +pub struct VimgrepArgs<'a> { + /// The search pattern text (empty for `//`, which reuses the last + /// search pattern). + pub pattern: &'a str, + /// `g`: list every match on a line, not just the first. + pub global: bool, + /// `j`: collect matches without jumping to the first one. + pub nojump: bool, + /// `f`: fuzzy match (parsed here; evaluation is the executor's job). + pub fuzzy: bool, + /// The raw file list following the pattern and flags. + pub files: &'a str, +} + +/// Splits a `:vimgrep`-family argument into pattern, flags, and file list. +/// Returns `None` when no pattern can be scanned (`skip_vimgrep_pat` +/// returning NULL, which the caller reports as E682). +#[must_use] +pub fn split_vimgrep_args(args: &str) -> Option> { + let start = skip_ascii_space(args, 0); + let bytes = args.as_bytes(); + let first = *bytes.get(start)?; + let (pattern, mut cursor) = if !first.is_ascii() || first.is_ascii_alphanumeric() || first == b'_' + { + // Bare-word pattern up to whitespace (`:vimgrep foo fname`). + let mut end = start; + while bytes + .get(end) + .is_some_and(|byte| !byte.is_ascii_whitespace()) + { + end += 1; + } + if end == start { + return None; + } + (&args[start..end], end) + } else { + // Delimited pattern; `parse_pattern` consumes the closing + // delimiter (`:vimgrep /pat/ files`). + // + // The pattern text must borrow from `args`, so re-scan the same + // span the parser found instead of cloning it. + let after = parse_pattern(args, start, first, true).ok()?.1; + let raw = &args[start..after]; + let pattern = raw + .strip_prefix(char::from(first)) + .and_then(|rest| rest.strip_suffix(char::from(first))) + .unwrap_or(raw); + (pattern, after) + }; + let (mut global, mut nojump, mut fuzzy) = (false, false, false); + while let Some(&byte) = bytes.get(cursor) { + match byte { + b'g' => global = true, + b'j' => nojump = true, + b'f' => fuzzy = true, + _ => break, + } + cursor += 1; + } + let files = args[cursor..].trim_start(); + if files.is_empty() { + return None; + } + Some(VimgrepArgs { + pattern, + global, + nojump, + fuzzy, + files, + }) +} + /// Returns the cursor just past a vimgrep-family leading pattern, or /// `args_start` when no pattern can be skipped (`skip_grep_pat`: `ex_docmd.c` /// 3840-3854; used by `separate_nextcmd` at `ex_docmd.c:4114`). @@ -1173,9 +1266,7 @@ fn shell_arg_family(name: &str, flags: CommandFlags, usefilter: bool) -> bool { /// swallows the text this command was read from, so the last separator /// before `before` is always the real chunk boundary. fn chunk_ws(input: &str, before: usize) -> &str { - let chunk_start = input[..before] - .rfind(['|', '\n']) - .map_or(0, |pos| pos + 1); + let chunk_start = input[..before].rfind(['|', '\n']).map_or(0, |pos| pos + 1); let indent_len = input[chunk_start..] .bytes() .take_while(|byte| matches!(byte, b' ' | b'\t')) @@ -1201,8 +1292,7 @@ fn command_end( // `separate_nextcmd` scans a `TRLBAR` argument for `|`/comment/newline // (ex_docmd.c:4130-4178). The substitute family shares the scan so a // separator after its flags still splits the command. - if flags.contains(CommandFlags::TRLBAR) - || matches!(name, "substitute" | "smagic" | "snomagic") + if flags.contains(CommandFlags::TRLBAR) || matches!(name, "substitute" | "smagic" | "snomagic") { return trlbar_command_end(input, args_start, flags, name); } @@ -1297,9 +1387,7 @@ fn newline_command_end(input: &str, args_start: usize, continuation: bool) -> us let bytes = input.as_bytes(); let mut cursor = args_start; while let Some(&byte) = bytes.get(cursor) { - if byte == b'\n' - && !(continuation && cursor > args_start && bytes[cursor - 1] == b'\\') - { + if byte == b'\n' && !(continuation && cursor > args_start && bytes[cursor - 1] == b'\\') { return cursor; } cursor += 1; @@ -1310,12 +1398,7 @@ fn newline_command_end(input: &str, args_start: usize, continuation: bool) -> us /// The `separate_nextcmd` scan for a `TRLBAR` argument: the command ends at /// `|` (except :append/:change/:insert), a `"` comment (except `EX_NOTRLCOM`), /// or a newline (ex_docmd.c:4130-4178). -fn trlbar_command_end( - input: &str, - args_start: usize, - flags: CommandFlags, - name: &str, -) -> usize { +fn trlbar_command_end(input: &str, args_start: usize, flags: CommandFlags, name: &str) -> usize { let bar_breaks = !matches!(name, "append" | "change" | "insert"); let bytes = input.as_bytes(); let mut escaped = false; @@ -1496,10 +1579,7 @@ fn heredoc_command_end(input: &str, after_shift: usize, args_start: usize) -> us // A missing or invalid marker fails inside `heredoc_get` before any body // line is consumed: the command owns the rest of the string and the // handler reports E172/E221/E991. - if marker.is_empty() - || marker.starts_with('"') - || marker.as_bytes()[0].is_ascii_lowercase() - { + if marker.is_empty() || marker.starts_with('"') || marker.as_bytes()[0].is_ascii_lowercase() { return input.len(); } // `trim` allows the terminator to repeat the whitespace prefix of @@ -1531,55 +1611,89 @@ fn is_comment_quote_exception( bytes: &[u8], args_start: usize, cursor: usize, - flags: CommandFlags, + _flags: CommandFlags, command_name: &str, ) -> bool { - is_initial_quoted_register(bytes, args_start, cursor, flags) - || (command_name == "@" && cursor == args_start) + // `separate_nextcmd` leaves only the `@"` and `redir @"` quote forms in + // the argument (ex_docmd.c:4165-4178): a register-argument command never + // quotes its register, so `:delete "a` comments out the rest of the line + // exactly like upstream (`:delete` has no `NOTRLCOM`). + (command_name == "@" && cursor == args_start) || (command_name == "redir" && cursor == args_start + 1 && bytes.get(args_start) == Some(&b'@')) } -fn is_initial_quoted_register( - bytes: &[u8], - args_start: usize, - cursor: usize, - flags: CommandFlags, -) -> bool { - flags.contains(CommandFlags::REGSTR) - && cursor == args_start - && bytes - .get(cursor + 1) - .copied() - .is_some_and(|byte| is_register(char::from(byte))) +/// `valid_yank_reg(regname, writing)` (register.c:153-165): named and +/// numbered registers work everywhere. A writing command (`:delete`, +/// `:yank`) may only name the writable set; a reading command +/// (`:put`/`:iput`) additionally takes the read-only `/`, `#`, `.`, `%`, +/// `:`, `=` sources. +fn valid_writing_register(register: char) -> bool { + valid_yank_register_chars(register, "") +} + +fn valid_reading_register(register: char) -> bool { + valid_yank_register_chars(register, "/#.%:=") } -fn take_register(args: &mut String) -> Option { +fn valid_yank_register_chars(register: char, read_only: &str) -> bool { + if register.is_ascii_alphanumeric() { + return true; + } + if read_only.contains(register) { + return true; + } + matches!(register, '"' | '-' | '_' | '*' | '+') +} + +/// `parse_register` (ex_docmd.c:1348-1369): the register is the argument's +/// first character, with `=` taking the rest of the argument as its +/// expression — which is why `:put =lineA` parses in any position, while +/// `:delete =x` reports the `=` as trailing characters and a leading digit +/// on a count command is the count, never a register (`:delete 2`, +/// `:yank 0` → E939). +fn take_register( + args: &mut String, + flags: CommandFlags, + command: &ResolvedCommand, +) -> Option { let trimmed = args.trim_start(); let skipped = args.len() - trimmed.len(); - let mut chars = trimmed.char_indices(); - let (first_offset, first) = chars.next()?; - let (register, consumed) = if first == '"' { - let (offset, register) = chars.next()?; - (register, offset + register.len_utf8()) + let first = trimmed.chars().next()?; + // "Accept numbered register only when no count allowed (:put)" + // (ex_docmd.c:1352): a digit leading a count command's argument is the + // count (`:delete 2` deletes two lines, `:yank 0` is E939). + if flags.contains(CommandFlags::COUNT) && first.is_ascii_digit() { + return None; + } + // `valid_yank_reg(*arg, !IS_USER && cmd != put && cmd != iput)`: + // reading commands (`:put`/`:iput`, and user commands) also take the + // read-only `/`, `#`, `.`, `%`, `:`, `=` names, while writing commands + // (`:delete`/`:yank`) reject them as trailing text. + let is_user = matches!(command, ResolvedCommand::User(_)); + let reading = is_user || matches!(command.name(), "put" | "iput"); + // "Do not allow register = for user commands" (ex_docmd.c:1353). + if first == '=' && (is_user || !reading) { + return None; + } + let valid = if reading { + valid_reading_register(first) } else { - let next = chars.next(); - if next.is_some_and(|(_, character)| !character.is_ascii_whitespace()) { - return None; - } - (first, first_offset + first.len_utf8()) + valid_writing_register(first) }; - if !is_register(register) { + if !valid { return None; } - args.drain(..skipped + consumed); + args.drain(..skipped + first.len_utf8()); + if first == '=' { + // `parse_register`'s `=` case: the rest of the line is the + // expression, so it stays verbatim in `args` and is never trailing + // text (upstream moves it to `expr_line`, leaving `ea.arg` empty). + return Some('='); + } *args = args.trim_start().to_owned(); - Some(register) -} - -fn is_register(character: char) -> bool { - character.is_ascii_alphanumeric() || "\"-:.%#=*+_/@".contains(character) + Some(first) } /// `parse_count` (`ex_docmd.c:1395-1430`): a leading digit run on a `COUNT` diff --git a/crates/ox-excmd/src/tests.rs b/crates/ox-excmd/src/tests.rs index ef87ff76..eb1b0f3f 100644 --- a/crates/ox-excmd/src/tests.rs +++ b/crates/ox-excmd/src/tests.rs @@ -533,6 +533,53 @@ fn filter_modifier_pattern_may_abut_the_command() { assert_eq!(command.command.name(), "print"); } +// A colon may separate a modifier from the command it qualifies: upstream +// re-skips space, tab and `:` at the top of every modifier iteration +// (parse_command_modifiers, ex_docmd.c:2494-2499), so `:unsilent :norm! n` +// parses as the `unsilent` modifier applied to `:norm! n` — not as a command +// named "unsilent" with trailing text. +#[test] +fn modifier_followed_by_colon_still_qualifies_the_command() { + let command = parse_one("unsilent :echo 1"); + assert_eq!(command.modifiers.len(), 1); + assert_eq!(command.modifiers[0].kind, ModifierKind::Unsilent); + assert_eq!(command.command.name(), "echo"); + assert_eq!(command.args, "1"); +} + +#[test] +fn silent_bang_followed_by_colon_still_qualifies_the_command() { + let command = parse_one("silent! :norm! n"); + assert_eq!(command.modifiers.len(), 1); + assert_eq!(command.modifiers[0].kind, ModifierKind::Silent); + assert!(command.modifiers[0].bang); + assert_eq!(command.command.name(), "normal"); + assert_eq!(command.args, "n"); +} + +#[test] +fn consecutive_colons_between_modifiers_are_skipped() { + let command = parse_one("silent :silent :echo 1"); + let kinds: Vec<_> = command + .modifiers + .iter() + .map(|modifier| modifier.kind) + .collect(); + assert_eq!(kinds, [ModifierKind::Silent, ModifierKind::Silent]); + assert_eq!(command.command.name(), "echo"); +} + +// `ea.cmd = skip_colon_white(ea.cmd, true)` (ex_docmd.c:2093-2094): a colon +// may follow the address range, so `:1,2:s/x/y/` is a substitute over lines +// 1-2, never a range-only command with the rest silently dropped. +#[test] +fn colon_between_range_and_command_is_skipped() { + let command = parse_one("1,2:s/x/y/"); + assert_eq!(command.command.name(), "substitute"); + assert_eq!(command.args, "/x/y/"); + assert!(command.range.is_some()); +} + #[test] fn filter_without_pattern_is_not_a_modifier() -> Result<(), String> { // ":filter" alone falls through to the builtin (NEEDARG -> E471). @@ -975,10 +1022,14 @@ fn delete_extracts_register() { assert!(command.args.is_empty()); } +// Upstream never quotes a register in an Ex command: `separate_nextcmd` +// turns the leading `"` into a comment before `parse_register` runs, so +// `:delete "+` deletes into the unnamed register instead of register `+`. #[test] -fn delete_extracts_quoted_register() { +fn delete_initial_quote_is_a_comment_not_a_register() { let command = parse_one("delete \"+"); - assert_eq!(command.register, Some('+')); + assert_eq!(command.register, None); + assert!(command.args.is_empty()); } #[test] @@ -988,6 +1039,45 @@ fn delete_extracts_register_then_count() { assert_eq!(command.count, Some(4)); } +#[test] +fn split_vimgrep_args_delimited_pattern() { + let split = split_vimgrep_args("/foo/ bar.txt").expect("pattern must split"); + assert_eq!(split.pattern, "foo"); + assert!(!split.global); + assert!(!split.nojump); + assert!(!split.fuzzy); + assert_eq!(split.files, "bar.txt"); +} + +#[test] +fn split_vimgrep_args_flags() { + let split = split_vimgrep_args("/foo/gj a.txt b.txt").expect("pattern must split"); + assert_eq!(split.pattern, "foo"); + assert!(split.global); + assert!(split.nojump); + assert!(!split.fuzzy); + assert_eq!(split.files, "a.txt b.txt"); +} + +#[test] +fn split_vimgrep_args_bare_word_has_no_flags() { + let split = split_vimgrep_args("foo bar.txt").expect("pattern must split"); + assert_eq!(split.pattern, "foo"); + assert!(!split.global); + assert_eq!(split.files, "bar.txt"); +} + +#[test] +fn split_vimgrep_args_needs_files() { + assert!(split_vimgrep_args("/foo/").is_none()); + assert!(split_vimgrep_args("").is_none()); +} + +#[test] +fn split_vimgrep_args_unterminated_pattern_is_none() { + assert!(split_vimgrep_args("/foo bar.txt").is_none()); +} + #[test] fn comment_after_delete_register_is_skipped() { let command = parse_one("delete a \" comment"); @@ -995,6 +1085,94 @@ fn comment_after_delete_register_is_skipped() { assert!(command.args.is_empty()); } +// `parse_register` (ex_docmd.c:1348-1370): a digit leading a count +// command's argument is the count, never a register — `:delete 2` deletes +// two lines exactly like upstream. +#[test] +fn delete_leading_digit_is_count_not_register() { + let command = parse_one("delete 2"); + assert_eq!(command.register, None); + assert_eq!(command.count, Some(2)); + assert!(command.args.is_empty()); +} + +// `:put` reads registers, so a digit names a register instead of the +// missing count (`:put 2` reports the empty register, like upstream). +#[test] +fn put_leading_digit_is_register_not_count() { + let command = parse_one("put 2"); + assert_eq!(command.register, Some('2')); + assert_eq!(command.count, None); +} + +// `parse_register`'s `=` case (ex_docmd.c:1358-1366): the rest of the +// argument is the expression, which is why `:put =lineA` parses in any +// position while `:delete =x` reports the `=` as trailing characters. +#[test] +fn put_expression_register_consumes_rest_of_argument() { + let command = parse_one("put =lineA"); + assert_eq!(command.register, Some('=')); + assert_eq!(command.args, "lineA"); +} + +#[test] +fn put_empty_expression_register_parses() { + let command = parse_one("put ="); + assert_eq!(command.register, Some('=')); + assert!(command.args.is_empty()); +} + +#[test] +fn put_expression_with_comment_stops_at_quote() { + let command = parse_one("put =lineA \" comment"); + assert_eq!(command.register, Some('=')); + assert_eq!(command.args, "lineA"); +} + +#[test] +fn put_expression_with_bar_splits_command() { + let commands = Parser::new().parse("put =1+1 | print").unwrap_or_default(); + assert_eq!(commands.len(), 2); + assert_eq!(commands[0].register, Some('=')); + assert_eq!(commands[0].args, "1+1"); + assert_eq!(commands[1].command.name(), "print"); +} + +// Read-only registers (`/`, `#`, `.`, `%`, `:`, `=`) are valid for `:put` — +#[test] +fn put_accepts_read_only_registers() { + let command = parse_one("put /"); + assert_eq!(command.register, Some('/')); + assert!(command.args.is_empty()); +} + +// — but not for `:delete`/`:yank`, where upstream leaves the name in the +// argument and reports it as trailing characters (`E488: /x`). +#[test] +fn delete_rejects_read_only_registers() { + assert!(Parser::new().parse("delete /x").is_err()); + assert!(Parser::new().parse("delete %").is_err()); + assert!(Parser::new().parse("yank :").is_err()); + assert!(Parser::new().parse("delete =x").is_err()); +} + +// `@` is not a valid yank register at all, so `:delete @` reports it as +// trailing characters exactly like upstream. +#[test] +fn delete_rejects_at_register() { + assert!(Parser::new().parse("delete @").is_err()); +} + +// A bare register does not need separating whitespace; upstream consumes +// exactly one character and reports the remainder as trailing characters +// (`E488: Trailing characters: x`), never the whole argument. +#[test] +fn delete_bare_register_consumes_one_character() { + let error = Parser::new().parse("delete ax").unwrap_err(); + assert_eq!(error.code, ErrorCode::E488); + assert!(error.message.contains("Trailing characters: x")); +} + #[test] fn buffer_extracts_count() { let command = parse_one("buffer 7"); From 73c8c5f72fae64959588521c368a57ff32e797c5 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:51:08 +0900 Subject: [PATCH 004/263] fix(eval): keep script-local scope live and answer screen queries broadly A function defined in one script and called while another script is current cloned a stale registry copy of the caller's `s:` map over the live one, so `runtest.vim`'s `s:has_ffi` read as undefined from inside test functions. Switching scripts now flushes the outgoing live map into its SID first and the call frame reloads the caller from that SID on return, which removes the whole class (41 oldtest failures across cmdline, search, and ins_complete). `g:Name(...)` and `function('g:Name')` resolve a variable-held Funcref the way upstream does, including E1085 with the written spelling when the variable exists but is not callable, and `s:`/`` names translate to the `` form when stored. screenchar, screenstring, screenchars, and screenattr answer the whole lines by columns grid with blank cells, so `nr2char(screenchar(row, col))` no longer produces E5070 outside the buffer text; -1 stays for out-of-grid coordinates, matching the oracle. --- crates/ox-editor/src/builtins/eval.rs | 58 +++++++++++++---- crates/ox-editor/src/builtins/window.rs | 62 +++++++++++++----- crates/ox-editor/src/script.rs | 31 ++++++--- crates/ox-editor/src/userfunc.rs | 1 - crates/ox-eval/src/eval.rs | 58 ++++++++++++++++- crates/ox-eval/src/lexer.rs | 23 +++---- crates/ox-eval/src/tests.rs | 85 +++++++++++++++++++++++++ 7 files changed, 265 insertions(+), 53 deletions(-) diff --git a/crates/ox-editor/src/builtins/eval.rs b/crates/ox-editor/src/builtins/eval.rs index 8c4de3de..71438782 100644 --- a/crates/ox-editor/src/builtins/eval.rs +++ b/crates/ox-editor/src/builtins/eval.rs @@ -256,10 +256,13 @@ fn expand_special_base( 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()) - }) + .map_or_else( + || OxStr(Vec::new()), + |buffer| { + let name = buffer.name().to_string_lossy(); + OxStr::from(name.as_ref()) + }, + ) }), "" => runtime.scripts.current_sid().map_or_else( || OxStr(Vec::new()), @@ -270,10 +273,13 @@ fn expand_special_base( ), "" => 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()) - }), + "" => 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()), } } @@ -371,11 +377,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 +400,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(), @@ -739,7 +771,9 @@ const DISPATCHED_COMMANDS: &[&str] = &[ "rshada", "rviminfo", "scriptencoding", + "scriptnames", "set", + "setfiletype", "setglobal", "setlocal", "sleep", diff --git a/crates/ox-editor/src/builtins/window.rs b/crates/ox-editor/src/builtins/window.rs index 17a2ac14..87009afa 100644 --- a/crates/ox-editor/src/builtins/window.rs +++ b/crates/ox-editor/src/builtins/window.rs @@ -11,7 +11,7 @@ use ox_eval::scope::{ScopeKind, scope_var_entry}; use ox_types::{OxStr, Typval}; use crate::builtins::position::{cursor_vcol, number_value, tabstop as position_tabstop}; -use crate::excmd_exec::{EvalHost, typval_number, typval_to_text}; +use crate::excmd_exec::{EvalHost, SetLayer, option_value, typval_number, typval_to_text}; use crate::layout::Frame; use ox_types::WinHandle; @@ -691,20 +691,35 @@ fn call_screenrow(editor: &Editor, args: &[Typval]) -> ox_eval::Result { Ok(Typval::Number(i64::try_from(row).unwrap_or(i64::MAX))) } -/// Resolves one screen cell to the character the covering window shows. +/// Resolves one screen cell to the character the composed screen shows. /// /// Only the current tabpage is on screen, so only its windows can own a cell. /// Windows in other tabpages have overlapping geometries and would otherwise /// win this search and return a character from something invisible. +/// +/// Blank cells are spaces, not missing cells: a coordinate inside the +/// `&lines`/`&columns` grid that no window covers (the status-line and +/// message rows beneath the windows, or a window row past the buffer's last +/// line) still displays the background, exactly like upstream's default grid +/// whose every in-range cell has a character (`eval/funcs.c:5711-5729`). +/// Only coordinates outside the grid have no cell at all, which upstream +/// reports as -1. Callers must not feed that -1 to `nr2char()`: the resulting +/// `E5070` is what `Screenline(&lines - 1)` in the oldtest suite trips over +/// when the grid reports holes instead of blanks. fn screen_cell(editor: &Editor, row: i64, column: i64) -> Option { let row = usize::try_from(row.checked_sub(1)?).ok()?; let column = usize::try_from(column.checked_sub(1)?).ok()?; + if row >= screen_size(editor, "lines") || column >= screen_size(editor, "columns") { + return None; + } let windows = editor .current_tabpage() .and_then(|tab| editor.tabpage_windows(tab).ok()) .unwrap_or_default(); for window in windows { - let geometry = editor.window_geometry(window).ok()?; + let Ok(geometry) = editor.window_geometry(window) else { + continue; + }; if row < geometry.row || row >= geometry.row + geometry.height || column < geometry.col @@ -712,23 +727,38 @@ fn screen_cell(editor: &Editor, row: i64, column: i64) -> Option { { continue; } - let state = editor.window(window).ok()?; + let Ok(state) = editor.window(window) else { + continue; + }; let line = state.topline + row - geometry.row; - let bytes = editor + let cell = editor .buffer(state.buffer) - .ok()? - .text() - .ok()? - .line(line) - .ok()?; - let text = String::from_utf8_lossy(&bytes); - let cell = text - .chars() - .nth(column - geometry.col) - .map_or_else(|| " ".to_owned(), |character| character.to_string()); + .ok() + .and_then(|buffer| buffer.text().ok()) + .and_then(|text| text.line(line).ok()) + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) + .and_then(|text| { + text.chars() + .nth(column - geometry.col) + .map(|character| character.to_string()) + }) + .unwrap_or_else(|| " ".to_owned()); return Some(cell); } - None + // In-grid but uncovered: the background shows through. + Some(" ".to_owned()) +} + +/// The screen grid extent in cells for `&lines`/`&columns`, mirroring +/// `screen_number_option` (`excmd_exec.rs`): the upstream default grid always +/// exists, so an unreadable option degrades to unbounded rather than empty. +fn screen_size(editor: &Editor, name: &str) -> usize { + match option_value(editor, name, SetLayer::Effective) { + Some(OptionValue::Number(value)) if *value > 0 => { + usize::try_from(*value).unwrap_or(usize::MAX) + } + _ => usize::MAX, + } } /// `winsaveview()` (`eval/window.c:f_winsaveview`): returns a dict with diff --git a/crates/ox-editor/src/script.rs b/crates/ox-editor/src/script.rs index eec9d026..ea93f241 100644 --- a/crates/ox-editor/src/script.rs +++ b/crates/ox-editor/src/script.rs @@ -1055,14 +1055,7 @@ impl ScriptCtx { /// `current_context()` all observe that script while it executes. A /// user command passes the caller's `seq` to keep `sc_seq` unchanged, /// while a function call passes the function's own `seq`. - pub fn push_alias_source( - &mut self, - sid: Sid, - seq: u64, - lnum: usize, - name: String, - order: u64, - ) { + pub fn push_alias_source(&mut self, sid: Sid, seq: u64, lnum: usize, name: String, order: u64) { self.source_stack.push(SourceFrame { sid, seq, @@ -1346,6 +1339,28 @@ impl ScriptCtx { } } + /// Switches the visible `s:` scope from one script to another, keeping + /// the per-SID registry live at every hop. + /// + /// The plain load/store pair only reconciles the registry when a sourced + /// script starts and finishes, so a script that is still executing keeps + /// its only copy of `s:` in `scope.script`. Entering a function whose + /// defining script differs — while the defining script is mid-execution + /// further down the stack — must not overwrite that live map with the + /// registry's stale copy (`:h E121` on `s:` reads such as + /// `runtest.vim`'s `s:has_ffi`). Flushing the outgoing frame first and + /// reading the incoming frame back from the registry keeps every SID's + /// stored map current, exactly like upstream's per-script `svars` dict, + /// which reads and writes mutate in place. + pub fn switch_script_scope(&mut self, from: Option, to: Sid, scope: &mut Scope) { + if from != Some(to) { + if let Some(from) = from { + self.store_script_scope(from, scope); + } + self.load_script_scope(to, scope); + } + } + /// Joins `\`-continuation lines, dropping comment lines, per the sourcing /// getline rules in `src/nvim/ex_docmd.c:1330-1430`: /// diff --git a/crates/ox-editor/src/userfunc.rs b/crates/ox-editor/src/userfunc.rs index b3791d6b..9ad229e9 100644 --- a/crates/ox-editor/src/userfunc.rs +++ b/crates/ox-editor/src/userfunc.rs @@ -409,7 +409,6 @@ impl UserFunctions { pub fn call_stack(&self) -> &[CallFrame] { &self.call_stack } - } /// Parses the parameter list between the signature parentheses into diff --git a/crates/ox-eval/src/eval.rs b/crates/ox-eval/src/eval.rs index 9036c28b..86a36433 100644 --- a/crates/ox-eval/src/eval.rs +++ b/crates/ox-eval/src/eval.rs @@ -951,14 +951,30 @@ impl<'a, H: BuiltinHost, R: RegexEngine> Evaluator<'a, H, R> { // A Funcref/Partial variable is callable (`g:Xsetlist()`). A // Number argument named `line` must not steal `line('.')` // (test_goto.vim `XTest_goto_decl`). - match Self::eval_variable(name, callee.span.start, scope) { + let variable = Self::eval_variable(name, callee.span.start, scope); + // `deref_func_name` (`eval/userfunc.c:445-460`) reports through + // `found_var` whether a variable of this name exists. A variable + // holding no callable is `E1085`, not `E117` — but only for the + // lookup failure itself; an `E117` raised inside a resolved + // function's body keeps its own text (`userfunc.c:1612`). + let found_var = variable.is_ok(); + let missing = format!("Unknown function: {}", name.to_string_lossy()); + match variable { Ok(value) if matches!(value.value, Typval::Funcref(_) | Typval::Partial(_)) => { return self.call_value(value.value, values, scope, depth); } Err(error) if error.code != "E121" => return Err(error), Ok(_) | Err(_) => {} } - return self.call_named(name, values, scope, callee.span.start, depth); + return self + .call_named(name, values, scope, callee.span.start, depth) + .map_err(|mut error| { + if found_var && error.code == "E117" && error.message == missing { + error.code = "E1085"; + error.message = format!("Not a callable type: {}", name.to_string_lossy()); + } + error + }); } let callee = self.eval_at(callee, scope, depth)?; self.call_value(callee.value, values, scope, depth) @@ -995,12 +1011,31 @@ impl<'a, H: BuiltinHost, R: RegexEngine> Evaluator<'a, H, R> { offset: usize, _depth: usize, ) -> Result { + // `call_func` skips a leading `g:` before it resolves the function + // (`eval/userfunc.c:1732`): global functions share one namespace, so + // `g:Name()` reaches `Name` while `string()` and the error messages + // keep the prefix. A builtin keeps the name as written, because + // `call_func` picks the internal table from the *stripped* name and + // then calls it with the original: `g:printf()` is E117. + let stripped = global_stripped_name(name); + let target = stripped.as_ref().unwrap_or(name); + // `user_func_error` reports the lookup failure under the *original* + // spelling (`eval/userfunc.c:1610-1616`), so `g:Nope()` says + // `Unknown function: g:Nope` even though the lookup used `Nope`. + // Only that exact lookup failure is relabelled; an error raised + // inside a function keeps its own text. + let missing = stripped + .as_ref() + .map(|target| format!("Unknown function: {}", target.to_string_lossy())); self.host - .call(name, args, scope) + .call(target, args, scope) .map(Evaluated::plain) .map_err(|mut error| { if error.code == "E117" { error.offset = offset; + if missing.as_deref() == Some(error.message.as_str()) { + error.message = format!("Unknown function: {}", name.to_string_lossy()); + } } error }) @@ -1121,6 +1156,23 @@ fn identity_type(value: &Typval) -> bool { ) } +/// The name a `g:`-prefixed call dispatches under, or `None` when the name +/// must be used verbatim. +/// +/// `call_func` (`eval/userfunc.c:1732-1735`) strips `g:` into `rfname` and +/// takes the builtin branch from *that*, then calls the internal table with +/// the name as written — so a builtin reached through `g:` still fails: +/// `g:printf('%d', 3)` is `E117: Unknown function: g:printf`. Every other +/// name resolves as a user function under the stripped spelling, because +/// global functions share a single namespace. +fn global_stripped_name(name: &OxStr) -> Option { + let rest = name.as_bytes().strip_prefix(b"g:")?; + if rest.is_empty() || crate::builtins::builtin_spec(&String::from_utf8_lossy(rest)).is_some() { + return None; + } + Some(OxStr(rest.to_vec())) +} + /// Parses the local index out of a `N` closure name. #[must_use] pub fn closure_index(name: &[u8]) -> Option { diff --git a/crates/ox-eval/src/lexer.rs b/crates/ox-eval/src/lexer.rs index 19a7a614..c62324d8 100644 --- a/crates/ox-eval/src/lexer.rs +++ b/crates/ox-eval/src/lexer.rs @@ -1031,7 +1031,9 @@ fn mod_mask(byte: u8) -> Option { /// Byte length of the first UTF-8 scalar (`utfc_ptr2len`); 1 on invalid UTF-8. fn utf_len(bytes: &[u8]) -> usize { - let Some(&first) = bytes.first() else { return 0 }; + let Some(&first) = bytes.first() else { + return 0; + }; let len = if first < 0x80 { 1 } else if first < 0xe0 { @@ -1077,17 +1079,10 @@ fn vim_str2nr(text: &[u8]) -> Option<(u32, usize)> { } // The number ends at the first non-digit; strict mode rejects a trailing // identifier character (`char-66x` is E474). - if digits - .get(digit_len) - .is_some_and(|byte| is_ident(*byte)) - { + if digits.get(digit_len).is_some_and(|byte| is_ident(*byte)) { return None; } - let value = u32::from_str_radix( - std::str::from_utf8(&digits[..digit_len]).ok()?, - radix, - ) - .ok()?; + let value = u32::from_str_radix(std::str::from_utf8(&digits[..digit_len]).ok()?, radix).ok()?; let signed = if negative { value.wrapping_neg() } else { @@ -1121,7 +1116,10 @@ fn handle_x_keys(second: u8, third: u8) -> DecodedKey { fn named_key_code(name: &[u8], next: u8) -> Option { const EXTRA: u8 = 0xfd; if name.len() >= 3 && name[0] == b't' && name[1] == b'_' { - return Some(DecodedKey::Special(name[2], name.get(3).copied().unwrap_or(next))); + return Some(DecodedKey::Special( + name[2], + name.get(3).copied().unwrap_or(next), + )); } let lower: Vec = name.iter().map(u8::to_ascii_lowercase).collect(); if let [b'f', rest @ ..] = lower.as_slice() @@ -1342,8 +1340,7 @@ fn find_special_key(tail: &[u8], offset: usize) -> Result, usize && after[5].is_ascii_digit() { // ``, ``, `` (`keycodes.c:544-552`). - vim_str2nr(&after[5..]) - .map(|(value, _)| DecodedKey::Char(value)) + vim_str2nr(&after[5..]).map(|(value, _)| DecodedKey::Char(value)) } else { let single = if modifiers != 0 { if after == b"\\\"" { diff --git a/crates/ox-eval/src/tests.rs b/crates/ox-eval/src/tests.rs index ea5c3712..a643e7b5 100644 --- a/crates/ox-eval/src/tests.rs +++ b/crates/ox-eval/src/tests.rs @@ -115,6 +115,18 @@ fn error(source: &[u8]) -> EvalError { } } +/// The `(code, message)` pair an expression fails with when evaluated against +/// a caller-supplied scope. +fn in_scope_error(source: &[u8], scope: &mut Scope) -> (String, String) { + let expression = Parser::new(source).parse().unwrap(); + let mut host = Host; + let regex = Regex; + let failure = Evaluator::new(&mut host, ®ex) + .eval(&expression, scope) + .unwrap_err(); + (failure.code.to_owned(), failure.message) +} + macro_rules! eval_cases { ($(($name:ident, $source:expr, $expected:expr, $citation:expr)),+ $(,)?) => {$( #[test] @@ -1625,3 +1637,76 @@ fn unparsable_expression_boundaries_keep_specific_diagnostics() { assert_eq!(error(b"").message, "Invalid expression: \"\""); assert_eq!(error(b"1 ? 2").code, "E109"); } + +// --------------------------------------------------------------------------- +// `g:`-prefixed call names +// Upstream: `call_func` strips a leading `g:` into `rfname` and resolves the +// user function under that stripped spelling (`eval/userfunc.c:1732-1735`), +// while the builtin branch still calls the internal table with the name as +// written, so `g:printf('%d', 3)` stays `E117: Unknown function: g:printf`. +// `deref_func_name`'s `found_var` turns a resolved non-callable variable into +// `E1085` instead of `E117` (`eval/userfunc.c:445-460,1610-1616`). +// --------------------------------------------------------------------------- + +/// A function registered under its bare name is reachable through `g:`. +/// `first` is served by this module's host and is deliberately not a real +/// builtin, so the `g:` stripping — not the builtin table — answers the call. +#[test] +fn call_strips_the_global_prefix_from_a_user_function_name() { + assert_eq!(value(b"first(7)"), Typval::Number(7)); + assert_eq!(value(b"g:first(7)"), Typval::Number(7)); +} + +/// The `g:` prefix cannot reach a builtin, because `call_func` keeps the +/// written name for the internal dispatch. +#[test] +fn call_does_not_strip_the_global_prefix_for_builtins() { + let failure = error(b"g:printf('%d', 3)"); + assert_eq!(failure.code, "E117"); + assert_eq!(failure.message, "Unknown function: g:printf"); +} + +/// An unknown `g:`-prefixed name reports the spelling the caller wrote, not +/// the one the lookup used. +#[test] +fn call_reports_unknown_global_prefixed_names_verbatim() { + let failure = error(b"g:NoSuchFunction()"); + assert_eq!(failure.code, "E117"); + assert_eq!(failure.message, "Unknown function: g:NoSuchFunction"); +} + +/// An existing variable that holds no callable is a type error, not a missing +/// function: upstream keeps `E1085` for every namespace and quotes the +/// spelling the caller used. +#[test] +fn call_to_a_non_callable_variable_reports_e1085() { + // Unqualified resolution reaches `g:`, so the bare form names `Fn`. + let mut bare = Scope::new(); + bare.global.push((OxStr::from("Fn"), Typval::Number(5))); + assert_eq!( + in_scope_error(b"Fn(1)", &mut bare), + ("E1085".to_owned(), "Not a callable type: Fn".to_owned()) + ); + + let mut global = Scope::new(); + global.global.push((OxStr::from("Fn"), Typval::Number(5))); + assert_eq!( + in_scope_error(b"g:Fn(1)", &mut global), + ("E1085".to_owned(), "Not a callable type: g:Fn".to_owned()) + ); + + let mut script = Scope::new(); + script.script.push((OxStr::from("Fn"), Typval::Number(5))); + assert_eq!( + in_scope_error(b"s:Fn(1)", &mut script), + ("E1085".to_owned(), "Not a callable type: s:Fn".to_owned()) + ); +} + +/// A missing name keeps `E117`: only a variable that *exists* becomes E1085. +#[test] +fn call_to_a_missing_name_stays_e117() { + let failure = error(b"g:NoSuchVariable(1)"); + assert_eq!(failure.code, "E117"); + assert_eq!(failure.message, "Unknown function: g:NoSuchVariable"); +} From 7af97a850fa54c28a050b9850cc24fe3854f24bb Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:51:13 +0900 Subject: [PATCH 005/263] feat(cmdline): add command-line builtins and the deferred setcmdpos slot getcmdline, getcmdpos, setcmdpos, getcmdtype, getcmdprompt, pumvisible, and wildmenumode now dispatch from editor state instead of reporting E117. The number coercion follows tv_get_number: floats truncate, strings take their numeric prefix, and a Dictionary is E728. setcmdpos stores a one-based slot that the next CTRL-R paste consumes and clears, matching upstream's static new_cmdpos: the slot is invisible to getcmdpos, resets with the command-line session, and paste writes into the command-line text rather than the buffer. CTRL-R CTRL-R shares that path. screenpos, win_screenpos, setcmdline, setcmdtype, and the completion-type queries are not part of this change. --- crates/ox-editor/src/builtins/cmdline.rs | 334 +++++++++++++++++++++++ crates/ox-editor/src/builtins/mod.rs | 12 +- crates/ox-editor/src/mode.rs | 238 +++++++++++----- crates/ox-editor/src/mode_tests.rs | 96 ++++--- 4 files changed, 556 insertions(+), 124 deletions(-) create mode 100644 crates/ox-editor/src/builtins/cmdline.rs diff --git a/crates/ox-editor/src/builtins/cmdline.rs b/crates/ox-editor/src/builtins/cmdline.rs new file mode 100644 index 00000000..b3985fcf --- /dev/null +++ b/crates/ox-editor/src/builtins/cmdline.rs @@ -0,0 +1,334 @@ +//! Command-line introspection builtins (`eval/funcs.c` `f_getcmd*`, +//! `f_setcmdpos`, `f_pumvisible`, `f_wildmenumode`). +//! +//! 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. + +use ox_eval::{EvalError, builtin_spec}; +use ox_types::{OxStr, Typval}; + +use crate::excmd_exec::{EvalHost, ExEditorAccess}; +use crate::mode::{CmdlineKind, Mode}; +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)))), + "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)), + _ => 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 +} + +#[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); + } +} diff --git a/crates/ox-editor/src/builtins/mod.rs b/crates/ox-editor/src/builtins/mod.rs index 0884fdd2..faca3e76 100644 --- a/crates/ox-editor/src/builtins/mod.rs +++ b/crates/ox-editor/src/builtins/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod assert; pub(crate) mod buffer; +pub(crate) mod cmdline; pub(crate) mod completion; pub(crate) mod environment; pub(crate) mod eval; @@ -68,6 +69,8 @@ pub(crate) enum Family { Completion, /// Match highlighting, served by [`matches`]. Match, + /// Command-line introspection, served by [`cmdline`]. + Cmdline, /// Tags-file queries, served by [`tag`]. Tag, /// The listening-RPC server, served by [`server`]. @@ -83,9 +86,9 @@ pub(crate) fn route(name: &str) -> Option { | "assert_match" | "assert_notequal" | "assert_notmatch" | "assert_report" | "assert_true" => Family::Assert, "append" | "appendbufline" | "bufadd" | "bufexists" | "bufload" | "bufname" | "bufnr" - | "changenr" | "deletebufline" | "getbufinfo" | "getbufline" | "getbufvar" - | "getchangelist" | "last_buffer_nr" | "prompt_getprompt" | "prompt_setprompt" - | "setbufline" | "setbufvar" | "undotree" => Family::Buffer, + | "changenr" | "deletebufline" | "did_filetype" | "getbufinfo" | "getbufline" + | "getbufvar" | "getchangelist" | "last_buffer_nr" | "prompt_getprompt" + | "prompt_setprompt" | "setbufline" | "setbufvar" | "undotree" => Family::Buffer, "api_info" | "chdir" | "defer" | "eventhandler" | "highlight_exists" | "hlID" | "hlexists" | "shellescape" | "stdpath" | "strdisplaywidth" | "strftime" | "mode" | "swapname" => Family::Environment, @@ -114,6 +117,8 @@ pub(crate) fn route(name: &str) -> Option { "complete" | "complete_info" | "getcompletion" => Family::Completion, "matchadd" | "matchaddpos" | "matchdelete" | "clearmatches" | "getmatches" | "setmatches" | "matcharg" => Family::Match, + "getcmdline" | "getcmdpos" | "setcmdpos" | "getcmdtype" | "getcmdprompt" + | "pumvisible" | "wildmenumode" => Family::Cmdline, "taglist" | "gettagstack" | "settagstack" => Family::Tag, "serverstart" | "serverstop" | "serverlist" => Family::Server, _ => return predicate_family(name), @@ -169,6 +174,7 @@ pub(crate) fn call( Family::Match => host .access .with_ex_editor(|editor| matches::call(editor, name, args)), + Family::Cmdline => cmdline::call(host, name, args), Family::Tag => tag::call(host, name, args), Family::Window => window::call(host, name, args), } diff --git a/crates/ox-editor/src/mode.rs b/crates/ox-editor/src/mode.rs index 4cf319ad..4ca92ca0 100644 --- a/crates/ox-editor/src/mode.rs +++ b/crates/ox-editor/src/mode.rs @@ -114,6 +114,10 @@ pub struct CmdlineState { pub text: String, /// Insertion position: a UTF-8 boundary in `text`, including its end. pub cursor_byte: usize, + /// Cursor column deferred by `setcmdpos()` (`ex_getln.c` `new_cmdpos`): + /// a 1-based character position consumed — and cleared — by the next + /// `CTRL-R` paste, never applied eagerly. `None` outside `setcmdpos`. + pub new_cmdpos: Option, /// Match occurrence requested before entering command-line mode. pub count: usize, /// Cursor position when the command line was entered @@ -318,6 +322,10 @@ pub struct ModeMachine { pub completion: CompletionSession, /// `CTRL-V` in command-line mode: insert the next character literally. pending_cmdline_literal: bool, + /// `CTRL-R` seen in command-line mode, waiting for the register name. + /// A second `CTRL-R` is consumed the same way: the single paste path + /// below inserts literally either way, so no second flag is needed. + pending_cmdline_ctrl_r: bool, /// Insert mode temporarily yielded to one Normal-mode command with /// `CTRL-O`. one_normal_command: bool, @@ -366,6 +374,7 @@ impl Default for ModeMachine { pending_ctrl_bslash: false, completion: CompletionSession::new(), pending_cmdline_literal: false, + pending_cmdline_ctrl_r: false, one_normal_command: false, insert_literal: None, pending_insert_ctrl_r: false, @@ -830,9 +839,7 @@ impl ModeMachine { // An unmapped Alt/Meta-prefixed key is the terminal's // `ESC`-prefix form (`getchar.c` `mod_mask`): deliver `ESC` // now and let the queued key itself arrive next. - Key::Special(KS_MODIFIER, mask) - if mask & (MOD_MASK_ALT | MOD_MASK_META) != 0 => - { + Key::Special(KS_MODIFIER, mask) if mask & (MOD_MASK_ALT | MOD_MASK_META) != 0 => { Ok(Step::Key('\u{1b}')) } Key::Special(second, third) => match Self::named_key(second, third) { @@ -1151,17 +1158,7 @@ impl ModeMachine { fn operand_prefix(prefix: &str) -> bool { matches!( prefix, - "register" - | "record" - | "exec" - | "r" - | "r\u{16}" - | "f" - | "F" - | "t" - | "T" - | "i" - | "a" + "register" | "record" | "exec" | "r" | "r\u{16}" | "f" | "F" | "t" | "T" | "i" | "a" ) } @@ -1300,7 +1297,9 @@ impl ModeMachine { let history = &self.cmdline_history; let start = state.history_index.unwrap_or(history.len()); let found = if up { - (0..start).rev().find(|&index| history[index].starts_with(&lookfor)) + (0..start) + .rev() + .find(|&index| history[index].starts_with(&lookfor)) } else { (start + 1..history.len()).find(|&index| history[index].starts_with(&lookfor)) }; @@ -1446,10 +1445,14 @@ impl ModeMachine { i64::try_from(ctx.cursor.col).unwrap_or(i64::MAX), tabstop, ); - let cur_vcol = cur_vcol.saturating_add(usize::try_from( - editor.window(ctx.window).map_or(0, |state| state.coladd.max(0)), - ) - .unwrap_or(usize::MAX)); + let cur_vcol = cur_vcol.saturating_add( + usize::try_from( + editor + .window(ctx.window) + .map_or(0, |state| state.coladd.max(0)), + ) + .unwrap_or(usize::MAX), + ); let want = editor.window(ctx.window).ok().map_or( i64::try_from(cur_vcol).unwrap_or(i64::MAX), |state| { @@ -1466,20 +1469,13 @@ impl ModeMachine { // the covering cluster (the multi-byte snap is folded // into the same translation). let col = if want == i64::MAX - || usize::try_from(want).unwrap_or(usize::MAX) - >= display_len(&line, tabstop) + || usize::try_from(want).unwrap_or(usize::MAX) >= display_len(&line, tabstop) { line.len() } else { vcol_to_byte(&line, usize::try_from(want).unwrap_or(0), tabstop) }; - editor.set_window_cursor( - ctx.window, - Position { - lnum: target, - col, - }, - )?; + editor.set_window_cursor(ctx.window, Position { lnum: target, col })?; if let Ok(state) = editor.window_mut(ctx.window) { state.curswant = want; state.set_curswant = false; @@ -1514,13 +1510,7 @@ impl ModeMachine { // not start a new undo block — return before `insert_moved` // is set. NamedKey::Del => { - insert::delete_forward( - editor, - ctx.buffer, - ctx.window, - ctx.cursor, - self.timestamp, - )?; + insert::delete_forward(editor, ctx.buffer, ctx.window, ctx.cursor, self.timestamp)?; return Ok(()); } // `i_`/`i_` are the `ins_s_left`/`ins_s_right` @@ -1531,13 +1521,8 @@ impl ModeMachine { let lines = (1..=count) .map(|n| ctx.line(editor, n)) .collect::, _>>()?; - let command = if key == NamedKey::CtrlLeft { - "b" - } else { - "w" - }; - if let Some(motion) = crate::motion::word_motion(&lines, ctx.cursor, command, 1) - { + let command = if key == NamedKey::CtrlLeft { "b" } else { "w" }; + if let Some(motion) = crate::motion::word_motion(&lines, ctx.cursor, command, 1) { let mut target = motion.target; // `ins_s_right` (`fwd_word`): the insert cursor may // rest one past the line, so a `w` that lands inside @@ -1954,6 +1939,7 @@ impl ModeMachine { history_index: None, history_saved: String::new(), history_lookfor: String::new(), + new_cmdpos: None, }))) } ':' => { @@ -1982,6 +1968,7 @@ impl ModeMachine { history_index: None, history_saved: String::new(), history_lookfor: String::new(), + new_cmdpos: None, }))) } 'n' | 'N' => { @@ -2160,11 +2147,7 @@ impl ModeMachine { start: ctx.cursor, end: Position { lnum: ctx.cursor.lnum, - col: ctx - .cursor - .col - .saturating_add(count - 1) - .min(line.len() - 1), + col: ctx.cursor.col.saturating_add(count - 1).min(line.len() - 1), }, kind: MotionKind::CharacterWise, inclusive: true, @@ -2235,8 +2218,12 @@ impl ModeMachine { if self.recordbuff.last() == Some(&b'q') { self.recordbuff.pop(); } - let rows: Vec> = self - .recordbuff + // `do_record` (`register.c:488-494`): strip `K_SPECIAL` + // quoting from the captured bytes before storing them, + // so a recorded multi-byte character reads back as the + // typed bytes; `exec_register` re-adds the quoting when + // the macro plays back. + let rows: Vec> = Keys::unescape_ks(&self.recordbuff) .split(|byte| *byte == b'\n') .map(<[u8]>::to_vec) .collect(); @@ -2822,6 +2809,7 @@ impl ModeMachine { history_index: None, history_saved: String::new(), history_lookfor: String::new(), + new_cmdpos: None, }))) } 'J' => { @@ -3398,7 +3386,11 @@ impl ModeMachine { self.pending_insert_ctrl_r = false; return self.insert_register(editor, ctx, key, eval); } - match self.insert_literal.take().unwrap_or(InsertLiteral::Awaiting) { + match self + .insert_literal + .take() + .unwrap_or(InsertLiteral::Awaiting) + { InsertLiteral::Awaiting => { let digits = match key { 'x' | 'X' => Some((16, 2)), @@ -3451,11 +3443,7 @@ impl ModeMachine { self.emit_literal_digits(editor, ctx, &digits, radix)?; return self.insert(editor, state, key, eval); } else { - self.insert_literal = Some(InsertLiteral::Digits { - radix, - max, - digits, - }); + self.insert_literal = Some(InsertLiteral::Digits { radix, max, digits }); } } } @@ -3480,11 +3468,62 @@ impl ModeMachine { if let Ok(value) = u32::from_str_radix(digits, radix) && let Some(ch) = char::from_u32(value).or_else(|| char::from_u32(value & 0xff)) { - insert::insert_char(editor, ctx.buffer, ctx.window, ctx.cursor, ch, self.timestamp)?; + insert::insert_char( + editor, + ctx.buffer, + ctx.window, + ctx.cursor, + ch, + self.timestamp, + )?; } Ok(()) } + /// `c_CTRL-R`: pastes a register into the command line (`ex_getln.c` + /// `command_line_insert_reg`). The text lands in [`CmdlineState::text`] + /// at the cursor — never through the buffer insert path — and a + /// position deferred by `setcmdpos()` is consumed here and cleared, + /// matching upstream's `new_cmdpos` slot. An empty register is a beep, + /// not an edit. + fn cmdline_paste_register( + &mut self, + editor: &mut Editor, + state: &mut CmdlineState, + name: char, + ) -> Result<(), ModeError> { + let Some(lines) = editor + .registers() + .get(name) + .ok() + .flatten() + .map(|content| content.getreg_lines()) + else { + return Ok(()); + }; + let pasted = lines + .iter() + .map(|line| String::from_utf8_lossy(line)) + .collect::>() + .join("\n"); + let before = state.text[..state.cursor_byte].chars().count(); + state.text.insert_str(state.cursor_byte, &pasted); + state.cursor_byte += pasted.len(); + let end = before + pasted.chars().count(); + let total = state.text.chars().count(); + let target = state + .new_cmdpos + .take() + .map(|position| position.clamp(1, total + 1)) + .unwrap_or(end + 1); + state.cursor_byte = state + .text + .char_indices() + .nth(target.saturating_sub(1)) + .map_or(state.text.len(), |(byte, _)| byte); + Ok(()) + } + /// `i_CTRL-R`: inserts the named register's content (`edit.c` /// `insert_reg`). Characterwise text splices at the cursor with typed /// newline semantics; linewise replaces the current line and leaves the @@ -3687,7 +3726,10 @@ impl ModeMachine { beep_flush(editor); return Ok(()); }; - let keys = Keys::from_encoded(bytes)?; + // `put_in_typebuf` with escaping (`register.c:534-556`): the stored + // macro is raw bytes, so re-escape `K_SPECIAL` before the encoded + // validation the typeahead requires. + let keys = Keys::from_encoded(Keys::escape_ks(&bytes).as_bytes().to_vec())?; let flags = TypeaheadFlags { mapped: true, ..TypeaheadFlags::default() @@ -3704,6 +3746,15 @@ impl ModeMachine { state: &mut CmdlineState, key: char, ) -> Result, ModeError> { + if self.pending_cmdline_ctrl_r && key != '\u{12}' { + self.pending_cmdline_ctrl_r = false; + // A cancel key aborts the pending paste and runs normally; any + // other key names the register to paste. + if !matches!(key, '\u{1b}' | '\u{3}' | '\n' | '\r') { + self.cmdline_paste_register(editor, state, key)?; + return Ok(None); + } + } if self.pending_cmdline_literal { self.pending_cmdline_literal = false; state.history_index = None; @@ -3731,6 +3782,10 @@ impl ModeMachine { self.pending_cmdline_literal = true; return Ok(None); } + if key == '\u{12}' { + self.pending_cmdline_ctrl_r = true; + return Ok(None); + } if key == '\u{1c}' { self.pending_ctrl_bslash = true; return Ok(None); @@ -4382,7 +4437,15 @@ impl ModeMachine { }; let containing = self .search - .preview(&ctx.lines, probe, "", SearchDirection::Backward, 1, false, None) + .preview( + &ctx.lines, + probe, + "", + SearchDirection::Backward, + 1, + false, + None, + ) .ok() .filter(|m| (m.match_end.lnum, m.match_end.col) > (ctx.cursor.lnum, ctx.cursor.col)); if let Some(m) = containing { @@ -4396,7 +4459,15 @@ impl ModeMachine { }; return Ok(self .search - .preview(&ctx.lines, m.match_start, "", direction, count - 1, wrapscan, None) + .preview( + &ctx.lines, + m.match_start, + "", + direction, + count - 1, + wrapscan, + None, + ) .ok() .map(|m| (m.match_start, m.match_end))); } @@ -5271,7 +5342,9 @@ fn display_text_width( ); let numbers = number_on || relative_on || statuscolumn_on; let (min_sc, max_sc) = signcolumn_bounds(signcolumn, numbers); - let sign_width = min_sc.max(max_sc.min(live_sign_slots(editor, buffer))).saturating_mul(2); + let sign_width = min_sc + .max(max_sc.min(live_sign_slots(editor, buffer))) + .saturating_mul(2); let number_width = if number_on || relative_on { let minimum = match editor.options().get_window(window, "numberwidth") { Ok(OptionValue::Number(value)) => usize::try_from((*value).clamp(1, 20)).unwrap_or(4), @@ -5284,7 +5357,9 @@ fn display_text_width( } else { 0 }; - width.saturating_sub(sign_width.saturating_add(number_width)).max(1) + width + .saturating_sub(sign_width.saturating_add(number_width)) + .max(1) } /// `nv_screengo`/`nv_g_home_m_cmd`/`nv_g_dollar_cmd`: the screen-line @@ -5360,13 +5435,13 @@ fn display_motion( return Ok(None); } } - let col = vcol_to_byte( - line_at(lnum), - usize::try_from(want).unwrap_or(0), - tabstop, - ); + let col = vcol_to_byte(line_at(lnum), usize::try_from(want).unwrap_or(0), tabstop); let want = if curswant == i64::MAX { i64::MAX } else { want }; - Ok(motion(Position { lnum, col }, curswant == i64::MAX, Some(want))) + Ok(motion( + Position { lnum, col }, + curswant == i64::MAX, + Some(want), + )) } "g0" | "g^" | "gm" => { let col = display_row_home( @@ -5377,7 +5452,14 @@ fn display_motion( tabstop, wrap, ); - Ok(motion(Position { lnum: ctx.cursor.lnum, col }, false, None)) + Ok(motion( + Position { + lnum: ctx.cursor.lnum, + col, + }, + false, + None, + )) } _ => Ok(display_row_dollar( &ctx.lines, @@ -5419,7 +5501,11 @@ fn display_row_home( ) -> usize { let row_start = cur_vcol / width * width; let i = if command == "gm" { - if wrap { row_start + width / 2 } else { width / 2 } + if wrap { + row_start + width / 2 + } else { + width / 2 + } } else if wrap { row_start } else { @@ -5461,7 +5547,13 @@ fn display_row_dollar( for _ in 0..count - 1 { if wrap { if !display_row_step( - &mut lnum, &mut linelen, &mut want, lines, tabstop, width, true, + &mut lnum, + &mut linelen, + &mut want, + lines, + tabstop, + width, + true, ) { return None; } @@ -5515,7 +5607,11 @@ fn seed_row_want( if curswant == i64::MAX { return vcol_as_i64(cur_vcol / width * width + width - 1); } - let want = if set_curswant { vcol_as_i64(cur_vcol) } else { curswant }; + let want = if set_curswant { + vcol_as_i64(cur_vcol) + } else { + curswant + }; want.clamp(0, vcol_as_i64(display_rows_of(linelen, width) * width) - 1) } diff --git a/crates/ox-editor/src/mode_tests.rs b/crates/ox-editor/src/mode_tests.rs index da9fc2db..b56d0958 100644 --- a/crates/ox-editor/src/mode_tests.rs +++ b/crates/ox-editor/src/mode_tests.rs @@ -4739,10 +4739,7 @@ fn ctrl_w_less_reaches_wincmd_through_the_typeahead() { machine.run_once(&mut editor, &mut eval).unwrap(); machine.run_once(&mut editor, &mut eval).unwrap(); - assert_eq!( - editor.window_geometry(focused).unwrap().width, - before - 1 - ); + assert_eq!(editor.window_geometry(focused).unwrap().width, before - 1); } /// Builds the `run` helper's editor without feeding keys — for tests @@ -4763,9 +4760,10 @@ fn named_editor(text: &str, cursor: Position) -> (Editor, ox_types::BufHandle, M /// Feeds one `K_SPECIAL` triple through the typeahead — the same /// `nvim_input` decode path a terminal key event takes. fn feed_special(editor: &mut Editor, machine: &mut ModeMachine, second: u8, third: u8) { - editor - .typeahead_mut() - .append(&Keys::special(second, third).unwrap(), TypeaheadFlags::default()); + editor.typeahead_mut().append( + &Keys::special(second, third).unwrap(), + TypeaheadFlags::default(), + ); let mut eval = NullExprEval; machine.run_once(editor, &mut eval).unwrap(); } @@ -4799,8 +4797,8 @@ fn normal_named_keys_are_motions() { fn normal_del_and_insert_dispatch() { let (mut editor, buffer, mut machine) = named_editor("abc", position(1, 0)); feed_special(&mut editor, &mut machine, b'k', b'D'); - let text = String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()) - .unwrap(); + let text = + String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()).unwrap(); assert_eq!(text, "bc"); feed_special(&mut editor, &mut machine, b'k', b'I'); @@ -4827,8 +4825,8 @@ fn operator_pending_del_is_noop() { machine.feed_keys(&mut editor, "d", &mut eval).unwrap(); feed_special(&mut editor, &mut machine, b'k', b'D'); machine.feed_keys(&mut editor, "w", &mut eval).unwrap(); - let text = String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()) - .unwrap(); + let text = + String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()).unwrap(); assert_eq!(text, "bar"); } @@ -4980,8 +4978,8 @@ fn insert_del_deletes_forward() { feed_special(&mut editor, &mut machine, b'k', b'D'); feed_special(&mut editor, &mut machine, b'@', b'7'); feed_special(&mut editor, &mut machine, b'k', b'D'); - let text = String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()) - .unwrap(); + let text = + String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()).unwrap(); assert_eq!(text, "ac\ndef"); } @@ -4996,7 +4994,9 @@ fn cmdline_up_down_walk_matching_history() { "e /tmp/alt".to_owned(), ]); let mut eval = NullExprEval; - machine.feed_keys(&mut editor, ":e /tmp/al", &mut eval).unwrap(); + machine + .feed_keys(&mut editor, ":e /tmp/al", &mut eval) + .unwrap(); let text_of = |machine: &ModeMachine| match machine.mode() { Mode::Cmdline(state) => (state.text.clone(), state.cursor_byte), _ => panic!("expected cmdline mode"), @@ -5041,8 +5041,8 @@ fn ctrl_bslash_named_key_inserts_literal_then_moves() { machine.feed_keys(&mut editor, "i", &mut eval).unwrap(); machine.feed_keys(&mut editor, "\u{1c}", &mut eval).unwrap(); feed_special(&mut editor, &mut machine, b'k', b'u'); - let text = String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()) - .unwrap(); + let text = + String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()).unwrap(); assert_eq!(text, "ab\n\u{1c}cd"); // The named key still dispatched: the cursor moved up a line. assert_eq!(window_cursor(&editor).lnum, 1); @@ -5059,8 +5059,8 @@ fn ctrl_bslash_named_key_overwrites_in_replace() { machine.feed_keys(&mut editor, "R", &mut eval).unwrap(); machine.feed_keys(&mut editor, "\u{1c}", &mut eval).unwrap(); feed_special(&mut editor, &mut machine, b'k', b'u'); - let text = String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()) - .unwrap(); + let text = + String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()).unwrap(); assert_eq!(text, "abc\n\u{1c}ef"); assert_eq!(window_cursor(&editor).lnum, 1); assert!(matches!(machine.mode(), Mode::Replace(_))); @@ -5076,9 +5076,7 @@ fn cmdline_ctrl_bslash_named_key_inserts_literal_then_runs() { machine.set_cmdline_history(vec!["edit file".to_owned()]); let mut eval = NullExprEval; machine.feed_keys(&mut editor, ":", &mut eval).unwrap(); - machine - .feed_keys(&mut editor, "\u{1c}", &mut eval) - .unwrap(); + machine.feed_keys(&mut editor, "\u{1c}", &mut eval).unwrap(); feed_special(&mut editor, &mut machine, b'k', b'u'); let text_of = |machine: &ModeMachine| match machine.mode() { Mode::Cmdline(state) => state.text.clone(), @@ -5112,19 +5110,16 @@ fn cmdline_ctrl_bslash_char_inserts_literal_then_types() { /// (`ins_compl_use_match(K_CTRL_Y)` is true). #[test] fn insert_ctrl_y_accepts_arrow_selected_completion() { - let (mut editor, buffer, mut machine) = - named_editor("foobar foobat\nx", position(2, 0)); + let (mut editor, buffer, mut machine) = named_editor("foobar foobat\nx", position(2, 0)); let mut eval = NullExprEval; machine.feed_keys(&mut editor, "i", &mut eval).unwrap(); machine .feed_keys(&mut editor, "fo\u{0e}", &mut eval) .unwrap(); feed_special(&mut editor, &mut machine, b'k', b'd'); - machine - .feed_keys(&mut editor, "\u{19}", &mut eval) - .unwrap(); - let text = String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()) - .unwrap(); + machine.feed_keys(&mut editor, "\u{19}", &mut eval).unwrap(); + let text = + String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()).unwrap(); assert_eq!(text, "foobar foobat\nfoobatx"); assert!(!machine.completion.is_active()); } @@ -5135,8 +5130,7 @@ fn insert_ctrl_y_accepts_arrow_selected_completion() { /// original. #[test] fn insert_ctrl_y_restores_leader_when_selection_is_original() { - let (mut editor, buffer, mut machine) = - named_editor("foobar foobat\nx", position(2, 0)); + let (mut editor, buffer, mut machine) = named_editor("foobar foobat\nx", position(2, 0)); let mut eval = NullExprEval; machine.feed_keys(&mut editor, "i", &mut eval).unwrap(); machine @@ -5145,11 +5139,9 @@ fn insert_ctrl_y_restores_leader_when_selection_is_original() { // Selection on the original entry: the inserted "obar" tail stays // until accept (`ins_compl_use_match(K_UP)` is false). feed_special(&mut editor, &mut machine, b'k', b'u'); - machine - .feed_keys(&mut editor, "\u{19}", &mut eval) - .unwrap(); - let text = String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()) - .unwrap(); + machine.feed_keys(&mut editor, "\u{19}", &mut eval).unwrap(); + let text = + String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()).unwrap(); assert_eq!(text, "foobar foobat\nfox"); assert!(!machine.completion.is_active()); } @@ -5159,8 +5151,7 @@ fn insert_ctrl_y_restores_leader_when_selection_is_original() { /// for them) — the cursor and the last-inserted match stay put. #[test] fn insert_arrows_select_completion_without_inserting() { - let (mut editor, buffer, mut machine) = - named_editor("foobar foobat\nx", position(2, 0)); + let (mut editor, buffer, mut machine) = named_editor("foobar foobat\nx", position(2, 0)); let mut eval = NullExprEval; machine.feed_keys(&mut editor, "i", &mut eval).unwrap(); machine @@ -5194,8 +5185,8 @@ fn insert_down_snaps_column_to_char_boundary() { // 2): the cursor lands on byte 3, not inside `é` (bytes 1..3). assert_eq!(window_cursor(&editor), position(2, 3)); machine.feed_keys(&mut editor, "Z", &mut eval).unwrap(); - let text = String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()) - .unwrap(); + let text = + String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()).unwrap(); assert_eq!(text, "abcd\naéZx"); } @@ -5226,7 +5217,9 @@ fn cmdline_up_matches_prefix_through_cursor_only() { let (mut editor, _, mut machine) = named_editor("x", position(1, 0)); machine.set_cmdline_history(vec!["edit file".to_owned(), "edit target".to_owned()]); let mut eval = NullExprEval; - machine.feed_keys(&mut editor, ":edit typo", &mut eval).unwrap(); + machine + .feed_keys(&mut editor, ":edit typo", &mut eval) + .unwrap(); // `c_` lands before `typo` — cursor at byte 5. feed_special(&mut editor, &mut machine, crate::KS_EXTRA, 85); feed_special(&mut editor, &mut machine, b'k', b'u'); @@ -5318,13 +5311,13 @@ fn insert_pending_literal_and_register_take_named_key() { let mut eval = NullExprEval; machine.feed_keys(&mut editor, "i\x16", &mut eval).unwrap(); feed_special(&mut editor, &mut machine, b'k', b'u'); - let text = String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()) - .unwrap(); + let text = + String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()).unwrap(); assert_eq!(text, ""); machine.feed_keys(&mut editor, "\x12", &mut eval).unwrap(); feed_special(&mut editor, &mut machine, b'k', b'd'); - let text = String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()) - .unwrap(); + let text = + String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()).unwrap(); assert_eq!(text, ""); } @@ -5334,10 +5327,12 @@ fn insert_pending_literal_and_register_take_named_key() { fn insert_named_key_flushes_digit_literal_first() { let (mut editor, buffer, mut machine) = named_editor("xy\nzz", position(2, 0)); let mut eval = NullExprEval; - machine.feed_keys(&mut editor, "i\x1665", &mut eval).unwrap(); - feed_special(&mut editor, &mut machine, b'k', b'u'); - let text = String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()) + machine + .feed_keys(&mut editor, "i\x1665", &mut eval) .unwrap(); + feed_special(&mut editor, &mut machine, b'k', b'u'); + let text = + String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()).unwrap(); assert_eq!(text, "xy\nAzz"); assert_eq!(window_cursor(&editor), position(1, 1)); } @@ -5348,11 +5343,12 @@ fn insert_named_key_flushes_digit_literal_first() { /// and its text destroyed). #[test] fn insert_ctrl_r_linewise_register_preserves_current_line() { - let (mut editor, buffer, mut machine) = - named_editor("alpha bravo\nfifteen", position(1, 0)); + let (mut editor, buffer, mut machine) = named_editor("alpha bravo\nfifteen", position(1, 0)); let mut eval = NullExprEval; machine.feed_keys(&mut editor, "\"ayy", &mut eval).unwrap(); - machine.feed_keys(&mut editor, "G0i\x12a", &mut eval).unwrap(); + machine + .feed_keys(&mut editor, "G0i\x12a", &mut eval) + .unwrap(); let text = String::from_utf8(editor.buffer(buffer).unwrap().text().unwrap().to_bytes()).unwrap(); assert_eq!(text, "alpha bravo\nalpha bravo\nfifteen"); From f433f6d3bef3beedca7c72437450f4df383387b8 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:51:13 +0900 Subject: [PATCH 006/263] fix(fold,diff): model folds and diff hunks from upstream semantics Fold ranges, foldmethod evaluation, and the open and close commands now come from one model, so `:fold` reports E350 only for methods that need an expression the port does not evaluate, and a closed fold answers foldclosed and foldclosedend consistently. diffget and diffput work on the hunk table instead of reconstructing it per call, which fixes the added and deleted line cases and the fold refresh after a put. --- crates/ox-editor/src/builtins/fold.rs | 84 ++- crates/ox-editor/src/diffmode.rs | 819 +++++++++++++++++++++++++- crates/ox-editor/src/fold.rs | 668 +++++++++++++++++++-- 3 files changed, 1489 insertions(+), 82 deletions(-) diff --git a/crates/ox-editor/src/builtins/fold.rs b/crates/ox-editor/src/builtins/fold.rs index 12d05744..ede1d5d9 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}; @@ -116,18 +116,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 +153,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 +177,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 +201,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'` diff --git a/crates/ox-editor/src/diffmode.rs b/crates/ox-editor/src/diffmode.rs index e7950981..f393c0f5 100644 --- a/crates/ox-editor/src/diffmode.rs +++ b/crates/ox-editor/src/diffmode.rs @@ -395,12 +395,18 @@ fn compute_blocks( /// Joins line keys with newlines for `similar`'s line-based diff, which /// splits on `\n` and reports per-line operations. +/// +/// Every line is newline-terminated, the way xdiff sees buffer lines: without +/// the trailing newline `similar`'s Myers mis-tiles a trailing equal run +/// into the last hunk (e.g. `['2','4','6']` vs `1..7` ends with a bogus +/// `Replace` instead of three one-line insertions plus matches). fn lines_to_text(lines: &[Vec]) -> String { - lines - .iter() - .map(|line| String::from_utf8_lossy(line).into_owned()) - .collect::>() - .join("\n") + let mut text = String::new(); + for line in lines { + text.push_str(&String::from_utf8_lossy(line)); + text.push('\n'); + } + text } /// `diff_infold` (diff.c:3547) for the current window as closed fold ranges @@ -481,6 +487,547 @@ fn fold_ranges_around( ranges } +/// Failure of [`diffgetput`]: the upstream `E` code plus its message, so the +/// Ex caller can report `E99`–`E103`, `E21` or `E793` exactly as +/// `ex_diffgetput` does. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DiffGetPutError { + /// Upstream error code (`E99`, `E100`, `E101`, `E102`, `E103`, `E21`, + /// `E793`). + pub code: &'static str, + /// Upstream error message without the code prefix. + pub message: String, +} + +impl DiffGetPutError { + fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +/// One hunk between the current buffer and the other buffer, in 1-based +/// `(start, count)` coordinates on each side. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Hunk { + /// Span in the current buffer. + current: (i64, i64), + /// Span in the other buffer. + other: (i64, i64), +} + +/// Copies diff hunks between two diff buffers: `:diffget` (`put == false)` +/// and `:diffput` (`put == true`), i.e. `ex_diffgetput` plus `diffgetput` +/// (`src/nvim/diff.c:3679-4029`). +/// +/// `target` is the other buffer (`None` selects it the way an argumentless +/// command does), `first`/`last` are the 1-based range in the current buffer, +/// and `addr_count` is whether the caller gave a range at all. A `do` or `dp` +/// normal command calls this with the cursor line and `addr_count == 0`, and +/// the bare-line adjustment below the cursor (`diff.c:3694-3708`) applies. +/// +/// The walk, the `start_skip`/`end_skip` range clipping, the no-range adjacent +/// hunk skip and the `off` tracking for edits to the current buffer all +/// mirror `diffgetput`; after any edit the tabpage blocks are recomputed, +/// which is upstream's trailing `ex_diffupdate` (`diff.c:3975-3978`). +/// Position bookkeeping inside the edited region rides on +/// [`Editor::replace_buffer_lines`]. +/// +/// Hunks only exist against the tabpage's reference buffer, so a get or put +/// between two compared buffers is a silent no-op; the oldtests only exercise +/// pairs touching the reference buffer. +/// +/// # Errors +/// +/// Returns [`DiffGetPutError`] with `E99` when the current buffer is not in +/// diff mode, `E100`/`E793`/`E101` when the argumentless buffer choice fails, +/// `E102`/`E103` for an unusable explicit buffer, or `E21` when the edited +/// buffer is not modifiable. +pub fn diffgetput( + editor: &mut Editor, + put: bool, + target: Option, + first: usize, + last: usize, + addr_count: usize, +) -> Result<(), DiffGetPutError> { + let tab = editor + .current_tabpage() + .ok_or_else(|| DiffGetPutError::new("E444", "No current tabpage"))?; + let window = editor + .current_window() + .ok_or_else(|| DiffGetPutError::new("E444", "No current window"))?; + let current = editor + .window(window) + .map(|state| state.buffer) + .map_err(|_| DiffGetPutError::new("E444", "No current window"))?; + let participants = diff_participants(editor, tab); + let position = participants + .iter() + .position(|&(_, buffer)| buffer == current) + .ok_or_else(|| DiffGetPutError::new("E99", "Current buffer is not in diff mode"))?; + + // Resolve the other buffer: argumentless selection skips buffers that a + // `:diffput` could not write to, then refuses a third diff buffer. + let other = match target { + None => select_other_buffer(editor, &participants, position, put)?, + Some(buffer) => { + if buffer == current { + return Ok(()); + } + if editor.buffer(buffer).is_err() { + let id = buffer_id(buffer); + return Err(DiffGetPutError::new( + "E102", + format!("Can't find buffer \"{id}\""), + )); + } + if !participants + .iter() + .any(|&(_, candidate)| candidate == buffer) + { + let name = buffer_display_name(editor, buffer); + return Err(DiffGetPutError::new( + "E103", + format!("Buffer \"{name}\" is not in diff mode"), + )); + } + buffer + } + }; + + // With no range the command works on the hunk at or just above the + // cursor, except on the last line with no hunk above, which takes the + // hunk below instead (`diff.c:3694-3708`). The "no hunk" tests are + // `diff_check` (`diff.c:2305-2320`): the cursor line must not be at or + // in a hunk, while the line above only vetoes when it sits strictly + // inside a change — an insertion point reports filler lines with the + // status untouched, so it does not veto. + let cursor_line = editor + .window(window) + .map(|state| state.cursor.lnum) + .unwrap_or(1) + .max(1); + let current_len = buffer_line_count(editor, current).max(1); + let (line1, line2) = if addr_count == 0 { + if cursor_line >= current_len + && hunk_at(editor, current, cursor_line).is_none() + && (cursor_line <= 1 || !hunk_strictly_inside(editor, current, cursor_line - 1)) + { + (cursor_line, cursor_line + 1) + } else { + (cursor_line.saturating_sub(1), cursor_line) + } + } else { + // `line1` may be zero (`0,1diffget` inserts above the first line, + // `diff.c:3679-3728`); only the order is enforced. + (first, last.max(first)) + }; + + if !buffer_modifiable(editor, if put { other } else { current }) { + return Err(DiffGetPutError::new( + "E21", + "Cannot make changes, 'modifiable' is off", + )); + } + + ensure_blocks(editor); + let hunks = hunks_between(editor, tab, current, other, &participants); + let from = if put { current } else { other }; + let from_lines = crate::excmd_exec::buffer_lines(editor, from) + .map_err(|_| DiffGetPutError::new("E749", "Empty buffer"))?; + + // `off` tracks how edits to the current buffer shift the lines below + // them; edits to the other buffer (`:diffput`) keep original coordinates. + let mut off: i64 = 0; + let mut index = 0; + while index < hunks.len() { + // With no range, adjacent hunks at or above the cursor collapse to + // the last one (`diff.c:3875-3884`). + if addr_count == 0 { + while index + 1 < hunks.len() + && hunks[index + 1].current.0 == hunks[index].current.0 + hunks[index].current.1 + && hunks[index + 1].current.0 == line_number(line1) + off + 1 + { + index += 1; + } + } + // The range check runs in original hunk coordinates: shifting it by + // `off` ends multi-hunk gets early (oracle: a full-range get must + // still reach hunks below an earlier net deletion). `off` only + // positions the edit and clips the range. + let hunk = hunks[index]; + if hunk.current.0 > line_number(line2) { + break; + } + if hunk.current.0 + hunk.current.1 > line_number(line1) { + off += apply_hunk( + editor, + HunkEdit { + hunk, + to: if put { other } else { current }, + current, + tracks_off: !put, + from_lines: &from_lines, + line1: line_number(line1) + off, + line2: line_number(line2) + off, + ranged: addr_count != 0, + window, + }, + )?; + } + index += 1; + } + + diffupdate(editor); + clamp_window_cursor(editor, window); + Ok(()) +} + +/// One hunk application: target buffer, source lines, range and cursor context. +/// +/// The direction falls out of `to` versus `current`: when they differ the +/// edit is a put, otherwise a get. No flag selects the behavior. +struct HunkEdit<'a> { + /// Hunk in current-buffer/other-buffer coordinates. + hunk: Hunk, + /// Buffer receiving the edit. + to: BufHandle, + /// Buffer the range addresses. + current: BufHandle, + /// Whether the net delta feeds the caller's `off` tracking. + tracks_off: bool, + /// Current source buffer lines. + from_lines: &'a [Vec], + /// 1-based range start in current-buffer coordinates. + line1: i64, + /// 1-based range end in current-buffer coordinates. + line2: i64, + /// Whether the caller gave an explicit range. + ranged: bool, + /// Window whose cursor the edit adjusts. + window: WinHandle, +} + +/// Converts a 1-based line number to `i64` coordinates, saturating instead of +/// wrapping on unrealistic inputs. +fn line_number(line: usize) -> i64 { + i64::try_from(line).unwrap_or(i64::MAX) +} + +/// Collects the hunks between `current` and `other` in ascending order. +/// +/// Blocks are computed per compared buffer against the tabpage's reference +/// buffer, so only pairs touching it resolve; anything else is empty. +fn hunks_between( + editor: &Editor, + tab: TabHandle, + current: BufHandle, + other: BufHandle, + participants: &[(WinHandle, BufHandle)], +) -> Vec { + let Some(reference) = participants.first().map(|&(_, buffer)| buffer) else { + return Vec::new(); + }; + let Some(entry) = editor.diff.blocks.get(&tab) else { + return Vec::new(); + }; + let mut hunks = Vec::new(); + for block in &entry.blocks { + let pair = if current == reference && block.other_buffer == other { + Some((block.base, block.other)) + } else if other == reference && block.other_buffer == current { + Some((block.other, block.base)) + } else { + None + }; + if let Some(((cur_start, cur_count), (from_start, from_count))) = pair { + hunks.push(Hunk { + current: (line_number(cur_start), line_number(cur_count)), + other: (line_number(from_start), line_number(from_count)), + }); + } + } + hunks +} + +/// Picks the other buffer for an argumentless `:diffget`/`:diffput` +/// (`diff.c:3691-3729`): the first other diff buffer, skipping ones a +/// `:diffput` could not modify, and refusing when a third buffer remains. +fn select_other_buffer( + editor: &Editor, + participants: &[(WinHandle, BufHandle)], + position: usize, + put: bool, +) -> Result { + let current = participants[position].1; + let mut found_not_ma = false; + let mut other = None; + for &(_, buffer) in participants { + if buffer == current { + continue; + } + if put && !buffer_modifiable(editor, buffer) { + found_not_ma = true; + continue; + } + other = Some(buffer); + break; + } + let Some(other) = other else { + if found_not_ma { + return Err(DiffGetPutError::new( + "E793", + "No other buffer in diff mode is modifiable", + )); + } + return Err(DiffGetPutError::new("E100", "No other buffer in diff mode")); + }; + for &(_, buffer) in participants { + if buffer == current || buffer == other { + continue; + } + if put && !buffer_modifiable(editor, buffer) { + continue; + } + return Err(DiffGetPutError::new( + "E101", + "More than two buffers in diff mode, don't know which one to use", + )); + } + Ok(other) +} + +/// Applies one overlapped hunk and answers the net line delta when the edit +/// went to the current buffer (`off` tracking, `diff.c:4019-4022`). +/// +/// The target span is deleted and the source lines clipped by +/// `start_skip`/`end_skip` are inserted in a single undo entry +/// (`diff.c:3899-3990`). The source slice stops at the source buffer's last +/// line (`diff.c:3940-3942`), and inserting the first line into a single +/// empty line replaces that dummy line, so one undo restores the empty +/// buffer (`diff.c:3945-3953`). +fn apply_hunk(editor: &mut Editor, edit: HunkEdit<'_>) -> Result { + let HunkEdit { + hunk, + to, + current, + tracks_off, + from_lines, + line1, + line2, + ranged, + window, + } = edit; + let put = to != current; + let (mut to_start, mut to_count) = if put { hunk.other } else { hunk.current }; + let (mut from_start, mut from_count) = if put { hunk.current } else { hunk.other }; + if ranged { + let mut start_skip = line1 - hunk.current.0; + if start_skip > 0 { + if start_skip > to_count { + to_start += to_count; + to_count = 0; + } else { + to_count -= start_skip; + to_start += start_skip; + } + } else { + start_skip = 0; + } + let mut end_skip = hunk.current.0 + hunk.current.1 - 1 - line2; + if end_skip > 0 { + if put { + to_count = to_count.min(hunk.current.1 - start_skip - end_skip); + } else { + to_count -= end_skip; + end_skip = (from_count - start_skip - to_count).max(0); + } + } else { + end_skip = 0; + } + // The oracle keeps the last source line across a start clip: a + // partial `2,5diffget` over a split change still inserts the source + // line above the range, while a full-span clip still applies. The + // end skip applies in full on both sides. + let from_advance = start_skip.min(from_count.saturating_sub(1).max(0)); + from_start += from_advance; + from_count -= from_advance + end_skip; + } + let available = line_number(from_lines.len()) - from_start + 1; + let take = from_count.max(0).min(available).max(0); + let skip = usize::try_from(from_start.max(1) - 1).unwrap_or(usize::MAX); + let take_count = usize::try_from(take).unwrap_or(0); + let inserted: Vec> = from_lines + .iter() + .skip(skip.min(from_lines.len())) + .take(take_count) + .cloned() + .collect(); + let deleted = to_count.max(0); + let deleted_count = usize::try_from(deleted).unwrap_or(0); + + // Replacing the single dummy line of an empty buffer keeps one undo + // entry for the whole get (`diff.c:3945-3953`). + let to_lines = crate::excmd_exec::buffer_lines(editor, to) + .map_err(|error| DiffGetPutError::new("E749", error.to_string()))?; + let single_empty = to_lines.len() == 1 && to_lines[0].is_empty(); + let (rep_start, rep_end) = if deleted == 0 && to_start == 1 && single_empty { + (1, 1) + } else { + let start = usize::try_from(to_start.max(1)).unwrap_or(1); + let end = usize::try_from((to_start + deleted - 1).max(0)).unwrap_or(0); + (start, end) + }; + + let cursor_before = editor + .window(window) + .map(|state| state.cursor) + .unwrap_or(ox_text::Position { lnum: 1, col: 0 }); + let mut cursor_after = cursor_before; + if editor.window(window).is_ok_and(|state| state.buffer == to) { + let net = line_number(inserted.len()) - deleted; + let at = line_number(cursor_after.lnum); + if at >= line_number(rep_start) { + if at >= line_number(rep_start) + deleted { + cursor_after.lnum = usize::try_from((at + net).max(1)).unwrap_or(cursor_after.lnum); + } else if net < 0 { + cursor_after.lnum = rep_start; + } + } + let count = buffer_line_count(editor, to) + .saturating_add(inserted.len()) + .saturating_sub(deleted_count) + .max(1); + cursor_after.lnum = cursor_after.lnum.min(count); + } + editor + .replace_buffer_lines(crate::LineReplaceRequest { + buffer: to, + start: rep_start, + end: rep_end, + lines: &inserted, + cursor_before, + cursor_after, + timestamp: 0, + }) + .map_err(|error| DiffGetPutError::new("E749", error.to_string()))?; + Ok(if tracks_off { + line_number(inserted.len()) - deleted + } else { + 0 + }) +} + +/// Whether `buffer` accepts edits (`'modifiable'`). +fn buffer_modifiable(editor: &Editor, buffer: BufHandle) -> bool { + !matches!( + editor.options().get_buffer(buffer, "modifiable"), + Ok(OptionValue::Boolean(false)) + ) +} + +/// Human name of `buffer` for `E103`, falling back to its handle. +fn buffer_display_name(editor: &Editor, buffer: BufHandle) -> String { + editor + .buffer(buffer) + .map(|state| state.name().to_string_lossy().into_owned()) + .unwrap_or_else(|_| format!("{buffer:?}")) +} + +/// Number of lines in `buffer`, or zero when it cannot be read. +fn buffer_line_count(editor: &Editor, buffer: BufHandle) -> usize { + crate::excmd_exec::buffer_lines(editor, buffer).map_or(0, |lines| lines.len()) +} + +/// Numeric identity of `buffer` for `E102`, which reports the buffer the +/// caller asked for. +fn buffer_id(buffer: BufHandle) -> String { + format!("{buffer:?}") +} + +/// Answers whether `line` sits strictly inside a change hunk in `buffer`'s +/// coordinates — the `diff_check` (`diff.c:2305-2320`) case that marks the +/// line status and vetoes the no-range hunk-below adjustment. Insertion +/// points (`count == 0`) never contain a line, so they never veto. +fn hunk_strictly_inside(editor: &mut Editor, buffer: BufHandle, line: usize) -> bool { + ensure_blocks(editor); + let Some(tab) = editor.current_tabpage() else { + return false; + }; + let participants = diff_participants(editor, tab); + let Some(reference) = participants.first().map(|&(_, buffer)| buffer) else { + return false; + }; + let Some(entry) = editor.diff.blocks.get(&tab) else { + return false; + }; + let line = line_number(line); + for block in &entry.blocks { + let pair = if buffer == reference { + Some(block.base) + } else if block.other_buffer == buffer { + Some(block.other) + } else { + continue; + }; + let Some((raw_start, raw_count)) = pair else { + continue; + }; + let (start, count) = (line_number(raw_start), line_number(raw_count)); + if count > 0 && start <= line && line < start + count { + return true; + } + } + false +} + +/// The index of the first hunk covering `line` in `buffer`'s coordinates, if +/// any — the `diff_check` scan (`diff.c:2305-2320`): the first hunk reaching +/// at least `line` covers it exactly when it starts at or above `line`. +fn hunk_at(editor: &mut Editor, buffer: BufHandle, line: usize) -> Option { + ensure_blocks(editor); + let tab = editor.current_tabpage()?; + let participants = diff_participants(editor, tab); + let reference = participants.first().map(|&(_, buffer)| buffer)?; + let entry = editor.diff.blocks.get(&tab)?; + let line = line_number(line); + for (index, block) in entry.blocks.iter().enumerate() { + let pair = if buffer == reference { + Some(block.base) + } else if block.other_buffer == buffer { + Some(block.other) + } else { + None + }; + let Some((raw_start, raw_count)) = pair else { + continue; + }; + let (start, count) = (line_number(raw_start), line_number(raw_count)); + if line <= start + count { + return (line >= start).then_some(index); + } + } + None +} + +/// Clamps the window cursor into the buffer after a get/put, matching +/// `check_cursor` (`diff.c:3980`). +fn clamp_window_cursor(editor: &mut Editor, window: WinHandle) { + let Ok(state) = editor.window(window) else { + return; + }; + let buffer = state.buffer; + let lnum = state.cursor.lnum; + let count = buffer_line_count(editor, buffer).max(1); + if lnum > count { + let col = state.cursor.col; + let _ = editor.set_window_cursor(window, ox_text::Position { lnum: count, col }); + } +} + /// Adds "hor" to `'scrollopt'` when missing (diff.c:1595-1597). fn add_scrollopt_hor(editor: &mut Editor) { let value = match editor.options().get_global("scrollopt") { @@ -871,3 +1418,265 @@ mod tests { assert_eq!(values, vec![-1, 19]); } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod diffgetput_tests { + //! Every expectation below is the value the oracle at + //! `.references/neovim/build/bin/nvim` (v0.13.0-dev-1390) answers for the + //! same script, recorded next to each assertion. + + use super::*; + use crate::Geometry; + use crate::TestEditorAccess; + use crate::excmd_exec::ExExecutor; + + /// An editor with one listed buffer shown in one 80x24 window. + fn editor() -> TestEditorAccess { + let mut editor = Editor::new(); + let buffer = editor.create_buffer(true).unwrap(); + editor + .create_tabpage(buffer, Geometry::new(0, 0, 80, 24).unwrap()) + .unwrap(); + TestEditorAccess::new(editor) + } + + /// Runs `script`, then answers the current buffer's lines as strings. + fn buffer_strings(access: &TestEditorAccess) -> Vec { + let editor = access.editor(); + let buffer = editor.current_buffer().unwrap(); + crate::excmd_exec::buffer_lines(&editor, buffer) + .unwrap() + .iter() + .map(|line| String::from_utf8_lossy(line).into_owned()) + .collect() + } + + /// Sets up buffers `0..49` (current) and a changed copy in a split. + fn two_buffers() -> (TestEditorAccess, ExExecutor) { + let access = editor(); + let mut exec = ExExecutor::new(); + exec.execute_script( + &access, + "diffmode.vim", + "call setline(1, range(50))\n\ + diffthis\n\ + new\n\ + let l = range(50)\n\ + let l[10] = 'one'\n\ + let l[20] = 'two'\n\ + call setline(1, l)\n\ + diffthis\n\ + wincmd p", + ) + .unwrap(); + (access, exec) + } + + // diff.c:3686 — the current buffer must take part in diff mode. + #[test] + fn diffgetput_reports_e99_without_diff_mode() { + let access = editor(); + let mut editor = access.editor_mut(); + let error = diffgetput(&mut editor, false, None, 1, 1, 1).unwrap_err(); + assert_eq!(error.code, "E99"); + } + + // diff.c:3708 — one diff buffer and no target: nothing to get from. + #[test] + fn diffgetput_reports_e100_with_a_single_diff_buffer() { + let access = editor(); + let mut exec = ExExecutor::new(); + exec.execute_script( + &access, + "diffmode.vim", + "call setline(1, range(50))\ndiffthis", + ) + .unwrap(); + let mut editor = access.editor_mut(); + let error = diffgetput(&mut editor, false, None, 1, 1, 1).unwrap_err(); + assert_eq!(error.code, "E100"); + } + + // diff.c:3719 — three diff buffers and no target: the choice is ambiguous. + #[test] + fn diffgetput_reports_e101_with_three_diff_buffers() { + let (access, _) = two_buffers(); + let mut exec = ExExecutor::new(); + exec.execute_script(&access, "diffmode.vim", "new\ndiffthis\nwincmd p") + .unwrap(); + let mut editor = access.editor_mut(); + let error = diffgetput(&mut editor, false, None, 11, 11, 1).unwrap_err(); + assert_eq!(error.code, "E101"); + } + + // Test_diffget_diffput (`11diffget`): the hunk at line 11 copies over. + #[test] + fn diffget_copies_the_addressed_hunk() { + let (access, _) = two_buffers(); + let mut editor = access.editor_mut(); + diffgetput(&mut editor, false, None, 11, 11, 1).unwrap(); + drop(editor); + let lines = buffer_strings(&access); + assert_eq!(lines[10], "one"); + assert_eq!(lines[20], "20"); + } + + // Test_diffget_diffput (`21diffput`): putting line 21 writes the other buffer. + #[test] + fn diffput_copies_the_addressed_hunk() { + let (access, _) = two_buffers(); + let mut editor = access.editor_mut(); + diffgetput(&mut editor, true, None, 21, 21, 1).unwrap(); + drop(editor); + let mut exec = ExExecutor::new(); + exec.execute_script(&access, "diffmode.vim", "wincmd w") + .unwrap(); + let lines = buffer_strings(&access); + assert_eq!(lines[20], "20"); + } + + // Test_diffget_diffput_deleted_lines (`3,3diffget` gets nothing, + // `3,4diffget` takes the last insertion past the end of file). + #[test] + fn diffget_handles_added_and_deleted_lines() { + let access = editor(); + let mut exec = ExExecutor::new(); + exec.execute_script( + &access, + "diffmode.vim", + "call setline(1, ['2','4','6'])\n\ + diffthis\n\ + new\n\ + call setline(1, range(1,7))\n\ + diffthis\n\ + wincmd w", + ) + .unwrap(); + let mut editor = access.editor_mut(); + diffgetput(&mut editor, false, None, 3, 3, 1).unwrap(); + drop(editor); + assert_eq!(buffer_strings(&access), vec!["2", "4", "6"]); + let mut editor = access.editor_mut(); + diffgetput(&mut editor, false, None, 3, 4, 1).unwrap(); + drop(editor); + assert_eq!(buffer_strings(&access), vec!["2", "4", "6", "7"]); + // `0,1diffget` takes the first insertion above the first line. + let mut editor = access.editor_mut(); + diffgetput(&mut editor, false, None, 0, 1, 1).unwrap(); + drop(editor); + assert_eq!(buffer_strings(&access), vec!["1", "2", "4", "6", "7"]); + } + + // Test_diffget_diffput_empty_buffer: getting into `['']` answers `['one']` + // and one undo restores the empty buffer. + #[test] + fn diffget_into_an_empty_buffer_undoes_cleanly() { + let access = editor(); + let mut exec = ExExecutor::new(); + exec.execute_script( + &access, + "diffmode.vim", + "diffthis\n\ + new\n\ + call setline(1, 'one')\n\ + diffthis\n\ + wincmd p", + ) + .unwrap(); + let mut editor = access.editor_mut(); + diffgetput(&mut editor, false, None, 1, 1, 0).unwrap(); + drop(editor); + assert_eq!(buffer_strings(&access), vec!["one"]); + let mut editor = access.editor_mut(); + let buffer = editor.current_buffer().unwrap(); + editor.buffer_undo(buffer).unwrap(); + drop(editor); + assert_eq!(buffer_strings(&access), vec![""]); + } + + // `:diffget` with no range acts on the hunk at the cursor line. + #[test] + fn diffget_without_a_range_uses_the_cursor_hunk() { + let (access, _) = two_buffers(); + let mut exec = ExExecutor::new(); + exec.execute_script(&access, "diffmode.vim", "call cursor(11, 1)") + .unwrap(); + let mut editor = access.editor_mut(); + diffgetput(&mut editor, false, None, 1, 1, 0).unwrap(); + drop(editor); + assert_eq!(buffer_strings(&access)[10], "one"); + } + + // Test_diff_nomodifiable (`norm do` into `'nomodifiable'`): E21. + #[test] + fn diffget_into_a_nommodifiable_buffer_fails() { + let (access, _) = two_buffers(); + let mut exec = ExExecutor::new(); + exec.execute_script(&access, "diffmode.vim", "setl nomodifiable") + .unwrap(); + let mut editor = access.editor_mut(); + let error = diffgetput(&mut editor, false, None, 11, 11, 1).unwrap_err(); + assert_eq!(error.code, "E21"); + } + + // Oracle probe: a partial `2,5diffget` over a split change keeps the + // source line above the range while clipping the target span. + #[test] + fn diffget_partial_range_keeps_the_source_line() { + let access = editor(); + let mut exec = ExExecutor::new(); + exec.execute_script( + &access, + "diffmode.vim", + "call setline(1, ['a','b','c','d','e'])\n\ + diffthis\n\ + new\n\ + call setline(1, ['X','c','Z','e'])\n\ + diffthis\n\ + wincmd p", + ) + .unwrap(); + let mut editor = access.editor_mut(); + diffgetput(&mut editor, false, None, 2, 5, 1).unwrap(); + drop(editor); + assert_eq!(buffer_strings(&access), vec!["a", "X", "c", "Z", "e"]); + } + + // Test_diffget_diffput_deleted_lines (`norm Gdp` twice): the no-range + // put on the last line takes the hunk below when the line above is only + // an insertion point. + #[test] + fn diffput_without_a_range_takes_the_hunk_below() { + let access = editor(); + let mut exec = ExExecutor::new(); + exec.execute_script( + &access, + "diffmode.vim", + "call setline(1, ['2','4','6'])\n\ + diffthis\n\ + new\n\ + call setline(1, range(1,7))\n\ + diffthis\n\ + wincmd p", + ) + .unwrap(); + let mut editor = access.editor_mut(); + diffgetput(&mut editor, true, None, 2, 3, 1).unwrap(); + drop(editor); + let mut exec = ExExecutor::new(); + exec.execute_script(&access, "diffmode.vim", "wincmd w") + .unwrap(); + assert_eq!(buffer_strings(&access), vec!["1", "2", "3", "4", "6", "7"]); + let mut exec = ExExecutor::new(); + exec.execute_script(&access, "diffmode.vim", "wincmd p\ncall cursor(3, 1)") + .unwrap(); + let mut editor = access.editor_mut(); + diffgetput(&mut editor, true, None, 3, 3, 0).unwrap(); + drop(editor); + let mut exec = ExExecutor::new(); + exec.execute_script(&access, "diffmode.vim", "wincmd w") + .unwrap(); + assert_eq!(buffer_strings(&access), vec!["1", "2", "3", "4", "6"]); + } +} diff --git a/crates/ox-editor/src/fold.rs b/crates/ox-editor/src/fold.rs index 97194ecf..f94c7863 100644 --- a/crates/ox-editor/src/fold.rs +++ b/crates/ox-editor/src/fold.rs @@ -15,6 +15,10 @@ use thiserror::Error; /// See `src/nvim/fold.c:78-84`. pub const MAX_FOLD_DEPTH: usize = 20; +/// The same bound as an `i32` for level arithmetic (`MAX_LEVEL` in +/// `src/nvim/fold.c:78-84`). +const MAX_LEVEL: i32 = 20; + /// A zero-based, byte-oriented buffer position. #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct Position { @@ -326,6 +330,10 @@ pub enum FoldError { pub struct Folds { method: FoldMethod, shift_width: usize, + tab_width: usize, + fold_ignore: Vec, + nest_max: i64, + min_lines: usize, marker_start: Vec, marker_end: Vec, manual: Vec, @@ -348,6 +356,10 @@ impl Folds { Self { method: FoldMethod::Manual, shift_width: 8, + tab_width: 8, + fold_ignore: b"#".to_vec(), + nest_max: i64::try_from(MAX_FOLD_DEPTH).unwrap_or(i64::MAX), + min_lines: 1, marker_start: b"{{{".to_vec(), marker_end: b"}}}".to_vec(), manual: Vec::new(), @@ -365,10 +377,25 @@ impl Folds { } /// Selects a fold method and invalidates computed data. + /// + /// Upstream's `foldUpdate` (`src/nvim/fold.c:742-776`) is a no-op for the + /// manual method: the window's fold tree survives a method switch, so + /// moving *to* manual adopts the computed folds as manual ones instead + /// of dropping them (`set fdm=manual` after indent keeps the folds). + /// A non-empty manual set wins over adopted folds. pub fn set_method(&mut self, method: FoldMethod) { if self.method != method { + let previous = self.method; self.method = method; - self.computed.clear(); + if method == FoldMethod::Manual + && previous != FoldMethod::Manual + && self.manual.is_empty() + && !self.computed.is_empty() + { + self.manual = std::mem::take(&mut self.computed); + } else { + self.computed.clear(); + } self.changedtick = None; self.requested_tick = None; self.dirty = true; @@ -399,6 +426,81 @@ impl Folds { Ok(()) } + /// Returns the tab-stop width used when measuring indent columns. + /// + /// Upstream's `get_indent_buf` counts a tab against `'tabstop'` + /// (`src/nvim/indent.c:369-372`), while the level divisor is the + /// effective shift width. + #[must_use] + pub const fn tab_width(&self) -> usize { + self.tab_width + } + + /// Sets the nonzero tab-stop width used when measuring indent columns. + /// + /// # Errors + /// + /// Returns [`FoldError::ZeroShiftWidth`] when `tab_width` is zero. + pub fn set_tab_width(&mut self, tab_width: usize) -> Result<(), FoldError> { + if tab_width == 0 { + return Err(FoldError::ZeroShiftWidth); + } + if self.tab_width != tab_width { + self.tab_width = tab_width; + if self.method == FoldMethod::Indent { + self.dirty = true; + } + } + Ok(()) + } + + /// Returns the `'foldignore'` bytes: lines whose first non-blank byte is + /// one of these have an undefined indent level. + #[must_use] + pub fn fold_ignore(&self) -> &[u8] { + &self.fold_ignore + } + + /// Sets the `'foldignore'` bytes. + pub fn set_fold_ignore(&mut self, ignore: impl Into>) { + let ignore = ignore.into(); + if self.fold_ignore != ignore { + self.fold_ignore = ignore; + if self.method == FoldMethod::Indent { + self.dirty = true; + } + } + } + + /// Returns the `'foldnestmax'` cap, clamped at use to `0..=MAX_FOLD_DEPTH`. + #[must_use] + pub const fn nest_max(&self) -> i64 { + self.nest_max + } + + /// Sets the `'foldnestmax'` cap (`src/nvim/fold.c:2783-2799` clamps each + /// indent level to `MAX(0, 'foldnestmax')`). + pub fn set_nest_max(&mut self, nest_max: i64) { + if self.nest_max != nest_max { + self.nest_max = nest_max; + if self.method == FoldMethod::Indent { + self.dirty = true; + } + } + } + + /// Returns the `'foldminlines'` floor: a closed fold with no more rows + /// than this is displayed open (`checkSmall`, `src/nvim/fold.c:1466-1491`). + #[must_use] + pub const fn min_lines(&self) -> usize { + self.min_lines + } + + /// Sets the `'foldminlines'` floor. + pub fn set_fold_min_lines(&mut self, min_lines: usize) { + self.min_lines = min_lines; + } + /// Returns the marker byte strings as `(start, end)`. #[must_use] pub fn markers(&self) -> (&[u8], &[u8]) { @@ -493,15 +595,15 @@ impl Folds { /// Lazily refreshes local methods or returns typed host work. /// - /// Each line is treated as bytes. Indentation counts ASCII spaces as one - /// column and advances an ASCII tab to the next shift-width boundary. + /// Each line is treated as bytes. Indent levels count ASCII spaces as one + /// column and advance an ASCII tab to the next tab-stop boundary, divided + /// by the shift width (`get_indent_buf`, `src/nvim/indent.c:369-372`). /// /// # Errors /// /// Returns [`FoldError::ZeroShiftWidth`] if indent computation runs with a - /// zero shift width, [`FoldError::EmptyRange`] or [`FoldError::CrossingRanges`] - /// if computed ranges are degenerate, or [`FoldError::WrongHostMethod`] when - /// the active method has no host kind. + /// zero shift or tab width, or [`FoldError::WrongHostMethod`] when the + /// active method has no host kind. pub fn refresh>( &mut self, changedtick: u64, @@ -521,14 +623,20 @@ impl Folds { self.dirty = false; } FoldMethod::Indent => { - let levels = indent_levels(lines, self.shift_width)?; - self.replace_computed(ranges_from_levels(&levels)?)?; + let records = indent_records( + lines, + self.shift_width, + self.tab_width, + &self.fold_ignore, + self.nest_max, + )?; + self.replace_computed(folds_from_records(&records)?)?; self.changedtick = Some(changedtick); self.dirty = false; } FoldMethod::Marker => { - let levels = marker_levels(lines, &self.marker_start, &self.marker_end); - self.replace_computed(ranges_from_levels(&levels)?)?; + let records = marker_records(lines, &self.marker_start, &self.marker_end); + self.replace_computed(folds_from_records(&records)?)?; self.changedtick = Some(changedtick); self.dirty = false; } @@ -731,12 +839,18 @@ impl Folds { /// fold and stops at the first closed one, so a closed fold nested inside /// an open one is reported while an open fold nested inside a closed one is /// not: the answer is always the outermost closed fold covering `row`. + /// A fold with no more rows than [`Self::min_lines`] is never reported + /// closed (`checkSmall`, `src/nvim/fold.c:1466-1491`); the search then + /// continues into its nested folds, which is how a two-line fold shrunk + /// to one line by an edit reads open again. #[must_use] pub fn closed_rows_at(&self, row: usize) -> Option<(usize, usize)> { self.active() .iter() - .find(|fold| fold.range.covers_row(row) && fold.state == FoldState::Closed) + .filter(|fold| fold.range.covers_row(row) && fold.state == FoldState::Closed) + .filter(|fold| fold.range.last_row() + 1 - fold.range.start.row > self.min_lines) .map(|fold| (fold.range.start.row, fold.range.last_row())) + .next() } /// Returns the nesting level at a zero-based row, zero when no fold covers @@ -1040,50 +1154,131 @@ pub fn indent_levels>( Ok(levels) } -fn marker_levels>(lines: &[B], start: &[u8], end: &[u8]) -> Vec { - let mut level = 0usize; - let mut levels = Vec::with_capacity(lines.len()); +/// One line's fold-level record for [`folds_from_records`]. +/// +/// The fields mirror the per-line state `foldUpdateIEMSRecurse` +/// (`src/nvim/fold.c:2074-2425`) threads through a refresh: the level getter +/// fills them in for every line in order, then the builder walks them to cut +/// folds. `start` counts start markers on the line (marker `{{{`, expr `>` +/// and `a`); `had_end` carries the *previous* line's end value (expr `<` and +/// `s`), closing folds the line after it was set. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct LevelRecord { + /// Fold level of this line; negative means undefined (blank line under + /// indent, or a negative expression result), resolved by looking ahead. + level: i32, + /// Start markers found on this line. + start: u32, + /// Previous line's end value, or [`NO_FOLD_END`] when the previous line + /// set none. + had_end: i32, +} + +/// [`LevelRecord::had_end`] value meaning the previous line ended no fold. +const NO_FOLD_END: i32 = i32::MAX; + +/// Computes one [`LevelRecord`] per line for the marker method. +/// +/// This is `foldlevelMarker` (`src/nvim/fold.c:2924-2980`) in record form: +/// the carried level starts at the previous line's next level, a start +/// marker raises both the line level and the next level (an explicit +/// `{{{n` sets both to `n`), and an end marker only lowers the next level +/// (an explicit `}}}n` additionally sets the line level to `n`, capped so +/// it never starts a fold above the level the line began at). +fn marker_records>(lines: &[B], start: &[u8], end: &[u8]) -> Vec { + let mut level: i32 = 0; + let mut records = Vec::with_capacity(lines.len()); for line in lines { let bytes = line.as_ref(); - let mut offset = 0usize; + let start_level = level; let mut next_level = level; - let mut line_level = level; + let mut starts: u32 = 0; + let mut offset = 0usize; while offset < bytes.len() { let start_at = find_bytes(&bytes[offset..], start).map(|at| offset + at); let end_at = find_bytes(&bytes[offset..], end).map(|at| offset + at); - match (start_at, end_at) { - (None, None) => break, - (Some(at), None) => { - let explicit = decimal_after(bytes, at + start.len()); - next_level = explicit - .unwrap_or_else(|| next_level.saturating_add(1)) - .min(MAX_FOLD_DEPTH); - line_level = line_level.max(next_level); - offset = at + start.len(); - } - (Some(start_at), Some(end_at)) if start_at <= end_at => { - let explicit = decimal_after(bytes, start_at + start.len()); - next_level = explicit - .unwrap_or_else(|| next_level.saturating_add(1)) - .min(MAX_FOLD_DEPTH); - line_level = line_level.max(next_level); - offset = start_at + start.len(); - } - (_, Some(at)) => { - line_level = line_level.max(next_level); - let explicit = decimal_after(bytes, at + end.len()); - next_level = explicit.map_or_else( - || next_level.saturating_sub(1), - |value| value.saturating_sub(1), - ); - offset = at + end.len(); - } + if start_at.is_some_and(|at| end_at.is_none_or(|end| at <= end)) { + let at = start_at.unwrap_or(offset); + (level, next_level, starts) = apply_start_marker( + bytes, + at + start.len(), + level, + next_level, + start_level, + starts, + ); + offset = at + start.len(); + } else if let Some(at) = end_at { + (level, next_level) = + apply_end_marker(bytes, at + end.len(), level, next_level, start_level); + offset = at + end.len(); + } else { + break; } } - levels.push(line_level.min(MAX_FOLD_DEPTH)); - level = next_level.min(MAX_FOLD_DEPTH); + next_level = next_level.max(0); + records.push(LevelRecord { + level, + start: starts, + had_end: NO_FOLD_END, + }); + level = next_level; } - levels + records +} + +/// Applies one start marker found at `digit_at` (the first byte after it). +/// +/// Mirrors the start branch of `foldlevelMarker`: a plain marker raises both +/// the line level and the next level and counts one start, while an explicit +/// `{{{n` sets both levels to `n` and reports `MAX(n - start_level, 1)` +/// starts, which is how the builder knows the line begins new folds. +fn apply_start_marker( + bytes: &[u8], + digit_at: usize, + level: i32, + next_level: i32, + start_level: i32, + starts: u32, +) -> (i32, i32, u32) { + if let Some(number) = decimal_after(bytes, digit_at) + && number > 0 + { + let numbered = i32::try_from(number).unwrap_or(i32::MAX).min(MAX_LEVEL); + let count = numbered + .saturating_sub(start_level) + .max(1) + .try_into() + .unwrap_or(u32::MAX); + return (numbered, numbered, count); + } + ( + level.saturating_add(1).min(MAX_LEVEL), + next_level.saturating_add(1).min(MAX_LEVEL), + starts.saturating_add(1), + ) +} + +/// Applies one end marker found at `digit_at` (the first byte after it). +/// +/// Mirrors the end branch of `foldlevelMarker`: a plain marker only lowers +/// the next level, so the end-marker line keeps the fold's level, while an +/// explicit `}}}n` additionally sets the line level to `n` without ever +/// starting a fold above the level the line began at. +fn apply_end_marker( + bytes: &[u8], + digit_at: usize, + level: i32, + next_level: i32, + start_level: i32, +) -> (i32, i32) { + if let Some(number) = decimal_after(bytes, digit_at) + && number > 0 + { + let numbered = i32::try_from(number).unwrap_or(i32::MAX).min(MAX_LEVEL); + return (numbered, numbered.saturating_sub(1).min(start_level)); + } + (level, next_level.saturating_sub(1)) } fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { @@ -1107,31 +1302,196 @@ fn decimal_after(bytes: &[u8], start: usize) -> Option { found.then_some(value) } -fn ranges_from_levels(levels: &[usize]) -> Result, FoldError> { - let max_level = levels - .iter() - .copied() - .max() - .unwrap_or(0) - .min(MAX_FOLD_DEPTH); - let mut ranges = Vec::new(); - for depth in 1..=max_level { - let mut start = None; - for row in 0..=levels.len() { - let folded = levels.get(row).is_some_and(|&level| level >= depth); - match (start, folded) { - (None, true) => start = Some(row), - (Some(start_row), false) => { - ranges.push(FoldRange::lines(start_row, row)?); - start = None; +/// Computes one [`LevelRecord`] per line for the indent method. +/// +/// This is `foldlevelIndent` (`src/nvim/fold.c:2783-2801`) in record form: +/// the level is the indent width (spaces count one column, tabs advance to +/// the next `tab_width` boundary) divided by `shift_width`, capped at +/// `MAX(0, nest_max)`. A blank line, or one whose first non-blank byte is in +/// `ignore` (`'foldignore'`), has an undefined (`-1`) level, except that the +/// first and last buffer lines always report zero +/// (`src/nvim/fold.c:2852-2854`). +/// +/// # Errors +/// +/// Returns [`FoldError::ZeroShiftWidth`] when `shift_width` or `tab_width` +/// is zero. +fn indent_records>( + lines: &[B], + shift_width: usize, + tab_width: usize, + ignore: &[u8], + nest_max: i64, +) -> Result, FoldError> { + if shift_width == 0 || tab_width == 0 { + return Err(FoldError::ZeroShiftWidth); + } + let cap = nest_max + .clamp(0, MAX_LEVEL.into()) + .try_into() + .unwrap_or(0usize); + let last = lines.len().saturating_sub(1); + let mut records = Vec::with_capacity(lines.len()); + for (index, line) in lines.iter().enumerate() { + let bytes = line.as_ref(); + let mut columns = 0usize; + let mut first_content = None; + for &byte in bytes { + match byte { + b' ' => columns = columns.saturating_add(1), + b'\t' => { + let advance = tab_width.saturating_sub(columns % tab_width); + columns = columns.saturating_add(advance.max(1)); + } + _ => { + first_content = Some(byte); + break; } - _ => {} } } + let undefined = match first_content { + None => true, + Some(byte) => ignore.contains(&byte), + }; + let level = if undefined { + if index == 0 || index == last { 0 } else { -1 } + } else { + columns + .saturating_div(shift_width) + .min(cap) + .try_into() + .unwrap_or(MAX_LEVEL) + }; + records.push(LevelRecord { + level, + start: 0, + had_end: NO_FOLD_END, + }); + } + Ok(records) +} + +/// Cuts whole-line fold ranges from per-line [`LevelRecord`]s. +/// +/// This is a fresh whole-buffer `foldUpdateIEMSRecurse` +/// (`src/nvim/fold.c:2074-2425`) without incremental reuse: a level-1 fold +/// starts at the first line with a positive level, and each fold extends +/// while following lines report at least its level. A line past the first +/// whose `start` count exceeds its level surplus (`level > lvl - start`, +/// `src/nvim/fold.c:2410`) or whose `had_end` reaches the fold level +/// (`level >= had_end`) ends the current fold and starts a new one, which is +/// how adjacent same-level marker folds stay apart. Undefined (`-1`) lines +/// are skipped to the next defined line: they join the fold when that line +/// continues it, and are left out when it ends the fold, exactly like the +/// `ll` lookahead in `src/nvim/fold.c:2332-2372`. +/// +/// # Errors +/// +/// Returns [`FoldError::EmptyRange`] if a computed range is degenerate. +fn folds_from_records(records: &[LevelRecord]) -> Result, FoldError> { + let mut ranges = Vec::new(); + let mut row = 0; + while row < records.len() { + if records[row].level <= 0 { + row += 1; + continue; + } + row = build_fold(records, 1, row, &mut ranges)?; } Ok(ranges) } +/// Builds the single fold at `level` starting at the defined row `first` +/// (whose level is at least `level`), appends its range when the scan +/// created one, and answers the first row below it. +/// +/// The loop mirrors `foldUpdateIEMSRecurse` for a fresh build: each visit +/// first offers the line to fold creation (a fold starts here when the level +/// differs, a start marker sits on it, the previous line ended a fold, or it +/// is the last line), then ends the fold below the line when the effective +/// level drops below `level`, and otherwise recurses into nested folds or +/// advances past undefined lines through [`next_defined_row`]. A frame that +/// ends before creating anything emits no range, which is how a lone deeper +/// line over a lower one leaves no nested fold. +fn build_fold( + records: &[LevelRecord], + level: i32, + first: usize, + ranges: &mut Vec, +) -> Result { + let last = records.len().saturating_sub(1); + let top = first; + let mut lnum = first; + let mut save = first; + let mut current = records[first]; + let mut created = false; + loop { + if lnum >= records.len() { + break; + } + if !created + && (current.level != level + || current.start != 0 + || current.had_end != NO_FOLD_END + || save == last + || lnum == last) + { + created = true; + } + let forced = lnum > top + && (level > current.level - start_level(current) || level >= current.had_end); + let effective = if forced { 0 } else { current.level }; + if effective < level { + break; + } + if effective > level && created { + let below = build_fold(records, level + 1, save, ranges)?; + lnum = below; + if lnum >= records.len() { + break; + } + // The last buffer row always carries a defined level, so a + // defined row at or after `below` always exists; keeping the + // previous records on the impossible miss ends the fold here + // instead of looping forever. + if let Some(defined) = next_defined_row(records, lnum) { + save = defined; + current = records[defined]; + } + continue; + } + // The last buffer row always carries a defined level, so reaching + // past it means the fold runs to the end of the buffer. + let Some(next_save) = next_defined_row(records, save + 1) else { + lnum = records.len(); + break; + }; + lnum = save + 1; + save = next_save; + current = records[next_save]; + } + if created { + ranges.push(FoldRange::lines(top, lnum)?); + } + Ok(lnum) +} + +/// Answers the first row at or after `from` whose level is defined +/// (non-negative), or `None` when every remaining line is undefined. +fn next_defined_row(records: &[LevelRecord], from: usize) -> Option { + records + .iter() + .enumerate() + .skip(from) + .find(|(_, record)| record.level >= 0) + .map(|(row, _)| row) +} + +/// Reads the `start` count as an `i32` for level arithmetic. +fn start_level(record: LevelRecord) -> i32 { + record.start.try_into().unwrap_or(i32::MAX) +} + fn normalize_folds(folds: &mut Vec) -> Result<(), FoldError> { folds.sort_by(|left, right| compare_ranges(left.range, right.range)); let mut stack: Vec = Vec::new(); @@ -1191,3 +1551,183 @@ fn set_all(folds: &mut [Fold], state: FoldState) -> usize { } changed } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + //! Every expectation below is the value the oracle at + //! `.references/neovim/build/bin/nvim` (v0.13.0-dev-1390) answers for the + //! same buffer, recorded next to each assertion. + + use super::*; + + fn byte_lines(lines: &[&str]) -> Vec> { + lines.iter().map(|line| line.as_bytes().to_vec()).collect() + } + + /// Refreshes `folds` over `lines` and answers `(start, end)` row pairs. + fn computed_ranges(folds: &mut Folds, lines: &[&str]) -> Vec<(usize, usize)> { + let owned = byte_lines(lines); + let tick = folds.cached_changedtick().unwrap_or(0) + 1; + folds.invalidate(tick); + match folds.refresh(tick, &owned).unwrap() { + FoldRefresh::Ready { .. } => {} + FoldRefresh::Host(_) => panic!("unexpected host request"), + } + folds + .folds() + .iter() + .map(|fold| (fold.range.start.row, fold.range.last_row())) + .collect() + } + + // fold.c:2924 — adjacent same-level marker folds stay apart: the second + // `{{{` line starts a new fold instead of merging into the first. + // Oracle (`['{{{','}}}','{{{','', '}}}']`): folds [1,2] and [3,5]. + #[test] + fn marker_folds_split_at_each_start_marker() { + let mut folds = Folds::new(); + folds.set_method(FoldMethod::Marker); + assert_eq!( + computed_ranges(&mut folds, &["{{{", "}}}", "{{{", "", "}}}"]), + vec![(0, 1), (2, 4)] + ); + } + + // fold.c:2924 — `['{{{','}}}','{{{','}}}']` gives [1,2] and [3,4]. + #[test] + fn marker_folds_split_adjacent_pairs() { + let mut folds = Folds::new(); + folds.set_method(FoldMethod::Marker); + assert_eq!( + computed_ranges(&mut folds, &["{{{", "}}}", "{{{", "}}}"]), + vec![(0, 1), (2, 3)] + ); + } + + // fold.c:2946 — an end marker with an explicit level sets the line's + // level to that number. Oracle (`['one }}}4','two']`): levels [4, 0], + // i.e. four nested single-line folds on line one. + #[test] + fn marker_end_digit_sets_the_line_level() { + let mut folds = Folds::new(); + folds.set_method(FoldMethod::Marker); + assert_eq!( + computed_ranges(&mut folds, &["one }}}4", "two"]), + vec![(0, 0), (0, 0), (0, 0), (0, 0)] + ); + assert_eq!(folds.level_at_row(0), 4); + assert_eq!(folds.level_at_row(1), 0); + } + + // fold.c:2924 — a lone `{{{ }}}` line is a single-line fold, which stays + // open because it is no bigger than 'foldminlines'. Oracle: level 1, + // `foldclosed(1)` is -1. + #[test] + fn marker_single_line_fold_is_reported_open() { + let mut folds = Folds::new(); + folds.set_method(FoldMethod::Marker); + assert_eq!(computed_ranges(&mut folds, &["{{{ }}}"]), vec![(0, 0)]); + assert_eq!(folds.closed_rows_at(0), None); + } + + // fold.c:2783 — a tab counts against 'tabstop', not 'shiftwidth': + // six tabs at `tabstop=8` indent 48 columns, i.e. level 24 at sw=2, + // capped by the default 'foldnestmax' of 20. Oracle: foldlevel 20. + #[test] + fn indent_tabs_measure_against_tabstop() { + let mut folds = Folds::new(); + folds.set_method(FoldMethod::Indent); + folds.set_shift_width(2).unwrap(); + folds.set_tab_width(8).unwrap(); + let owned = byte_lines(&["\t\t\t\t\t\ta"]); + folds.invalidate(1); + folds.refresh(1, &owned).unwrap(); + assert_eq!(folds.level_at_row(0), 20); + } + + // fold.c:2783 — `'foldnestmax'` caps the reported level. Oracle: + // `setlocal foldnestmax=10` gives 10, `=-1` gives 0. + #[test] + fn indent_respects_foldnestmax() { + let mut folds = Folds::new(); + folds.set_method(FoldMethod::Indent); + folds.set_shift_width(2).unwrap(); + folds.set_tab_width(8).unwrap(); + folds.set_nest_max(10); + let owned = byte_lines(&["\t\t\t\t\t\ta"]); + folds.invalidate(1); + folds.refresh(1, &owned).unwrap(); + assert_eq!(folds.level_at_row(0), 10); + folds.set_nest_max(-1); + folds.invalidate(2); + folds.refresh(2, &owned).unwrap(); + assert_eq!(folds.level_at_row(0), 0); + } + + // fold.c:2794 — a line whose first non-blank byte is in 'foldignore' + // has an undefined level. Oracle (`[' a',' # x',' b']`, fdi=#): + // the comment line joins the surrounding fold. + #[test] + fn indent_ignores_foldignore_lines() { + let mut folds = Folds::new(); + folds.set_method(FoldMethod::Indent); + folds.set_shift_width(2).unwrap(); + let owned = byte_lines(&[" a", " # x", " b"]); + folds.invalidate(1); + folds.refresh(1, &owned).unwrap(); + assert_eq!(folds.level_at_row(1), 1); + assert_eq!( + computed_ranges(&mut folds, &[" a", " # x", " b"]), + vec![(0, 2)] + ); + } + + // fold.c:1466 — a fold shrunk to one line by an edit reads open again: + // its single row is no bigger than the default 'foldminlines' of 1. + // Oracle (`['line 1',' line 2']` after deleting line 3): fc2=-1, fl2=1. + #[test] + fn shrunk_single_line_fold_reads_open() { + let mut folds = Folds::new(); + folds.set_method(FoldMethod::Indent); + folds.set_shift_width(2).unwrap(); + assert_eq!( + computed_ranges(&mut folds, &["line 1", " line 2"]), + vec![(1, 1)] + ); + assert_eq!(folds.level_at_row(1), 1); + assert_eq!(folds.closed_rows_at(1), None); + } + + // fold.c:1466 — `'foldminlines'=5` keeps a two-line manual fold displayed + // open. Oracle: `foldclosed(2)` is -1 while the window uses 5. + #[test] + fn foldminlines_keeps_small_folds_open() { + let mut folds = Folds::new(); + folds + .create_manual(Position::new(1, 0), Position::new(3, 0)) + .unwrap(); + assert_eq!(folds.closed_rows_at(1), Some((1, 2))); + folds.set_fold_min_lines(5); + assert_eq!(folds.closed_rows_at(1), None); + folds.set_fold_min_lines(0); + assert_eq!(folds.closed_rows_at(1), Some((1, 2))); + } + + // fold.c:742 — switching to manual adopts the computed folds instead of + // dropping them. Oracle (`set fdm=indent`, redraw, `set fdm=manual`): + // the indent folds answer `foldclosed` as manual ones. + #[test] + fn switching_to_manual_adopts_computed_folds() { + let mut folds = Folds::new(); + folds.set_method(FoldMethod::Indent); + folds.set_shift_width(2).unwrap(); + let owned = byte_lines(&["a", " b", " c"]); + folds.invalidate(1); + folds.refresh(1, &owned).unwrap(); + assert_eq!(folds.method(), FoldMethod::Indent); + folds.set_method(FoldMethod::Manual); + assert_eq!(folds.method(), FoldMethod::Manual); + assert_eq!(folds.closed_rows_at(1), Some((1, 2))); + } +} From e46b3a23adbf335b2445be57a1c624b1eede8514 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:51:19 +0900 Subject: [PATCH 007/263] feat(mapping): implement the abbreviation family on the mapping engine `:abbreviate`, `:abclear`, and the insert-mode, command-line, and both-mode forms register on the same engine as mappings, with the `noabbrev` and `iabbrev` forms and the `maparg`, `mapcheck`, and `maplist` reporting the oldtests read. Resolution rules match the mapping side: a full match wins over a shorter one, and the expansion keeps the engine's one lookup per keystroke. --- crates/ox-editor/src/builtins/mapping.rs | 432 +++++++++++++- crates/ox-editor/src/mapping.rs | 693 ++++++++++++++++++++++- 2 files changed, 1081 insertions(+), 44 deletions(-) diff --git a/crates/ox-editor/src/builtins/mapping.rs b/crates/ox-editor/src/builtins/mapping.rs index 7ccf5aab..7fe73f40 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,6 +11,13 @@ //! 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` @@ -24,27 +31,26 @@ //! (`` 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 `