From 540eab776906e07755def66d984c56653d29a394 Mon Sep 17 00:00:00 2001 From: Zhou Fang Date: Fri, 28 Aug 2026 13:41:09 +0900 Subject: [PATCH 1/4] feat(char): CJK-aware f/t/F/T through flash.nvim char mode Wrap flash.plugins.char.mode idempotently: the single typed character is compiled through the mix mode, so pinyin/romaji/romanization first letters and CJK punctuation classes match CJK characters in flash's enhanced char motions. t/T keep native before/after landing positions and the multi_line line anchor; max_length, labeler, actions and the Rust matcher path stay untouched (char mode is vim-regex only). Opt out with setup({ char = false }). --- ARCHITECTURE.md | 2 +- README.ja.md | 9 +++ README.ko.md | 9 +++ README.md | 9 +++ README.zh.md | 9 +++ lua/flash-cjk/config.lua | 2 + lua/flash-cjk/init.lua | 7 +++ lua/flash-cjk/patches.lua | 58 +++++++++++++++++- tests/e2e/scenario.lua | 93 ++++++++++++++++++++++++++++ tests/run.lua | 126 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 321 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 08bfcad..a297491 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -22,7 +22,7 @@ input letter. resolution (no flash.nvim dependency) - `match.lua` — matching domain: segmentation parser, punctuation classes, language-lock markers, mixed-mode compiler -- `patches.lua` — flash.nvim patches (C-c dispatch, prompt lock display) +- `patches.lua` — flash.nvim patches (C-c dispatch, prompt lock display, char-motion f/t/F/T search-mode wrap) - `util.lua` — shared helpers - `labeler.lua` — flash labeler: next-letter prediction (from spellings or the Rust matcher's per-match predictions), skip-set, monotonic label pool - `rust.lua` — bridge to the native matcher: persistent UDS server transport (Unix, zero config), per-keystroke spawn fallback, JSON protocol, circuit breaker to the vim-regex path diff --git a/README.ja.md b/README.ja.md index 1deab50..28b06f0 100644 --- a/README.ja.md +++ b/README.ja.md @@ -63,6 +63,7 @@ Neovim ≥ 0.10 と [flash.nvim](https://github.com/folke/flash.nvim) が必要 }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, + char = true, } ``` @@ -89,6 +90,10 @@ Neovim ≥ 0.10 と [flash.nvim](https://github.com/folke/flash.nvim) が必要 1 回のクエリに、異なる言語の入力コードを混在させられます。入力例は下の使い方の「混合入力」を参照してください。 +### `char` + +flash.nvim 内蔵の拡張文字モーション(flash の `modes.char`、デフォルトで有効)を CJK 対応にします。入力した 1 文字が、`s` ジャンプと同じ多言語エンジンでマッチされます。`fv` で「中」へジャンプでき(小鶴双拼の zhong の頭文字 `v`)、`ft` なら「中」(日本語訓令式ローマ字 `tyuu`)や「梯」(ピンイン `ti`)に到達します。`;`/`,` の循環、カウント、オペレータ待機時の挙動は flash ネイティブのモーションとまったく同じです。マッチングは 1 文字のみで、複数キーの読みを入力したい場合は `s` を使ってください。`setup({ char = false })` を設定すると、flash ネイティブの ASCII 専用の挙動に戻ります。 + ## ⌨️ 使い方 操作は flash.nvim とほぼ同じです。`s` を押し、目的のテキストに対応する入力コードを入力するだけです。以下の例では、トリガーキー `s` を含めた完全なキー列で示します。 @@ -105,6 +110,10 @@ English, a中文,日本語, 한국어. Hello, 你好,こんにちは、안 `sav` と入力すると「a中」にマッチします。`a` は ASCII としてリテラルマッチし、`v` は小鶴双拼における「中」の入力コードのプレフィックスです。 +### 文字モーション + +同じエンジンは `f`/`t`/`F`/`T`(flash の拡張文字モーション)にも作用します。上のサンプル行では、`fv` で「中」へジャンプできます。 + ### 多言語マッチング `sn` と入力すると、クエリ文字 `n` が次のすべてに同時にマッチします: diff --git a/README.ko.md b/README.ko.md index 9fb839e..65b72b7 100644 --- a/README.ko.md +++ b/README.ko.md @@ -63,6 +63,7 @@ Neovim ≥ 0.10과 [flash.nvim](https://github.com/folke/flash.nvim)이 필요 }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, + char = true, } ``` @@ -89,6 +90,10 @@ Neovim ≥ 0.10과 [flash.nvim](https://github.com/folke/flash.nvim)이 필요 하나의 쿼리에 서로 다른 언어의 입력 코드를 섞어 사용할 수 있습니다. 아래 사용법 예시의 혼합 입력을 참고하세요. +### `char` + +flash.nvim 내장 확장 문자 모션(flash의 `modes.char`, 기본 활성화)도 CJK 매칭을 지원하게 합니다. 입력한 한 글자가 `s` 점프와 동일한 다국어 엔진으로 매칭됩니다. `fv`로 「中」에 점프할 수 있고(샤오헤 쌍병음에서 zhong의 초성 `v`), `ft`면 「中」(일본어 훈레이식 로마자 `tyuu`)이나 「梯」(병음 `ti`)에 도달합니다. `;`/`,` 순환, 카운트, 오퍼레이터 대기 동작은 flash 네이티브 모션과 완전히 같습니다. 매칭은 한 글자만 지원하며, 여러 키로 이루어진 읽기를 입력하려면 `s`를 사용하세요. `setup({ char = false })`로 설정하면 flash 네이티브의 ASCII 전용 동작으로 되돌아갑니다. + ## ⌨️ 사용법 사용 방법은 flash.nvim과 거의 같습니다. `s`를 누른 다음 대상 텍스트의 입력 코드를 입력하면 됩니다. 아래 예시에서는 트리거 키 `s`를 포함한 전체 키 시퀀스를 보여 줍니다. @@ -105,6 +110,10 @@ English, a中文,日本語, 한국어. Hello, 你好,こんにちは、안 `sav`를 입력하면 「a中」에 매칭됩니다. `a`는 ASCII로 리터럴 매칭되고, `v`는 샤오헤 쌍병음에서 「中」의 입력 코드 접두사입니다. +### 문자 모션 + +같은 엔진은 `f`/`t`/`F`/`T`(flash의 확장 문자 모션)에도 적용됩니다. 위 샘플 행에서 `fv`를 입력하면 「中」로 점프합니다. + ### 다국어 매칭 `sn`을 입력하면 쿼리 문자 `n`이 다음 항목을 동시에 매칭합니다: diff --git a/README.md b/README.md index 677759e..eeb9718 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Default configuration: }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, + char = true, } ``` @@ -89,6 +90,10 @@ When multiple languages match at once, `priority = { "zhcn", "ja", "ko" }` deter Allows a single query to mix input codes from different languages. See Mixed input in the usage examples below. +### `char` + +Makes flash.nvim's built-in enhanced char motions (flash's `modes.char`, enabled by default) CJK-aware. A single typed character is matched through the same multi-language engine as `s`-jumps: `fv` jumps to `中` (Xiaohe `v` initial for zhong), while `ft` reaches `中` (Japanese kunrei-shiki `tyuu`) or `梯` (pinyin `ti`). `;`/`,` cycling, counts, and operator-pending behave exactly like flash's native motions. Matching is single-character only — press `s` for full multi-key readings. Set `setup({ char = false })` to restore flash's native ASCII-only behavior. + ## ⌨️ Usage Usage is almost identical to flash.nvim: press `s`, then type the input code for the target text. The examples below include the trigger key `s` as part of the full key sequence. @@ -105,6 +110,10 @@ Type `si` (`s` starts flash-cjk and `i` is the query) to match the `i` in `Engli Type `sav` to match `a中`: `a` is matched literally as ASCII, while `v` is the Xiaohe double pinyin code prefix for `中`. +### Char motions + +The same engine also powers `f`/`t`/`F`/`T` (flash's enhanced char motions). On the sample line above, `fv` jumps to `中`. + ### Multi-language matching Type `sn`, and the query character `n` matches all of the following at once: diff --git a/README.zh.md b/README.zh.md index 4621ba7..dfc6633 100644 --- a/README.zh.md +++ b/README.zh.md @@ -63,6 +63,7 @@ https://github.com/user-attachments/assets/37599dab-b0c6-4d90-8463-cb4706841ac3 }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, + char = true, } ``` @@ -89,6 +90,10 @@ https://github.com/user-attachments/assets/37599dab-b0c6-4d90-8463-cb4706841ac3 允许一次查询同时包含不同语言的输入码,见下方使用示例中的混合输入。 +### `char` + +让 flash.nvim 内置的增强字符移动(flash 的 `modes.char`,默认开启)也支持 CJK 匹配。输入的单个字符会通过与 `s` 跳转相同的多语言引擎匹配:`fv` 可以跳到「中」(小鹤双拼中 zhong 的声母 `v`),`ft` 则能到达「中」(日语训令式罗马字 `tyuu`)或「梯」(拼音 `ti`)。`;`/`,` 循环、计数和 operator-pending 行为与 flash 原生移动完全一致。匹配只支持单个字符——需要完整的多键输入码时请按 `s`。设置 `setup({ char = false })` 即可恢复 flash 原生的纯 ASCII 行为。 + ## ⌨️ 使用 用法和 flash.nvim 基本一致:按下 `s`,再输入目标文本的输入码即可。下面的示例会把触发键 `s` 一并写入完整按键序列。 @@ -105,6 +110,10 @@ English, a中文,日本語, 한국어. Hello, 你好,こんにちは、안 输入 `sav` 可以匹配「a中」:`a` 使用 ASCII 字面匹配,`v` 是小鹤双拼中「中」的输入码前缀。 +### 字符移动 + +同样的引擎也作用于 `f`/`t`/`F`/`T`(flash 的增强字符移动)。在上面这行示例中,`fv` 会跳到「中」。 + ### 多语言匹配 输入 `sn` 后,查询字符 `n` 会同时匹配: diff --git a/lua/flash-cjk/config.lua b/lua/flash-cjk/config.lua index 7d78dc7..a2024b6 100644 --- a/lua/flash-cjk/config.lua +++ b/lua/flash-cjk/config.lua @@ -19,6 +19,8 @@ M.config = { }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, + -- CJK-aware f/t/F/T through flash.nvim's char mode + char = true, } ---Normalizes one languages[lang] value: true -> enabled, false -> diff --git a/lua/flash-cjk/init.lua b/lua/flash-cjk/init.lua index 2e73fbb..4ab6f5d 100644 --- a/lua/flash-cjk/init.lua +++ b/lua/flash-cjk/init.lua @@ -64,6 +64,7 @@ local function build_opts(langs) -- lock is configured; everything else keeps flash's behavior. patches.get_char_patch() patches.prompt_patch() + patches.char_mode_patch() local defaults = { labels = "asdfghjklqwertyuiopzxcvbnm", search = { @@ -114,10 +115,16 @@ function M.setup(opts) if type(opts.mixed_input) == "boolean" then M.config.mixed_input = opts.mixed_input end + if type(opts.char) == "boolean" then + M.config.char = opts.char + end if opts.priority ~= nil then -- labeler-layer only: the mix mode does not read it M.config.priority = config.normalize_priority(opts.priority) end + -- also installed here so ftFT is CJK-aware before the first jump: + -- flash-cjk loads before flash's first f/F/t/T press + patches.char_mode_patch() end return M diff --git a/lua/flash-cjk/patches.lua b/lua/flash-cjk/patches.lua index 1511693..8c28ae7 100644 --- a/lua/flash-cjk/patches.lua +++ b/lua/flash-cjk/patches.lua @@ -1,8 +1,14 @@ --- flash.nvim patches, installed by build_opts() in init.lua. Each patch --- mutates flash only when a flash-cjk feature needs it. +-- flash.nvim patches: C-c dispatch, prompt lock display, and the +-- CJK-aware char-motion mode wrap. Installed by build_opts() in +-- init.lua -- the char wrap also by setup(), ahead of flash's first +-- f/F/t/T press. Each patch mutates flash only when a flash-cjk +-- feature needs it. local M = {} +local config = require("flash-cjk.config") +local match = require("flash-cjk.match") + -- Swallows the C-c interrupt inside flash's input loop while the -- active flash state has a C-c language-lock action registered (plain -- flash jumps keep the original behavior: the interrupt exits). @@ -65,4 +71,52 @@ function M.prompt_patch() end end +-- Makes flash's enhanced f/t/F/T motions (modes.char) CJK-aware: the +-- single typed character is compiled through the mix mode, so pinyin, +-- romaji and romanization first letters and CJK punctuation classes +-- match CJK characters too. f/F search the target itself, t the char +-- before it and T the char after it -- exactly where the native +-- motions land. +function M.char_mode_patch() + local ok, Char = pcall(require, "flash.plugins.char") + if not ok or type(Char.mode) ~= "function" or Char._flash_cjk_patched then + return + end + ---@diagnostic disable-next-line: inject-field + Char._flash_cjk_patched = true + local orig = Char.mode + ---@diagnostic disable-next-line: duplicate-set-field + Char.mode = function(motion) + local native = orig(motion) + return function(c) + if not config.config.char then + return native(c) + end + -- compiled per input, so setup() changes are always honored + local cjk = match.make_mix_mode( + config.lang_flags(), + config.filter_keys(config.config.languages) + )(c) + local pattern + if motion == "t" then + -- match the char right before the target (native: \m.\ze\V) + pattern = "\\m.\\ze" .. cjk + elseif motion == "T" then + -- match the char right after the target (native: \V\zs\m.) + pattern = "\\m" .. cjk .. "\\zs." + else + pattern = cjk -- f/F: the target itself + end + -- flash's stubs type Config.get strictly and do not know the + -- char mode's fields; the call mirrors char.lua's own usage + ---@diagnostic disable-next-line: param-type-mismatch, undefined-field + if not require("flash.config").get("char").multi_line then + local pos = vim.api.nvim_win_get_cursor(0) + pattern = ("\\%%%dl"):format(pos[1]) .. pattern + end + return pattern + end + end +end + return M diff --git a/tests/e2e/scenario.lua b/tests/e2e/scenario.lua index 6a50272..b2dd57d 100644 --- a/tests/e2e/scenario.lua +++ b/tests/e2e/scenario.lua @@ -264,4 +264,97 @@ if not no_rust then ok(addr ~= nil and vim.uv.fs_stat(addr) ~= nil, "server mode: socket exists") end +-- 3. Char mode (f/t/F/T) through the real flash char flow: Char.jump +-- driven with the target prefed into the typeahead (flash's getchar +-- consumes nvim_input keys raw, bypassing keymaps -- same technique +-- as fc.jump above). The char wrap is vim-regex in BOTH phases (rust +-- never touches modes.char), so every check is phase-agnostic. +-- Char state is reset between cases: while a char state is visible, +-- the same motion letter means clever-f repeat and reads no new char. +do + local Char = require("flash.plugins.char") + -- byte cols (1-based): line 1 中=4 梯=11, line 2 你=4 + local clines = { "aa 中 bb 梯 cc", "xx 你 yy" } + vim.cmd("enew!") + vim.api.nvim_buf_set_lines(0, 0, -1, false, clines) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + local function char_reset() + Char.motion = "f" + Char.char = nil + Char.current = false + Char.jump_labels = false + if Char.state then + Char.state:hide() + Char.state = nil + end + end + + -- cursor resets between runs so every motion starts from the same + -- spot (backward cases pass their own start position) + local function char_jump(motion, prefed, cursor) + char_reset() + vim.api.nvim_win_set_cursor(0, cursor or { 1, 0 }) + vim.api.nvim_input(prefed) + return pcall(Char.jump, motion) + end + + local function check_landed(label, want_row, want_col, okr, err) + local r, c = unpack(vim.api.nvim_win_get_cursor(0)) + ok( + okr and r == want_row and c + 1 == want_col, + ("char %s (got %d,%d want %d,%d%s)"):format( + label, + r, + c + 1, + want_row, + want_col, + not okr and err and (": " .. tostring(err)) or "" + ) + ) + end + + -- f+t: tyuu (ja) reads 中; 梯 (zhcn ti) is t-initial too but later + -- on the line -- the leftmost match wins + local okc, errc = char_jump("f", "t") + check_landed("f+t: cursor on 中, first t-initial match", 1, 4, okc, errc) + + -- f+v: zhcn flypy singlepin zh -> v reads the same 中 + okc, errc = char_jump("f", "v") + check_landed("f+v: cursor on 中 (zhcn flypy)", 1, 4, okc, errc) + + -- t+v: native t lands on the char right BEFORE the target + okc, errc = char_jump("t", "v") + check_landed("t+v: cursor on char before 中", 1, 3, okc, errc) + + -- F+n: backward from the tail of line 2 onto 你 (ja) + okc, errc = char_jump("F", "n", { 2, 7 }) + check_landed("F+n: cursor back on 你", 2, 4, okc, errc) + + -- T+t: native T lands on the char right AFTER the target (the + -- space following 中) + okc, errc = char_jump("T", "t", { 1, 7 }) + check_landed("T+t: cursor on char after 中", 1, 7, okc, errc) + + -- f+b: plain ascii input stays a literal match + okc, errc = char_jump("f", "b") + check_landed("f+b: cursor on literal b", 1, 8, okc, errc) + + -- ";" repeats the last f WITHOUT reading a new char (no prefed + -- key, straight to the second b from where f+b landed) + okc, errc = pcall(Char.jump, ";") + check_landed("; repeat: second b, no new char read", 1, 9, okc, errc) + + -- setup({ char = false }) restores native char motions: no literal + -- z anywhere on the line and no CJK reading consulted, so the + -- cursor must stay put + fc.setup({ char = false }) + okc, errc = char_jump("f", "z") + check_landed("char=false: f+z native finds nothing, no move", 1, 1, okc, errc) + + -- re-enable what this section disabled + fc.setup({ char = true }) + ok(fc.config.char == true, "char re-enabled after the disabled check") +end + finish() diff --git a/tests/run.lua b/tests/run.lua index 8768e80..fdf7eab 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -854,6 +854,132 @@ do ) end +-- --------------------------------------------------------------------------- +-- char mode (f/t/F/T): CJK-aware enhanced motions through the +-- idempotent char_mode_patch -- at pattern level and through the real +-- Char.jump loop (search chars prefed into typeahead, the same +-- technique the jump sections above use) + +do + local patches = require("flash-cjk.patches") + patches.char_mode_patch() -- no-op when setup() already installed it + local Char = require("flash.plugins.char") + local FlashConfig = require("flash.config") + local char_lines = { "aa 中 bb 梯 cc", "xx 你 yy" } + + ok(Char._flash_cjk_patched == true, "char mode: patch marker set on flash.plugins.char") + local ok_re, re_v = pcall(vim.regex, Char.mode("f")("v")) + ok(ok_re, "char mode: mode(f)(v) compiles as a vim regex") + ok(ok_re and re_v:match_str("中") ~= nil, "char mode: v pattern matches 中 (flypy zh->v)") + local re_t = vim.regex(Char.mode("f")("t")) + ok(re_t:match_str("中") ~= nil, "char mode: t pattern matches 中 (ja tyuu)") + ok(re_t:match_str("梯") ~= nil, "char mode: t pattern matches 梯 (zhcn ti)") + ok(re_t:match_str("你") == nil, "char mode: t pattern does not match 你") + + local pat_t = Char.mode("t")("t") + ok( + pat_t:sub(1, 6) == "\\m.\\ze", + "char mode: t pattern starts with \\m.\\ze (char before target)" + ) + local pat_T = Char.mode("T")("t") + ok( + pat_T:find("\\zs.", 1, true) ~= nil, + "char mode: T pattern contains \\zs. (char after target)" + ) + ok(vim.regex(pat_t):match_str("x中") ~= nil, "char mode: t regex matches the char before 中") + ok(vim.regex(pat_T):match_str("中x") ~= nil, "char mode: T regex matches the char after 中") + + -- multi_line = false prepends the cursor-line anchor + vim.cmd("enew!") + vim.api.nvim_buf_set_lines(0, 0, -1, false, char_lines) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + FlashConfig.setup({ modes = { char = { multi_line = false } } }) + local anchored = Char.mode("f")("v") + ok(anchored:sub(1, 4) == "\\%1l", "char mode: multi_line=false prepends \\%1l on line 1") + FlashConfig.setup({ modes = { char = { multi_line = true } } }) + ok(Char.mode("f")("v"):sub(1, 4) ~= "\\%1l", "char mode: anchor gone after multi_line restore") + + -- idempotence: a second patch call must not double-wrap + patches.char_mode_patch() + ok(Char._flash_cjk_patched == true, "char mode: marker survives a second patch call") + ok( + not Char.mode("t")("t"):find("\\m.\\ze\\m.\\ze", 1, true), + "char mode: re-patch does not double-wrap" + ) + local ok_re2, re_v2 = pcall(vim.regex, Char.mode("f")("v")) + ok(ok_re2 and re_v2:match_str("中") ~= nil, "char mode: pattern still matches after re-patch") + + -- config gate at pattern level: char=false restores the native literal + fc.setup({ char = false }) + ok(Char.mode("f")("v") == "\\Vv", "char mode: char=false falls back to native \\Vv") + fc.setup({ char = true }) + + -- flow level: real Char.jump in a scratch buffer. The state must + -- be reset between cases -- same-motion repeats read no new char. + -- Earlier loop sections abort by re-queuing into typeahead + -- (the flash loop consumes prefed keys raw), and one leftover esc + -- would silently kill the prefed chars below -- drain first. + while true do + local c = vim.fn.getcharstr(0) + if c == 0 or c == "" then + break + end + end + local function char_reset() + if Char.state then + Char.state:hide() + end + Char.state = nil + Char.char = nil + Char.motion = "f" + end + + local function char_case(motion, prefeed, cursor) + char_reset() + vim.cmd("enew!") + vim.api.nvim_buf_set_lines(0, 0, -1, false, char_lines) + vim.api.nvim_win_set_cursor(0, cursor or { 1, 0 }) + if prefeed then + vim.api.nvim_input(prefeed) + end + local ran = pcall(Char.jump, motion) + return vim.api.nvim_win_get_cursor(0), ran + end + + local cur, ran = char_case("f", "t") + ok(ran and cur[1] == 1 and cur[2] == 3, "char flow: f+t jumps to 中 (byte 4)") + cur, ran = char_case("f", "v") + ok(ran and cur[1] == 1 and cur[2] == 3, "char flow: f+v jumps to 中 (byte 4)") + cur, ran = char_case("f", "b") + ok(ran and cur[1] == 1 and cur[2] == 7, "char flow: f+b jumps to the literal b (byte 8)") + cur, ran = char_case("t", "v") + ok(ran and cur[1] == 1 and cur[2] == 2, "char flow: t+v stops right before 中 (byte 3)") + cur, ran = char_case("F", "n", { 2, 7 }) + ok(ran and cur[1] == 2 and cur[2] == 3, "char flow: F+n jumps back to 你 (line 2, byte 4)") + cur, ran = char_case("T", "t", { 1, 7 }) + ok(ran and cur[1] == 1 and cur[2] == 6, "char flow: T+t stops right after 中 (byte 7)") + + -- repeat: after f+b lands on byte 8, ";" re-jumps without reading + -- a new char (the second b) + cur, ran = char_case("f", "b") + ok(ran and cur[1] == 1 and cur[2] == 7, "char flow: repeat setup lands on the first b (byte 8)") + local ok_repeat = pcall(Char.jump, ";") + cur = vim.api.nvim_win_get_cursor(0) + ok( + ok_repeat and cur[1] == 1 and cur[2] == 8, + "char flow: ; repeat jumps to the second b (byte 9)" + ) + + -- config gate through the loop: char=false restores native semantics + fc.setup({ char = false }) + cur, ran = char_case("f", "z") + ok( + ran and cur[1] == 1 and cur[2] == 0, + "char flow: char=false f+z finds no literal z (no move)" + ) + fc.setup({ char = true }) -- re-enable for anything that follows +end + print(string.format("%d passed, %d failed", passed, failed)) if failed > 0 then error("test failures") From 0a2b3a51901074d72efcda07d49b95ade89ff5a2 Mon Sep 17 00:00:00 2001 From: Zhou Fang Date: Fri, 28 Aug 2026 17:48:00 +0900 Subject: [PATCH 2/4] refactor(config): group flash-owned motion integration under motions.char The char integration flag moves from a top-level boolean into the motions group -- the config surface for integrations on entry points flash.nvim itself owns (modes.char today; flash-owned surfaces such as the / search mode land here later). Matching-semantic options stay top-level. Non-boolean flags are rejected ("false" is truthy in Lua and would silently enable the integration); a rejected setup leaves the config untouched. --- README.ja.md | 10 +++++++--- README.ko.md | 10 +++++++--- README.md | 10 +++++++--- README.zh.md | 10 +++++++--- lua/flash-cjk/config.lua | 4 ++-- lua/flash-cjk/init.lua | 16 ++++++++++++++-- lua/flash-cjk/patches.lua | 2 +- tests/e2e/scenario.lua | 14 +++++++------- tests/run.lua | 23 +++++++++++++++-------- 9 files changed, 67 insertions(+), 32 deletions(-) diff --git a/README.ja.md b/README.ja.md index 28b06f0..e9240c9 100644 --- a/README.ja.md +++ b/README.ja.md @@ -63,7 +63,7 @@ Neovim ≥ 0.10 と [flash.nvim](https://github.com/folke/flash.nvim) が必要 }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, - char = true, + motions = { char = true }, } ``` @@ -90,9 +90,13 @@ Neovim ≥ 0.10 と [flash.nvim](https://github.com/folke/flash.nvim) が必要 1 回のクエリに、異なる言語の入力コードを混在させられます。入力例は下の使い方の「混合入力」を参照してください。 -### `char` +### `motions` -flash.nvim 内蔵の拡張文字モーション(flash の `modes.char`、デフォルトで有効)を CJK 対応にします。入力した 1 文字が、`s` ジャンプと同じ多言語エンジンでマッチされます。`fv` で「中」へジャンプでき(小鶴双拼の zhong の頭文字 `v`)、`ft` なら「中」(日本語訓令式ローマ字 `tyuu`)や「梯」(ピンイン `ti`)に到達します。`;`/`,` の循環、カウント、オペレータ待機時の挙動は flash ネイティブのモーションとまったく同じです。マッチングは 1 文字のみで、複数キーの読みを入力したい場合は `s` を使ってください。`setup({ char = false })` を設定すると、flash ネイティブの ASCII 専用の挙動に戻ります。 +flash.nvim 自身が持つモーションへの統括設定です。現時点では flash の `modes.char` のみで、将来的に flash 自身の入口(`/` 検索モードなど)もここに追加されます。 + +#### `char` + +flash.nvim 内蔵の拡張文字モーション(flash の `modes.char`、デフォルトで有効)を CJK 対応にします。入力した 1 文字が、`s` ジャンプと同じ多言語エンジンでマッチされます。`fv` で「中」へジャンプでき(小鶴双拼の zhong の頭文字 `v`)、`ft` なら「中」(日本語訓令式ローマ字 `tyuu`)や「梯」(ピンイン `ti`)に到達します。`;`/`,` の循環、カウント、オペレータ待機時の挙動は flash ネイティブのモーションとまったく同じです。マッチングは 1 文字のみで、複数キーの読みを入力したい場合は `s` を使ってください。`setup({ motions = { char = false } })` を設定すると、flash ネイティブの ASCII 専用の挙動に戻ります。 ## ⌨️ 使い方 diff --git a/README.ko.md b/README.ko.md index 65b72b7..c8c0d29 100644 --- a/README.ko.md +++ b/README.ko.md @@ -63,7 +63,7 @@ Neovim ≥ 0.10과 [flash.nvim](https://github.com/folke/flash.nvim)이 필요 }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, - char = true, + motions = { char = true }, } ``` @@ -90,9 +90,13 @@ Neovim ≥ 0.10과 [flash.nvim](https://github.com/folke/flash.nvim)이 필요 하나의 쿼리에 서로 다른 언어의 입력 코드를 섞어 사용할 수 있습니다. 아래 사용법 예시의 혼합 입력을 참고하세요. -### `char` +### `motions` -flash.nvim 내장 확장 문자 모션(flash의 `modes.char`, 기본 활성화)도 CJK 매칭을 지원하게 합니다. 입력한 한 글자가 `s` 점프와 동일한 다국어 엔진으로 매칭됩니다. `fv`로 「中」에 점프할 수 있고(샤오헤 쌍병음에서 zhong의 초성 `v`), `ft`면 「中」(일본어 훈레이식 로마자 `tyuu`)이나 「梯」(병음 `ti`)에 도달합니다. `;`/`,` 순환, 카운트, 오퍼레이터 대기 동작은 flash 네이티브 모션과 완전히 같습니다. 매칭은 한 글자만 지원하며, 여러 키로 이루어진 읽기를 입력하려면 `s`를 사용하세요. `setup({ char = false })`로 설정하면 flash 네이티브의 ASCII 전용 동작으로 되돌아갑니다. +flash.nvim 자체 모션에 대한 통합 설정 묶음입니다. 현재는 flash의 `modes.char`뿐이며, 향후 flash 자체 진입점(예: `/` 검색 모드)도 여기서 설정하게 됩니다. + +#### `char` + +flash.nvim 내장 확장 문자 모션(flash의 `modes.char`, 기본 활성화)도 CJK 매칭을 지원하게 합니다. 입력한 한 글자가 `s` 점프와 동일한 다국어 엔진으로 매칭됩니다. `fv`로 「中」에 점프할 수 있고(샤오헤 쌍병음에서 zhong의 초성 `v`), `ft`면 「中」(일본어 훈레이식 로마자 `tyuu`)이나 「梯」(병음 `ti`)에 도달합니다. `;`/`,` 순환, 카운트, 오퍼레이터 대기 동작은 flash 네이티브 모션과 완전히 같습니다. 매칭은 한 글자만 지원하며, 여러 키로 이루어진 읽기를 입력하려면 `s`를 사용하세요. `setup({ motions = { char = false } })`로 설정하면 flash 네이티브의 ASCII 전용 동작으로 되돌아갑니다. ## ⌨️ 사용법 diff --git a/README.md b/README.md index eeb9718..82016a7 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Default configuration: }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, - char = true, + motions = { char = true }, } ``` @@ -90,9 +90,13 @@ When multiple languages match at once, `priority = { "zhcn", "ja", "ko" }` deter Allows a single query to mix input codes from different languages. See Mixed input in the usage examples below. -### `char` +### `motions` -Makes flash.nvim's built-in enhanced char motions (flash's `modes.char`, enabled by default) CJK-aware. A single typed character is matched through the same multi-language engine as `s`-jumps: `fv` jumps to `中` (Xiaohe `v` initial for zhong), while `ft` reaches `中` (Japanese kunrei-shiki `tyuu`) or `梯` (pinyin `ti`). `;`/`,` cycling, counts, and operator-pending behave exactly like flash's native motions. Matching is single-character only — press `s` for full multi-key readings. Set `setup({ char = false })` to restore flash's native ASCII-only behavior. +Groups the integrations on motions flash.nvim itself owns. Today that is flash's `modes.char`; future flash-owned surfaces (such as the `/` search mode) would be configured here as well. + +#### `char` + +Makes flash.nvim's built-in enhanced char motions (flash's `modes.char`, enabled by default) CJK-aware. A single typed character is matched through the same multi-language engine as `s`-jumps: `fv` jumps to `中` (Xiaohe `v` initial for zhong), while `ft` reaches `中` (Japanese kunrei-shiki `tyuu`) or `梯` (pinyin `ti`). `;`/`,` cycling, counts, and operator-pending behave exactly like flash's native motions. Matching is single-character only — press `s` for full multi-key readings. Set `setup({ motions = { char = false } })` to restore flash's native ASCII-only behavior. ## ⌨️ Usage diff --git a/README.zh.md b/README.zh.md index dfc6633..73ddb8f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -63,7 +63,7 @@ https://github.com/user-attachments/assets/37599dab-b0c6-4d90-8463-cb4706841ac3 }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, - char = true, + motions = { char = true }, } ``` @@ -90,9 +90,13 @@ https://github.com/user-attachments/assets/37599dab-b0c6-4d90-8463-cb4706841ac3 允许一次查询同时包含不同语言的输入码,见下方使用示例中的混合输入。 -### `char` +### `motions` -让 flash.nvim 内置的增强字符移动(flash 的 `modes.char`,默认开启)也支持 CJK 匹配。输入的单个字符会通过与 `s` 跳转相同的多语言引擎匹配:`fv` 可以跳到「中」(小鹤双拼中 zhong 的声母 `v`),`ft` 则能到达「中」(日语训令式罗马字 `tyuu`)或「梯」(拼音 `ti`)。`;`/`,` 循环、计数和 operator-pending 行为与 flash 原生移动完全一致。匹配只支持单个字符——需要完整的多键输入码时请按 `s`。设置 `setup({ char = false })` 即可恢复 flash 原生的纯 ASCII 行为。 +用于集中配置 flash.nvim 自有移动方式的集成。目前是 flash 的 `modes.char`,未来其他 flash 自有的入口(例如 `/` 搜索模式)也会放在这里配置。 + +#### `char` + +让 flash.nvim 内置的增强字符移动(flash 的 `modes.char`,默认开启)也支持 CJK 匹配。输入的单个字符会通过与 `s` 跳转相同的多语言引擎匹配:`fv` 可以跳到「中」(小鹤双拼中 zhong 的声母 `v`),`ft` 则能到达「中」(日语训令式罗马字 `tyuu`)或「梯」(拼音 `ti`)。`;`/`,` 循环、计数和 operator-pending 行为与 flash 原生移动完全一致。匹配只支持单个字符——需要完整的多键输入码时请按 `s`。设置 `setup({ motions = { char = false } })` 即可恢复 flash 原生的纯 ASCII 行为。 ## ⌨️ 使用 diff --git a/lua/flash-cjk/config.lua b/lua/flash-cjk/config.lua index a2024b6..291aff1 100644 --- a/lua/flash-cjk/config.lua +++ b/lua/flash-cjk/config.lua @@ -19,8 +19,8 @@ M.config = { }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, - -- CJK-aware f/t/F/T through flash.nvim's char mode - char = true, + -- integrations on flash-owned entry points (char: CJK-aware f/t/F/T) + motions = { char = true }, } ---Normalizes one languages[lang] value: true -> enabled, false -> diff --git a/lua/flash-cjk/init.lua b/lua/flash-cjk/init.lua index 4ab6f5d..1da61cd 100644 --- a/lua/flash-cjk/init.lua +++ b/lua/flash-cjk/init.lua @@ -115,8 +115,20 @@ function M.setup(opts) if type(opts.mixed_input) == "boolean" then M.config.mixed_input = opts.mixed_input end - if type(opts.char) == "boolean" then - M.config.char = opts.char + if opts.motions ~= nil then + if type(opts.motions) ~= "table" then + error("flash-cjk: motions must be a table") + end + -- every motion flag is boolean; a non-boolean (e.g. the string + -- "false") is truthy in Lua and would silently enable the + -- integration, so it is rejected instead of coerced + for key, value in pairs(opts.motions) do + if type(value) ~= "boolean" then + error(("flash-cjk: motions.%s must be a boolean"):format(key)) + end + end + -- unknown motion keys are kept unused: forward compatibility + M.config.motions = vim.tbl_deep_extend("force", {}, M.config.motions, opts.motions) end if opts.priority ~= nil then -- labeler-layer only: the mix mode does not read it diff --git a/lua/flash-cjk/patches.lua b/lua/flash-cjk/patches.lua index 8c28ae7..d3e06d7 100644 --- a/lua/flash-cjk/patches.lua +++ b/lua/flash-cjk/patches.lua @@ -89,7 +89,7 @@ function M.char_mode_patch() Char.mode = function(motion) local native = orig(motion) return function(c) - if not config.config.char then + if not config.config.motions.char then return native(c) end -- compiled per input, so setup() changes are always honored diff --git a/tests/e2e/scenario.lua b/tests/e2e/scenario.lua index b2dd57d..0e2cec4 100644 --- a/tests/e2e/scenario.lua +++ b/tests/e2e/scenario.lua @@ -345,16 +345,16 @@ do okc, errc = pcall(Char.jump, ";") check_landed("; repeat: second b, no new char read", 1, 9, okc, errc) - -- setup({ char = false }) restores native char motions: no literal - -- z anywhere on the line and no CJK reading consulted, so the - -- cursor must stay put - fc.setup({ char = false }) + -- setup({ motions = { char = false } }) restores native char motions: + -- no literal z anywhere on the line and no CJK reading consulted, + -- so the cursor must stay put + fc.setup({ motions = { char = false } }) okc, errc = char_jump("f", "z") - check_landed("char=false: f+z native finds nothing, no move", 1, 1, okc, errc) + check_landed("motions.char=false: f+z native finds nothing, no move", 1, 1, okc, errc) -- re-enable what this section disabled - fc.setup({ char = true }) - ok(fc.config.char == true, "char re-enabled after the disabled check") + fc.setup({ motions = { char = true } }) + ok(fc.config.motions.char == true, "char re-enabled after the disabled check") end finish() diff --git a/tests/run.lua b/tests/run.lua index fdf7eab..dce680a 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -909,10 +909,17 @@ do local ok_re2, re_v2 = pcall(vim.regex, Char.mode("f")("v")) ok(ok_re2 and re_v2:match_str("中") ~= nil, "char mode: pattern still matches after re-patch") - -- config gate at pattern level: char=false restores the native literal - fc.setup({ char = false }) - ok(Char.mode("f")("v") == "\\Vv", "char mode: char=false falls back to native \\Vv") - fc.setup({ char = true }) + -- config gate at pattern level: motions.char=false restores the native literal + fc.setup({ motions = { char = false } }) + ok(Char.mode("f")("v") == "\\Vv", "char mode: motions.char=false falls back to native \\Vv") + fc.setup({ motions = { char = true } }) + + -- non-boolean flags must error, not silently enable: "false" is + -- truthy in Lua, and a rejected setup leaves the config untouched + local ok_bad = pcall(fc.setup, { motions = { char = "false" } }) + ok(not ok_bad, "char mode: motions.char rejects non-boolean values") + ok(fc.config.motions.char == true, "char mode: config untouched after rejected setup") + ok(not pcall(fc.setup, { motions = "nope" }), "char mode: motions rejects non-table values") -- flow level: real Char.jump in a scratch buffer. The state must -- be reset between cases -- same-motion repeats read no new char. @@ -970,14 +977,14 @@ do "char flow: ; repeat jumps to the second b (byte 9)" ) - -- config gate through the loop: char=false restores native semantics - fc.setup({ char = false }) + -- config gate through the loop: motions.char=false restores native semantics + fc.setup({ motions = { char = false } }) cur, ran = char_case("f", "z") ok( ran and cur[1] == 1 and cur[2] == 0, - "char flow: char=false f+z finds no literal z (no move)" + "char flow: motions.char=false f+z finds no literal z (no move)" ) - fc.setup({ char = true }) -- re-enable for anything that follows + fc.setup({ motions = { char = true } }) -- re-enable for anything that follows end print(string.format("%d passed, %d failed", passed, failed)) From 937a6ce7e7b9224043b1f9367f2b07fca6cd913a Mon Sep 17 00:00:00 2001 From: Zhou Fang Date: Fri, 28 Aug 2026 17:54:05 +0900 Subject: [PATCH 3/4] fix(config): normalize motions through known fields only Deep-extending arbitrary user fields into config.motions persisted unknown keys (typos look accepted) and left no room for future table-shaped motion surfaces. normalize_motions mirrors the languages precedent instead: known flags are validated booleans, unknown fields are dropped (forward compatibility) -- only normalized values merge. --- lua/flash-cjk/config.lua | 17 +++++++++++++++++ lua/flash-cjk/init.lua | 16 ++++++---------- tests/run.lua | 12 ++++++++++++ 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/lua/flash-cjk/config.lua b/lua/flash-cjk/config.lua index 291aff1..8b5c369 100644 --- a/lua/flash-cjk/config.lua +++ b/lua/flash-cjk/config.lua @@ -59,6 +59,23 @@ function M.normalize_language(lang, value) return normalized end +---Normalizes a motions value: known motion flags must be booleans (a +---non-boolean like the string "false" is truthy in Lua and would +---silently enable the integration). Unknown fields are ignored +---(forward compatibility). +---@param motions table +---@return table normalized { char?: boolean } +function M.normalize_motions(motions) + local normalized = {} + if motions.char ~= nil then + if type(motions.char) ~= "boolean" then + error("flash-cjk: motions.char must be a boolean") + end + normalized.char = motions.char + end + return normalized +end + ---Base entry a language's setup() merge starts from: the built-in ---defaults for en, an empty table elsewhere (existing entries merge ---onto themselves). diff --git a/lua/flash-cjk/init.lua b/lua/flash-cjk/init.lua index 1da61cd..bc4077c 100644 --- a/lua/flash-cjk/init.lua +++ b/lua/flash-cjk/init.lua @@ -119,16 +119,12 @@ function M.setup(opts) if type(opts.motions) ~= "table" then error("flash-cjk: motions must be a table") end - -- every motion flag is boolean; a non-boolean (e.g. the string - -- "false") is truthy in Lua and would silently enable the - -- integration, so it is rejected instead of coerced - for key, value in pairs(opts.motions) do - if type(value) ~= "boolean" then - error(("flash-cjk: motions.%s must be a boolean"):format(key)) - end - end - -- unknown motion keys are kept unused: forward compatibility - M.config.motions = vim.tbl_deep_extend("force", {}, M.config.motions, opts.motions) + M.config.motions = vim.tbl_deep_extend( + "force", + {}, + M.config.motions, + config.normalize_motions(opts.motions) + ) end if opts.priority ~= nil then -- labeler-layer only: the mix mode does not read it diff --git a/tests/run.lua b/tests/run.lua index dce680a..0418d03 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -920,6 +920,18 @@ do ok(not ok_bad, "char mode: motions.char rejects non-boolean values") ok(fc.config.motions.char == true, "char mode: config untouched after rejected setup") ok(not pcall(fc.setup, { motions = "nope" }), "char mode: motions rejects non-table values") + -- unknown motion fields are dropped (forward compatibility), + -- including future table-shaped ones -- nothing arbitrary persists + fc.setup({ motions = { search = { enabled = true } } }) + ok( + fc.config.motions.char == true and fc.config.motions.search == nil, + "char mode: unknown motion fields dropped by setup" + ) + local ok_typo = pcall(fc.setup, { motions = { typo = true } }) + ok( + ok_typo and fc.config.motions.typo == nil, + "char mode: boolean typo succeeds and is not persisted" + ) -- flow level: real Char.jump in a scratch buffer. The state must -- be reset between cases -- same-motion repeats read no new char. From 1fa405e1103d81eafbd2e5bc8224e665e15200f6 Mon Sep 17 00:00:00 2001 From: Zhou Fang Date: Fri, 28 Aug 2026 18:56:17 +0900 Subject: [PATCH 4/4] feat(search): CJK-aware / and ? labels through flash's search mode Wrap flash.plugins.search.start idempotently: after the native state is created, the pattern mode is swapped for a mix-mode compiler and the default labeler for flash-cjk's predictive one (multi-char queries need next-letter prediction so a continuation letter never collides with an assigned label -- flash's check_jump treats the extending char as a label press). Native vim regex keeps its contract: queries containing metacharacters (the magic set plus the search delimiter) or non-ASCII bytes pass through verbatim, so /.* and /^func are untouched; flash's operator-pending position-pattern rewrite relies on the same passthrough. , n and N stay native -- labels are the CJK carrier. Requires flash's own modes.search.enabled; opt out with setup({ motions = { search = false } }). --- README.ja.md | 18 ++- README.ko.md | 18 ++- README.md | 18 ++- README.zh.md | 18 ++- lua/flash-cjk/config.lua | 13 +- lua/flash-cjk/init.lua | 7 +- lua/flash-cjk/patches.lua | 62 ++++++++- tests/e2e/repro.lua | 4 + tests/e2e/scenario.lua | 268 +++++++++++++++++++++++++++++++++++++- tests/run.lua | 149 ++++++++++++++++++++- 10 files changed, 555 insertions(+), 20 deletions(-) diff --git a/README.ja.md b/README.ja.md index e9240c9..34b082d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -63,7 +63,7 @@ Neovim ≥ 0.10 と [flash.nvim](https://github.com/folke/flash.nvim) が必要 }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, - motions = { char = true }, + motions = { char = true, search = true }, } ``` @@ -92,12 +92,22 @@ Neovim ≥ 0.10 と [flash.nvim](https://github.com/folke/flash.nvim) が必要 ### `motions` -flash.nvim 自身が持つモーションへの統括設定です。現時点では flash の `modes.char` のみで、将来的に flash 自身の入口(`/` 検索モードなど)もここに追加されます。 +flash.nvim 自身が持つモーションへの統括設定です。現時点では flash の `modes.char` と `/` 検索モードに対応しており、将来的に他の flash 自身の入口もここに追加されます。 #### `char` flash.nvim 内蔵の拡張文字モーション(flash の `modes.char`、デフォルトで有効)を CJK 対応にします。入力した 1 文字が、`s` ジャンプと同じ多言語エンジンでマッチされます。`fv` で「中」へジャンプでき(小鶴双拼の zhong の頭文字 `v`)、`ft` なら「中」(日本語訓令式ローマ字 `tyuu`)や「梯」(ピンイン `ti`)に到達します。`;`/`,` の循環、カウント、オペレータ待機時の挙動は flash ネイティブのモーションとまったく同じです。マッチングは 1 文字のみで、複数キーの読みを入力したい場合は `s` を使ってください。`setup({ motions = { char = false } })` を設定すると、flash ネイティブの ASCII 専用の挙動に戻ります。 +#### `search` + +flash.nvim の検索統合を通して、`/` と `?` のラベルオーバーレイを CJK 対応にします。これには flash 自身の検索モードが必要で、flash では**デフォルトで無効**です。flash の設定で有効にしてください: + +```lua +{ modes = { search = { enabled = true } } } +``` + +クエリ(ピンイン/ローマ字)を入力すると、CJK のマッチに flash-cjk の予測ラベルが表示され、ラベル文字を押すだけでジャンプできます。ラベルが入力の続きになりそうな文字と衝突することはないため、クエリを打ち続けていて意図せずジャンプしてしまうことはありません。`` を押せばクエリをそのまま検索として確定し、`n`/`N` はネイティブな vim の挙動のままです。vim 正規表現のメタ文字(`\ . * [ ] ^ $ ~ /`)や非 ASCII バイトを含むクエリはネイティブな vim 正規表現としてそのまま渡されるため、`/.*` や `/^func` は従来どおり動作します。`/` パスでの句読点の CJK クラスは非メタ文字キー(`, ; : ' "` など)のみを対象とし、`s` ジャンプでは `.` `?` `[` `]` を含む完全なセットが引き続き使えます。 + ## ⌨️ 使い方 操作は flash.nvim とほぼ同じです。`s` を押し、目的のテキストに対応する入力コードを入力するだけです。以下の例では、トリガーキー `s` を含めた完全なキー列で示します。 @@ -118,6 +128,10 @@ English, a中文,日本語, 한국어. Hello, 你好,こんにちは、안 同じエンジンは `f`/`t`/`F`/`T`(flash の拡張文字モーション)にも作用します。上のサンプル行では、`fv` で「中」へジャンプできます。 +### 検索 + +flash の検索モードを有効にすれば、`/ti` と入力したときに 梯/ち に flash-cjk のラベルが表示され、ラベルを押すとジャンプします。 + ### 多言語マッチング `sn` と入力すると、クエリ文字 `n` が次のすべてに同時にマッチします: diff --git a/README.ko.md b/README.ko.md index c8c0d29..c72db3f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -63,7 +63,7 @@ Neovim ≥ 0.10과 [flash.nvim](https://github.com/folke/flash.nvim)이 필요 }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, - motions = { char = true }, + motions = { char = true, search = true }, } ``` @@ -92,12 +92,22 @@ Neovim ≥ 0.10과 [flash.nvim](https://github.com/folke/flash.nvim)이 필요 ### `motions` -flash.nvim 자체 모션에 대한 통합 설정 묶음입니다. 현재는 flash의 `modes.char`뿐이며, 향후 flash 자체 진입점(예: `/` 검색 모드)도 여기서 설정하게 됩니다. +flash.nvim 자체 모션에 대한 통합 설정 묶음입니다. 현재는 flash의 `modes.char`와 `/` 검색 모드를 지원하며, 향후 다른 flash 자체 진입점도 여기서 설정하게 됩니다. #### `char` flash.nvim 내장 확장 문자 모션(flash의 `modes.char`, 기본 활성화)도 CJK 매칭을 지원하게 합니다. 입력한 한 글자가 `s` 점프와 동일한 다국어 엔진으로 매칭됩니다. `fv`로 「中」에 점프할 수 있고(샤오헤 쌍병음에서 zhong의 초성 `v`), `ft`면 「中」(일본어 훈레이식 로마자 `tyuu`)이나 「梯」(병음 `ti`)에 도달합니다. `;`/`,` 순환, 카운트, 오퍼레이터 대기 동작은 flash 네이티브 모션과 완전히 같습니다. 매칭은 한 글자만 지원하며, 여러 키로 이루어진 읽기를 입력하려면 `s`를 사용하세요. `setup({ motions = { char = false } })`로 설정하면 flash 네이티브의 ASCII 전용 동작으로 되돌아갑니다. +#### `search` + +flash.nvim의 검색 통합을 통해 `/`와 `?`의 라벨 오버레이를 CJK 매칭으로 확장합니다. 이 기능은 flash 자체의 검색 모드가 필요한데, flash에서는 **기본 비활성화**입니다. flash 설정에서 활성화하세요: + +```lua +{ modes = { search = { enabled = true } } } +``` + +쿼리(병음/로마자)를 입력하면 CJK 매칭에 flash-cjk의 예측 라벨이 표시되고, 라벨 문자를 누르면 점프합니다. 라벨은 이어 입력할 가능성이 있는 문자와 절대 충돌하지 않으므로, 쿼리를 계속 입력하다가 의도치 않게 점프하는 일은 없습니다. ``을 누르면 쿼리를 그대로 검색으로 확정하고, `n`/`N`은 네이티브 vim 동작을 유지합니다. vim 정규식 메타 문자(`\ . * [ ] ^ $ ~ /`)나 비 ASCII 바이트를 포함한 쿼리는 네이티브 vim 정규식으로 그대로 전달되므로 `/.*`와 `/^func`는 이전과 똑같이 동작합니다. `/` 경로에서 문장 부호 CJK 클래스는 메타 문자가 아닌 키(`, ; : ' "` 등)만 다루며, `s` 점프는 `.` `?` `[` `]`를 포함한 전체 집합을 계속 유지합니다. + ## ⌨️ 사용법 사용 방법은 flash.nvim과 거의 같습니다. `s`를 누른 다음 대상 텍스트의 입력 코드를 입력하면 됩니다. 아래 예시에서는 트리거 키 `s`를 포함한 전체 키 시퀀스를 보여 줍니다. @@ -118,6 +128,10 @@ English, a中文,日本語, 한국어. Hello, 你好,こんにちは、안 같은 엔진은 `f`/`t`/`F`/`T`(flash의 확장 문자 모션)에도 적용됩니다. 위 샘플 행에서 `fv`를 입력하면 「中」로 점프합니다. +### 검색 + +flash의 검색 모드를 활성화하면 `/ti` 입력 시 梯/ち에 flash-cjk 라벨이 표시되고, 라벨을 누르면 점프합니다. + ### 다국어 매칭 `sn`을 입력하면 쿼리 문자 `n`이 다음 항목을 동시에 매칭합니다: diff --git a/README.md b/README.md index 82016a7..477ea09 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Default configuration: }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, - motions = { char = true }, + motions = { char = true, search = true }, } ``` @@ -92,12 +92,22 @@ Allows a single query to mix input codes from different languages. See Mixed inp ### `motions` -Groups the integrations on motions flash.nvim itself owns. Today that is flash's `modes.char`; future flash-owned surfaces (such as the `/` search mode) would be configured here as well. +Groups the integrations on motions flash.nvim itself owns. Today that is flash's `modes.char` and the `/` search mode; future flash-owned surfaces would be configured here as well. #### `char` Makes flash.nvim's built-in enhanced char motions (flash's `modes.char`, enabled by default) CJK-aware. A single typed character is matched through the same multi-language engine as `s`-jumps: `fv` jumps to `中` (Xiaohe `v` initial for zhong), while `ft` reaches `中` (Japanese kunrei-shiki `tyuu`) or `梯` (pinyin `ti`). `;`/`,` cycling, counts, and operator-pending behave exactly like flash's native motions. Matching is single-character only — press `s` for full multi-key readings. Set `setup({ motions = { char = false } })` to restore flash's native ASCII-only behavior. +#### `search` + +Makes the `/` and `?` label overlays CJK-aware through flash.nvim's search integration. This requires flash's own search mode, which is **off by default in flash** — enable it in your flash opts: + +```lua +{ modes = { search = { enabled = true } } } +``` + +Type the query (pinyin/romaji letters) and flash-cjk's predictive labels appear on CJK matches; press a label char to jump. Labels never collide with likely continuation letters, so extending the query never jumps you somewhere unexpected. Pressing `` commits the search as typed, and `n`/`N` keep native vim semantics. Queries containing vim regex metacharacters (`\ . * [ ] ^ $ ~ /`) or non-ASCII bytes pass through as native vim regex, so `/.*` and `/^func` keep working exactly as before. On the `/` path, punctuation CJK classes cover only non-metacharacter keys (e.g. `, ; : ' "`); the `s`-jump keeps the full set including `.` `?` `[` `]`. + ## ⌨️ Usage Usage is almost identical to flash.nvim: press `s`, then type the input code for the target text. The examples below include the trigger key `s` as part of the full key sequence. @@ -118,6 +128,10 @@ Type `sav` to match `a中`: `a` is matched literally as ASCII, while `v` is the The same engine also powers `f`/`t`/`F`/`T` (flash's enhanced char motions). On the sample line above, `fv` jumps to `中`. +### Search + +With flash's search mode enabled, `/ti` shows flash-cjk labels on 梯/ち; press the label to jump. + ### Multi-language matching Type `sn`, and the query character `n` matches all of the following at once: diff --git a/README.zh.md b/README.zh.md index 73ddb8f..c2573a3 100644 --- a/README.zh.md +++ b/README.zh.md @@ -63,7 +63,7 @@ https://github.com/user-attachments/assets/37599dab-b0c6-4d90-8463-cb4706841ac3 }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, - motions = { char = true }, + motions = { char = true, search = true }, } ``` @@ -92,12 +92,22 @@ https://github.com/user-attachments/assets/37599dab-b0c6-4d90-8463-cb4706841ac3 ### `motions` -用于集中配置 flash.nvim 自有移动方式的集成。目前是 flash 的 `modes.char`,未来其他 flash 自有的入口(例如 `/` 搜索模式)也会放在这里配置。 +用于集中配置 flash.nvim 自有移动方式的集成。目前包括 flash 的 `modes.char` 和 `/` 搜索模式,未来其他 flash 自有的入口也会放在这里配置。 #### `char` 让 flash.nvim 内置的增强字符移动(flash 的 `modes.char`,默认开启)也支持 CJK 匹配。输入的单个字符会通过与 `s` 跳转相同的多语言引擎匹配:`fv` 可以跳到「中」(小鹤双拼中 zhong 的声母 `v`),`ft` 则能到达「中」(日语训令式罗马字 `tyuu`)或「梯」(拼音 `ti`)。`;`/`,` 循环、计数和 operator-pending 行为与 flash 原生移动完全一致。匹配只支持单个字符——需要完整的多键输入码时请按 `s`。设置 `setup({ motions = { char = false } })` 即可恢复 flash 原生的纯 ASCII 行为。 +#### `search` + +通过 flash.nvim 的搜索集成,让 `/` 和 `?` 的标签覆盖层支持 CJK 匹配。它依赖 flash 自身的搜索模式,而该模式在 flash 中**默认关闭**——需要在 flash 的配置中开启: + +```lua +{ modes = { search = { enabled = true } } } +``` + +输入查询(拼音/罗马字字母)后,flash-cjk 的预测式标签会出现在 CJK 匹配项上,按下标签字符即可跳转。标签绝不会与可能的后续输入字母冲突,因此继续输入查询不会被意外跳走。直接按 `` 会按原样提交搜索,`n`/`N` 保持原生 vim 语义。包含 vim 正则元字符(`\ . * [ ] ^ $ ~ /`)或非 ASCII 字节的查询会原样交给原生 vim 正则处理,所以 `/.*`、`/^func` 的行为和以前完全一样。在 `/` 路径上,标点的 CJK 匹配类只覆盖非元字符键(如 `, ; : ' "`);`s` 跳转仍保留包括 `.` `?` `[` `]` 在内的完整集合。 + ## ⌨️ 使用 用法和 flash.nvim 基本一致:按下 `s`,再输入目标文本的输入码即可。下面的示例会把触发键 `s` 一并写入完整按键序列。 @@ -118,6 +128,10 @@ English, a中文,日本語, 한국어. Hello, 你好,こんにちは、안 同样的引擎也作用于 `f`/`t`/`F`/`T`(flash 的增强字符移动)。在上面这行示例中,`fv` 会跳到「中」。 +### 搜索 + +开启 flash 的搜索模式后,输入 `/ti` 会在 梯/ち 上显示 flash-cjk 标签,按下标签即可跳转。 + ### 多语言匹配 输入 `sn` 后,查询字符 `n` 会同时匹配: diff --git a/lua/flash-cjk/config.lua b/lua/flash-cjk/config.lua index 8b5c369..7334f7a 100644 --- a/lua/flash-cjk/config.lua +++ b/lua/flash-cjk/config.lua @@ -19,8 +19,9 @@ M.config = { }, priority = { "zhcn", "ja", "ko" }, mixed_input = true, - -- integrations on flash-owned entry points (char: CJK-aware f/t/F/T) - motions = { char = true }, + -- integrations on flash-owned entry points (char: CJK-aware + -- f/t/F/T, search: CJK-aware / and ?) + motions = { char = true, search = true }, } ---Normalizes one languages[lang] value: true -> enabled, false -> @@ -64,7 +65,7 @@ end ---silently enable the integration). Unknown fields are ignored ---(forward compatibility). ---@param motions table ----@return table normalized { char?: boolean } +---@return table normalized { char?: boolean, search?: boolean } function M.normalize_motions(motions) local normalized = {} if motions.char ~= nil then @@ -73,6 +74,12 @@ function M.normalize_motions(motions) end normalized.char = motions.char end + if motions.search ~= nil then + if type(motions.search) ~= "boolean" then + error("flash-cjk: motions.search must be a boolean") + end + normalized.search = motions.search + end return normalized end diff --git a/lua/flash-cjk/init.lua b/lua/flash-cjk/init.lua index bc4077c..9b4db63 100644 --- a/lua/flash-cjk/init.lua +++ b/lua/flash-cjk/init.lua @@ -65,6 +65,7 @@ local function build_opts(langs) patches.get_char_patch() patches.prompt_patch() patches.char_mode_patch() + patches.search_mode_patch() local defaults = { labels = "asdfghjklqwertyuiopzxcvbnm", search = { @@ -130,9 +131,11 @@ function M.setup(opts) -- labeler-layer only: the mix mode does not read it M.config.priority = config.normalize_priority(opts.priority) end - -- also installed here so ftFT is CJK-aware before the first jump: - -- flash-cjk loads before flash's first f/F/t/T press + -- also installed here so ftFT and `/` are CJK-aware before the + -- first jump: flash-cjk loads before flash's first f/F/t/T press + -- or `/` search patches.char_mode_patch() + patches.search_mode_patch() end return M diff --git a/lua/flash-cjk/patches.lua b/lua/flash-cjk/patches.lua index d3e06d7..01598f3 100644 --- a/lua/flash-cjk/patches.lua +++ b/lua/flash-cjk/patches.lua @@ -1,8 +1,8 @@ -- flash.nvim patches: C-c dispatch, prompt lock display, and the --- CJK-aware char-motion mode wrap. Installed by build_opts() in --- init.lua -- the char wrap also by setup(), ahead of flash's first --- f/F/t/T press. Each patch mutates flash only when a flash-cjk --- feature needs it. +-- CJK-aware char-motion and search-mode wraps. Installed by +-- build_opts() in init.lua -- the char and search wraps also by +-- setup(), ahead of flash's first f/F/t/T press or `/` search. Each +-- patch mutates flash only when a flash-cjk feature needs it. local M = {} @@ -119,4 +119,58 @@ function M.char_mode_patch() end end +-- Native vim regex keeps its contract: metacharacters (the magic +-- set), the search delimiter, and non-ASCII bytes (IME input) pass +-- through untouched; only plain-text queries go through the mix +-- compiler. Trade-off: punctuation-class CJK matching in `/` covers +-- only the non-meta keys , ; ' " : ! - -- the s-jump keeps the full +-- set (there . [ ] ? are matchable too). +local function native_regex(query) + return query:find("[\\.%[%]^$~/*]") ~= nil or query:find("[\128-\255]") ~= nil +end + +-- Makes flash's search mode (modes.search: `/` and `?` with flash's +-- label overlay) CJK-aware: every cmdline change recompiles the whole +-- query through the mix mode, so pinyin/romaji input matches CJK +-- characters; typing a label char jumps through flash's own +-- check_jump. /n/N keep native semantics. Queries that look like +-- regex (see native_regex) pass through verbatim -- flash's +-- operator-pending `\%l\%c.` rewrite relies on that too. +function M.search_mode_patch() + local ok, Search = pcall(require, "flash.plugins.search") + if not ok or type(Search.start) ~= "function" or Search._flash_cjk_patched then + return + end + ---@diagnostic disable-next-line: inject-field + Search._flash_cjk_patched = true + local orig = Search.start + ---@diagnostic disable-next-line: duplicate-set-field + Search.start = function() + orig() + local state = Search.state + if not config.config.motions.search or not state then + return + end + -- compiled per start, so setup() changes are always honored + local flags = config.lang_flags() + local keys = config.filter_keys(config.config.languages) + local mix = match.make_mix_mode(flags, keys) + -- State.new bakes the hardcoded mode string and the default + -- labeler into plain fields; both are swapped post-construction + state.pattern.mode = function(str) + if native_regex(str) then + return str + end + return mix(str) + end + -- unlike char mode, queries are multi-char: a typed + -- continuation letter could collide with an assigned label, so + -- the flash-cjk labeler (which predicts and skips likely next + -- letters) must replace flash's default here + state.labeler = function(_, st) + require("flash-cjk.labeler").new(st, flags, keys, config.config.priority):update() + end + end +end + return M diff --git a/tests/e2e/repro.lua b/tests/e2e/repro.lua index 001ba3a..2d1b96e 100644 --- a/tests/e2e/repro.lua +++ b/tests/e2e/repro.lua @@ -44,6 +44,10 @@ require("lazy").setup({ "folke/flash.nvim", opts = { highlight = { backdrop = false, matches = false }, + -- flash ships modes.search disabled; the e2e search-mode + -- section drives the real / and ? cmdline flow, which only + -- creates a search state when the mode is enabled + modes = { search = { enabled = true } }, }, }, { diff --git a/tests/e2e/scenario.lua b/tests/e2e/scenario.lua index 0e2cec4..2b489df 100644 --- a/tests/e2e/scenario.lua +++ b/tests/e2e/scenario.lua @@ -16,6 +16,7 @@ local function ok(cond, msg) failed = failed + 1 out:write("FAIL " .. msg .. "\n") end + out:flush() -- survive a killed run: the file is the only signal end local function finish() @@ -357,4 +358,269 @@ do ok(fc.config.motions.char == true, "char re-enabled after the disabled check") end -finish() +-- 4. Search mode (/ and ?) through the real cmdline flow: `/` typed +-- via nvim_input opens the cmdline, flash's CmdlineEnter autocmd +-- creates the search state, every CmdlineChanged recompiles the +-- pattern through flash-cjk's mode fn, and typing a label char jumps +-- via flash's own check_jump. The search wrap is vim-regex in BOTH +-- phases (rust never touches modes.search), so every check is +-- phase-agnostic. The cmdline is closed and the module state reset +-- between cases so nothing leaks past this section. +-- +-- The main input loop only consumes nvim_input typeahead once this +-- scheduled scenario callback has RETURNED (vim.wait pumps events but +-- never hands queued keys to the cmdline), so the cases run as +-- coroutines driven by a uv timer between cmdline keystrokes -- +-- real `/` keys, real autocmds, only the observer is async. The +-- driver owns finish(): it fires once the last case settles. +do + local Search = require("flash.plugins.search") + local uv = vim.uv or vim.loop + local slines = { "aa 中 bb 梯 cc", "xx ち yy" } + local function buf_reset() + vim.cmd("enew!") + vim.api.nvim_buf_set_lines(0, 0, -1, false, slines) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + end + + local function match_char(m) + local line = (vim.api.nvim_buf_get_lines(0, m.pos[1] - 1, m.pos[1], false))[1] or "" + return line:sub(m.pos[2] + 1, m.pos[2] + 3) -- CJK: 3 bytes + end + + local function any_cjk(results) + for _, m in ipairs(results or {}) do + local ch = match_char(m) + if ch == "梯" or ch == "ち" then + return true + end + end + return false + end + + local function cmdline_open() + return vim.fn.getcmdtype() ~= "" + end + + -- suspends the case until pred() holds; the driver resumes with + -- false once the case deadline passes, so callers fail honestly + local function yield(pred) + return (coroutine.yield(pred)) + end + + -- exits any open cmdline FIRST (its CmdlineLeave autocmd still + -- reads Search.state), then drops the module state + local function search_reset() + if cmdline_open() then + vim.api.nvim_input("") + end + yield(function() + return not cmdline_open() + end) + if Search.state then + Search.state:hide() + Search.state = nil + end + end + + -- feeds `cmd .. query` into the real cmdline and waits for flash's + -- state to settle on the full query (waits on the pattern, not on + -- matches -- literal-only queries legitimately produce none) + local function cmdline_search(cmd, query) + vim.api.nvim_input(cmd .. query) + return yield(function() + return Search.state ~= nil + and Search.state.pattern ~= nil + and Search.state.pattern.pattern == query + end) + end + + local cases = {} + local function add_case(fn) + cases[#cases + 1] = coroutine.create(fn) + end + + -- /ti: pinyin 梯 and romaji ち both match through the mix mode, + -- the compiled pattern keeps the plain-text alternative, and a + -- real label char jumps the cursor onto the CJK match + add_case(function() + buf_reset() + search_reset() + local opened = cmdline_search("/", "ti") + ok(opened, "search /ti: cmdline flow created a flash search state") + if opened and Search.state.results then + ok( + any_cjk(Search.state.results), + "search /ti: results include CJK (梯/ち) through pinyin+romaji" + ) + ok( + Search.state.pattern.search:find("[tT][iI]", 1, true) ~= nil, + "search /ti: compiled pattern also keeps the literal [tT][iI] alternative" + ) + local target + for _, m in ipairs(Search.state.results) do + if m.label and (match_char(m) == "梯" or match_char(m) == "ち") then + target = m + break + end + end + if target then + local want_r, want_c = target.pos[1], target.pos[2] + local want_ch = match_char(target) + -- extends the cmdline by one char naming a label: + -- flash's check_jump fires, exits the cmdline, jumps + vim.api.nvim_input(target.label) + local landed = yield(function() + local cur = vim.api.nvim_win_get_cursor(0) + return Search.state == nil and cur[1] == want_r and cur[2] == want_c + end) + local cur = vim.api.nvim_win_get_cursor(0) + ok( + landed, + ("search /ti: label %s jumped the cursor onto %s (got %d,%d want %d,%d)"):format( + target.label, + want_ch, + cur[1], + cur[2], + want_r, + want_c + ) + ) + else + ok(false, "search /ti: no labeled CJK match to jump to") + end + elseif opened then + ok(false, "search /ti: no results on the search state") + end + end) + + -- ? runs the same flow backward: same CJK matches, forward=false + add_case(function() + buf_reset() + search_reset() + local opened = cmdline_search("?", "ti") + ok(opened, "search ?ti: cmdline flow created a flash search state") + if opened and Search.state.results then + ok( + Search.state.opts.search.forward == false, + "search ?ti: state marks the search backward (forward=false)" + ) + ok(any_cjk(Search.state.results), "search ?ti: results include CJK (梯/ち)") + elseif opened then + ok(false, "search ?ti: no results on the search state") + end + end) + + -- metacharacter queries pass through verbatim: `.*` must reach + -- flash as the plain vim regex it was typed as, not a CJK class + add_case(function() + buf_reset() + search_reset() + local opened = cmdline_search("/", ".*") + ok(opened, "search /.*: cmdline flow reached the pattern") + if opened then + ok( + Search.state.pattern.search == ".*", + ("search /.*: pattern passed through verbatim (got %s)"):format( + tostring(Search.state.pattern.search) + ) + ) + end + end) + + -- gate: motions.search=false leaves flash's search mode untouched + -- -- the pattern mode stays flash's own "search" string and /ti is + -- literal-only (no CJK results; this buffer has no literal "ti") + add_case(function() + fc.setup({ motions = { search = false } }) + buf_reset() + search_reset() + local opened = cmdline_search("/", "ti") + ok(opened, "search gate: cmdline flow created a flash search state") + if opened then + ok( + type(Search.state.pattern.mode) == "string", + "search gate: pattern mode untouched (flash's own string)" + ) + ok( + not any_cjk(Search.state.results), + "search gate: /ti literal-only with motions.search=false (no CJK results)" + ) + end + search_reset() + + -- re-enable what this section disabled + fc.setup({ motions = { search = true } }) + ok(fc.config.motions.search == true, "search re-enabled after the disabled check") + end) + + -- trailer: never leave the cmdline open past this section, so no + -- cmdline state leaks into anything that runs later + add_case(function() + -- (a blocking getchar drain is off-limits here: it would stall + -- the driver mid-case, past even its deadline) + search_reset() + end) + + -- one resume per tick: start the case (pred == nil), or resume it + -- with the pred's verdict -- false once the per-case deadline + -- passes, so timeouts fail their check instead of hanging + local timer = assert(uv.new_timer()) + local idx, pred, deadline = 1, nil, nil + local CASE_MS, SECTION_MS = 5000, 60000 + local section_deadline = uv.now() + SECTION_MS + -- ticks queued while a case body pumps the loop must not resume + -- the still-running coroutine ("cannot resume running coroutine") + local ticking = false + local function stop() + timer:stop() + timer:close() + finish() + end + local function step() + if uv.now() > section_deadline then + ok(false, "search section: global timeout") + return stop() + end + local co = cases[idx] + if not co then + return stop() + end + if coroutine.status(co) == "dead" then + idx, pred = idx + 1, nil + return + end + local verdict = pred and pred() + if pred and not verdict and uv.now() <= deadline then + return -- still waiting on the current pred + end + local ran, next_pred = coroutine.resume(co, pred and verdict or nil) + deadline = uv.now() + CASE_MS + if not ran then + ok(false, "search case crashed: " .. tostring(next_pred)) + idx, pred = idx + 1, nil + return + end + if coroutine.status(co) == "dead" then + idx, pred = idx + 1, nil + else + pred = next_pred + end + end + timer:start( + 10, + 10, + vim.schedule_wrap(function() + if ticking then + return + end + ticking = true + local ran, err = pcall(step) + ticking = false + if not ran then + ok(false, "search case crashed: " .. tostring(err)) + idx, pred = idx + 1, nil + end + end) + ) +end diff --git a/tests/run.lua b/tests/run.lua index 0418d03..1f7ee41 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -922,9 +922,9 @@ do ok(not pcall(fc.setup, { motions = "nope" }), "char mode: motions rejects non-table values") -- unknown motion fields are dropped (forward compatibility), -- including future table-shaped ones -- nothing arbitrary persists - fc.setup({ motions = { search = { enabled = true } } }) + fc.setup({ motions = { future = { enabled = true } } }) ok( - fc.config.motions.char == true and fc.config.motions.search == nil, + fc.config.motions.char == true and fc.config.motions.future == nil, "char mode: unknown motion fields dropped by setup" ) local ok_typo = pcall(fc.setup, { motions = { typo = true } }) @@ -999,6 +999,151 @@ do fc.setup({ motions = { char = true } }) -- re-enable for anything that follows end +-- --------------------------------------------------------------------------- +-- search mode (/ and ?): CJK-aware search labels through the +-- idempotent search_mode_patch -- at state level. Search.start() +-- builds a full flash State outside any cmdline, so no real `/` +-- session is needed to exercise the swap + +do + local patches = require("flash-cjk.patches") + patches.search_mode_patch() -- no-op when setup() already installed it + local Search = require("flash.plugins.search") + + ok(Search._flash_cjk_patched == true, "search mode: patch marker set on flash.plugins.search") + local start_ref = Search.start + patches.search_mode_patch() + ok(Search.start == start_ref, "search mode: re-patch does not double-wrap") + + local function search_reset() + if Search.state then + Search.state:hide() + end + Search.state = nil + end + + vim.cmd("enew!") + vim.api.nvim_buf_set_lines( + 0, + 0, + -1, + false, + { "ti 梯 ち", "func 日本 ab*cd", "な に ぬ ね の" } + ) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + -- enhanced start: mode compiler and labeler are swapped in + -- post-construction + Search.start() + local state = assert(Search.state) + ok(type(state.pattern.mode) == "function", "search mode: pattern.mode swapped to a function") + ok(type(state.labeler) == "function", "search mode: labeler swapped to a function") + + state:update({ pattern = "ti", check_jump = false }) + local hit_cn, hit_kana = false, false + for _, m in ipairs(state.results) do + -- pos[2] is a 0-based byte column: compare against 3 bytes + local ch = string.sub(vim.fn.getline(m.pos[1]), m.pos[2] + 1, m.pos[2] + 3) + if ch == "梯" then + hit_cn = true + elseif ch == "ち" then + hit_kana = true + end + end + ok(hit_cn, "search mode: ti finds pinyin 梯") + ok(hit_kana, "search mode: ti finds kana ち") + ok(state.pattern.search ~= "ti", "search mode: query compiled, not passed through") + local re_ti = vim.regex(state.pattern.search) + ok( + re_ti:match_str("梯") ~= nil and re_ti:match_str("ち") ~= nil, + "search mode: compiled regex matches 梯 and ち" + ) + + -- multi-char queries need the predictive labeler: with pattern "n", + -- every candidate's next romaji letter (na ni nu ne no) is a + -- likely continuation -- none may collide with an assigned label + -- (flash's own labeler would hand out "a" as its first label here) + state:update({ pattern = "n", check_jump = false }) + local predicted = { a = true, e = true, i = true, o = true, u = true } + local n_labeled_search, collide = 0, false + for _, m in ipairs(state.results) do + if m.label then + n_labeled_search = n_labeled_search + 1 + if predicted[m.label] then + collide = true + end + end + end + ok( + n_labeled_search >= 5 and not collide, + "search mode: predictive labeler skips likely next letters (a/i/u/e/o)" + ) + + -- native regex contract: the magic set, the delimiter and + -- non-ASCII bytes pass through verbatim -- flash's operator-pending + -- \\%l\\%c. rewrite relies on this + for _, p in ipairs({ "\\%1l\\%2c.", ".*", "^func", "foo.*bar", "ab*cd", "日本" }) do + state:update({ pattern = p, check_jump = false }) + ok(state.pattern.search == p, "search mode: native passthrough for " .. vim.inspect(p)) + end + + -- punctuation split: non-meta keys compile through the mix (, + -- matches ,); metacharacters like . pass through verbatim -- only + -- the s-jump keeps the full punctuation set + state:update({ pattern = ",", check_jump = false }) + ok(state.pattern.search ~= ",", "search mode: comma compiles through the mix mode") + ok( + vim.regex(state.pattern.search):match_str(",") ~= nil, + "search mode: comma pattern matches ," + ) + state:update({ pattern = ".", check_jump = false }) + ok(state.pattern.search == ".", "search mode: dot passes through verbatim") + + -- gate: motions.search=false leaves flash's native search state + search_reset() + fc.setup({ motions = { search = false } }) + Search.start() + local plain = assert(Search.state) + ok( + plain.pattern.mode == "search", + "search mode: motions.search=false keeps the native mode string" + ) + plain:update({ pattern = "ti", check_jump = false }) + local gate_cjk = false + for _, m in ipairs(plain.results) do + local ch = string.sub(vim.fn.getline(m.pos[1]), m.pos[2] + 1, m.pos[2] + 3) + if ch == "梯" or ch == "ち" then + gate_cjk = true + end + end + ok(not gate_cjk, "search mode: gate off -- ti results are literal-only (no CJK)") + ok(#plain.results > 0, "search mode: gate off -- literal ti still found") + search_reset() + + -- re-enable: a fresh start picks the swap up again (compiled per + -- start, so setup() changes are always honored) + fc.setup({ motions = { search = true } }) + Search.start() + ok( + type(Search.state.pattern.mode) == "function" and type(Search.state.labeler) == "function", + "search mode: re-enabled start swaps mode and labeler in again" + ) + search_reset() + + -- validation mirrors char mode: known flags must be booleans (a + -- rejected setup leaves the config untouched); unknown fields drop + ok( + not pcall(fc.setup, { motions = { search = "false" } }), + "search mode: motions.search rejects non-boolean values" + ) + ok(fc.config.motions.search == true, "search mode: config untouched after rejected setup") + local ok_typo2 = pcall(fc.setup, { motions = { typo2 = true } }) + ok( + ok_typo2 and fc.config.motions.typo2 == nil, + "search mode: unknown motion fields dropped, not persisted" + ) +end + print(string.format("%d passed, %d failed", passed, failed)) if failed > 0 then error("test failures")