Merge pull request #71 from levineuwirth/session-regex-search
Regex in-buffer search (multi-line, C-M-s + M-r toggle)
This commit is contained in:
commit
5f342ed2c6
|
|
@ -2434,6 +2434,7 @@ dependencies = [
|
|||
"proptest",
|
||||
"rand 0.8.6",
|
||||
"rand_distr",
|
||||
"regex",
|
||||
"rmp-serde",
|
||||
"semver",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ crdt = ["dep:loro", "pmacs-protocol/crdt"]
|
|||
crossterm = "0.28"
|
||||
thiserror = { workspace = true }
|
||||
unicode-width = "0.2"
|
||||
# Regex engine for in-buffer regex search (Q#RX1). `regex::bytes::Regex`
|
||||
# matches over rope-snapshot bytes and yields byte offsets directly.
|
||||
# Already in the lockfile transitively; promoted to a direct dependency.
|
||||
regex = "1"
|
||||
# Work-stealing deque, MPMC channels, and parking primitives for the
|
||||
# M3 worker pool (spec §6.3). The umbrella crate re-exports
|
||||
# `crossbeam_deque`, `crossbeam_channel`, and `crossbeam_utils`.
|
||||
|
|
|
|||
|
|
@ -122,15 +122,23 @@ cmd { name = "buffer.self-insert",
|
|||
-- C-s / C-r begin a live in-buffer isearch: the match under the cursor
|
||||
-- highlights as you type, the same key steps to the next/previous
|
||||
-- match, RET accepts (keeping the highlights until the next edit), and
|
||||
-- C-g / Esc restore the pre-search cursor. While a search is running
|
||||
-- every keystroke is intercepted in Rust (dispatch_search_key), so
|
||||
-- these commands only run to *start* a search from an idle keymap.
|
||||
-- C-g / Esc restore the pre-search cursor. C-M-s / C-M-r start a regex
|
||||
-- search, and M-r toggles literal <-> regex mid-search (handled in
|
||||
-- Rust). While a search is running every keystroke is intercepted in
|
||||
-- Rust (dispatch_search_key), so these commands only run to *start* a
|
||||
-- search from an idle keymap. ed.search_start(forward, regex).
|
||||
cmd { name = "search.forward",
|
||||
description = "Start an incremental search forward from the cursor.",
|
||||
fn = function() ed.search_start(true) end }
|
||||
fn = function() ed.search_start(true, false) end }
|
||||
cmd { name = "search.backward",
|
||||
description = "Start an incremental search backward from the cursor.",
|
||||
fn = function() ed.search_start(false) end }
|
||||
fn = function() ed.search_start(false, false) end }
|
||||
cmd { name = "search.forward-regex",
|
||||
description = "Start an incremental regex search forward from the cursor.",
|
||||
fn = function() ed.search_start(true, true) end }
|
||||
cmd { name = "search.backward-regex",
|
||||
description = "Start an incremental regex search backward from the cursor.",
|
||||
fn = function() ed.search_start(false, true) end }
|
||||
|
||||
-- History --------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -59,8 +59,12 @@ bind("TAB", "buffer.tab")
|
|||
-- adds isearch without colliding with the CUA / Emacs editing keys.
|
||||
-- Once a search is running, C-s / C-r step to the next / previous
|
||||
-- match; that interception happens in Rust, so it needs no binding.
|
||||
-- C-M-s / C-M-r start a regex search (Emacs isearch-forward-regexp);
|
||||
-- M-r toggles literal <-> regex mid-search (intercepted in Rust).
|
||||
bind("C-s", "search.forward")
|
||||
bind("C-r", "search.backward")
|
||||
bind("C-M-s", "search.forward-regex")
|
||||
bind("C-M-r", "search.backward-regex")
|
||||
|
||||
-- CUA-style word-level deletion (the same shortcuts users expect from
|
||||
-- IDEs, browsers, terminals on Linux/Windows). C-BS deletes back to
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
# In-buffer search — consolidated framing + as-built
|
||||
|
||||
Consolidates the two framing passes for in-buffer search:
|
||||
**incremental substring isearch** (PR #70) and **regex search** (this
|
||||
arc). Supersedes the separate `incremental-search-framing.md` and
|
||||
`regex-search-framing.md`. Where the implementation diverged from a
|
||||
framing stance, the "As-built" notes record what actually shipped and
|
||||
why.
|
||||
|
||||
User-decided up front:
|
||||
|
||||
- **Incremental isearch** — highlight live as you type, the same key
|
||||
steps to the next match, `RET` accepts, `Esc`/`C-g` restores origin.
|
||||
- **Smart-case** matching (case-insensitive unless the query has an
|
||||
uppercase letter), for both substring and regex.
|
||||
- **Regex** with **both** dedicated entry keys (`C-M-s` / `C-M-r`)
|
||||
**and** a mid-search toggle (`M-r`), matching **multi-line**.
|
||||
|
||||
## Architecture (as-built)
|
||||
|
||||
- **`search::SearchStore`** — per-buffer `HashMap<BufferId,
|
||||
SearchState>` (query + sorted `Vec<ByteRange>` matches + active
|
||||
index), shared `Arc<Mutex>`, mirroring `diag::DiagnosticStore`. The
|
||||
active index is navigation state on the store (two windows on one
|
||||
buffer share the active highlight — the diagnostics tradeoff). Edits
|
||||
mark the entry stale (M11.8) so matches at pre-edit byte positions
|
||||
never paint until a re-search.
|
||||
- **`EditorCore::SearchSession { query, origin, forward, regex,
|
||||
invalid }`** — the live input state. `search_begin/input_char/
|
||||
backspace/step/finish/toggle_regex/recompute` drive it. `recompute`
|
||||
runs the matcher over an `O(1)` rope snapshot, writes the store,
|
||||
refocuses from the origin cursor, moves the cursor to the active
|
||||
match.
|
||||
- **Shared dispatch.** Keys are intercepted in `EditorState::
|
||||
dispatch_key` → `dispatch_search_key` (`SearchKey::from_chord`).
|
||||
This is the *same* path the daemon runs for round-tripped GUI
|
||||
keystrokes, so isearch behaves identically in both frontends; only
|
||||
the prompt *surface* differs.
|
||||
|
||||
## Matching
|
||||
|
||||
- **`find_all(haystack, query)`** — smart-case ASCII substring,
|
||||
non-overlapping. Case-insensitive unless `query` has an uppercase
|
||||
char.
|
||||
- **`find_all_regex(haystack, pattern) -> Option<Vec<ByteRange>>`** —
|
||||
`regex::bytes::Regex` over the whole buffer. `Some` for a valid
|
||||
pattern (possibly empty), `None` when it won't compile, so the caller
|
||||
distinguishes *invalid* (show `[invalid]`) from *zero matches*.
|
||||
Smart-case via a `(?i)` prefix unless the pattern carries an
|
||||
uppercase letter. Multi-line is free — the regex runs over the whole
|
||||
byte slice, so an explicit `\n` (or `(?s).`) spans lines while `.`
|
||||
stays line-bound. Zero-width matches (`a*`, `^`, `$`) are filtered.
|
||||
The `regex` crate's linear-time engine makes a pathological pattern
|
||||
slow at worst, never catastrophic. An uppercase letter inside an
|
||||
escape/class (`\D`, `[A-Z]`) trips case-sensitivity — accepted, the
|
||||
same coarse rule as the literal path.
|
||||
|
||||
## Input & bindings
|
||||
|
||||
- `C-s` / `C-r` — start a literal isearch forward / backward; once
|
||||
running, the same keys step next / previous (intercepted in Rust, no
|
||||
binding). `RET` accepts (keeps cursor + highlights until the next
|
||||
edit); `C-g` / `Esc` cancels (restores the origin cursor, clears the
|
||||
store); `BS` shortens the query.
|
||||
- `C-M-s` / `C-M-r` — start a **regex** isearch (`search.forward-regex`
|
||||
/ `search.backward-regex` → `ed.search_start(forward, regex)`).
|
||||
- `M-r` — toggle literal ↔ regex mid-search (a `SearchKey` decoded in
|
||||
`dispatch_search_key`, so it works the same in both frontends).
|
||||
- Both keys were free in the default map (save is `C-x C-s`, redo is
|
||||
`C-x r`), so isearch landed without disturbing the CUA / Emacs
|
||||
editing keys.
|
||||
|
||||
## Frontend surfaces
|
||||
|
||||
- **TUI** — a `SearchView` overlay attached to the active window on
|
||||
`search_begin` (deduped; self-suppresses with no matches / when
|
||||
stale) washes matches; a bottom-row prompt reads `[Regex] I-search:
|
||||
<query> (n/m)` (or `[no match]` / `[invalid]`). The terminal cursor
|
||||
stays in the buffer at the active match. Multi-line matches wash each
|
||||
spanned row (mirrors `paint_local_selection`'s per-row clip, newline
|
||||
excluded).
|
||||
- **GUI (pmacs-gpu)** — matches wash via `SearchMatch` /
|
||||
`SearchMatchActive` decorations through `push_glyph_extent_rects`,
|
||||
which already fans a byte range across visual lines, so **multi-line
|
||||
needed no GUI rendering change**. The query reaches the band via the
|
||||
`SearchPrompt` wire message. Key routing reuses the M11.6
|
||||
`DispatchIdle` gate: `daemon_intercepts_keys` (a live `SearchPrompt`
|
||||
or `!dispatch_idle`) round-trips every key into the daemon's search
|
||||
while it runs, and `is_search_entry_chord` (`C-s`/`C-r`/`C-M-s`/
|
||||
`C-M-r`) forwards the entry chords that are otherwise withheld.
|
||||
Escape cancels an active search instead of quitting the window.
|
||||
|
||||
## Wire (`InstanceMessage::SearchPrompt`)
|
||||
|
||||
`{ buffer_id, query: Option<String>, active: Option<u32>, total: u32,
|
||||
regex: bool, invalid: bool }`. Emitted by the semantic producer
|
||||
(cached-compare suppressed like `StatusFacts`); `query: None` clears
|
||||
the band. Protocol **v9** added the message (query/active/total);
|
||||
**v10** added `regex` / `invalid` (an encoding change to the variant),
|
||||
so the daemon's per-session filter gates it at `>= 10` — a v9 peer is
|
||||
sent no `SearchPrompt` (decorations still highlight) rather than
|
||||
mis-decoding the wider shape. `SUPPORTED = [6, 7, 8, 9, 10]`.
|
||||
|
||||
## As-built divergences from the framing passes
|
||||
|
||||
1. **Entry binding: `C-f` → `C-s` / `C-r`.** The incremental framing
|
||||
penciled `C-f` (CUA "Find", rebinding `cursor.right`) "for veto."
|
||||
`C-s` / `C-r` shipped instead: both were unbound, Emacs-faithful,
|
||||
and need no `cursor.right` rebind. User-validated.
|
||||
2. **Input model: minibuffer-hosted → dedicated core search mode.**
|
||||
The framing (Q#SR5) proposed hosting the query in the minibuffer
|
||||
with a new `on_changed` hook. Shipped as a frontend-agnostic
|
||||
`SearchSession` on `EditorCore` driven by `dispatch_search_key`,
|
||||
because **pmacs-gpu has no minibuffer** — a shared core mode was the
|
||||
only way to make search work identically in both frontends.
|
||||
3. **Regex: deferred → shipped.** Q#SR2 deferred regex ("literal-text
|
||||
is the 95% case"); this arc added it as `find_all_regex` + a mode
|
||||
flag, keeping the literal path the default.
|
||||
4. **Multi-line: substring single-line → regex multi-line.** Substring
|
||||
matches never span lines; regex can. The TUI `SearchView` (which
|
||||
assumed single-line) gained per-row washing; the GUI was already
|
||||
multi-line-capable.
|
||||
|
||||
## Bets that held (validation gate)
|
||||
|
||||
- Stale-after-edit linger — closed by `apply_active_edit` marking the
|
||||
store stale.
|
||||
- Invalid-regex incremental states — `foo(` shows `[invalid]`, never
|
||||
panics, recovers on completion.
|
||||
- Multi-line TUI wash — per-row clip at line boundaries, no phantom
|
||||
trailing cell.
|
||||
- GUI key routing — `dispatch_idle` flips during search; the optimistic
|
||||
path round-trips instead of editing the buffer.
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
# Incremental in-buffer search — framing pass
|
||||
|
||||
Date: 2026-06-15. The last deferred GUI item: `SearchMatch` /
|
||||
`SearchMatchActive` decorations exist on the wire (message.rs) and are
|
||||
gated to `None` in both frontends "waiting on a search feature." This
|
||||
builds that feature. Decided up front (user): **incremental isearch**
|
||||
(highlight live as you type, same key steps to next, Enter accepts,
|
||||
Esc/C-g restores origin) with **smart-case substring** matching
|
||||
(case-insensitive unless the query has an uppercase letter).
|
||||
|
||||
## Survey facts (anchors)
|
||||
|
||||
- Greenfield in-buffer search; only `project.search` (cross-file grep)
|
||||
exists. No `Buffer::find` / rope search.
|
||||
- `DiagnosticStore` (diag.rs) is a near-exact template: keyed
|
||||
`Arc<Mutex>` store, sorted entries, `next_after`/`previous_before`,
|
||||
stale tracking, Lua nav bindings, overlay `_attach_view`.
|
||||
- Producer `scoped_decorations` (semantic_render.rs) + TUI
|
||||
`DiagnosticView::render` are the emit/paint templates (viewport clip,
|
||||
line-start cache, stale-skip).
|
||||
- GPU `decoration_kind_to_bg_color` already draws bg decorations
|
||||
through the quad pipeline; SearchMatch/Active just need their arms.
|
||||
- The minibuffer (minibuffer.rs) is keystroke-driven and pseudo-modal
|
||||
(dispatch_minibuffer_key intercepts all keys while active) but has
|
||||
**no live-preview/on_changed hook** — the one missing piece for
|
||||
incremental highlight.
|
||||
|
||||
## Q#SR1 — store shape & ownership
|
||||
|
||||
**Stance: a per-buffer `SearchStore` mirroring `DiagnosticStore`.**
|
||||
`by_buffer: HashMap<BufferId, SearchState>` where `SearchState` holds
|
||||
the resolved query, the sorted `Vec<ByteRange>` matches, and the
|
||||
active index. Shared `Arc<Mutex>`. The active index lives on the store
|
||||
(navigation state), not per-window — v1 accepts that two windows on
|
||||
the same buffer share the active highlight (note it; selection is the
|
||||
per-window concept, search mirrors diagnostics). Edits mark the
|
||||
buffer's entry stale (M11.8 model) so matches at pre-edit byte
|
||||
positions aren't painted until re-search.
|
||||
|
||||
## Q#SR2 — search primitive
|
||||
|
||||
**Stance: smart-case substring over a rope snapshot, regex deferred.**
|
||||
`find_all(haystack, query) -> Vec<ByteRange>`: case-insensitive unless
|
||||
`query` contains an uppercase char (then exact). Built on
|
||||
`snapshot_rope().slice` bytes (the diagnostics path's cheap snapshot).
|
||||
Recomputed on query change; invalidated on edit. No `regex` crate in
|
||||
v1 (literal-text is the 95% case; regex is a later toggle). Overlapping
|
||||
matches: advance past each match's start+1 (standard non-overlapping).
|
||||
|
||||
## Q#SR3 — decoration emission
|
||||
|
||||
**Stance: mirror the diagnostics producer.** In `scoped_decorations`,
|
||||
read the search store for the viewport buffer, emit `SearchMatch` for
|
||||
every visible match and `SearchMatchActive` for the active one
|
||||
(emitted last / higher z so it wins the overlap). Reuse the line cache
|
||||
+ `clip_to_viewport`. Stale-skip exactly like diagnostics. TUI gets a
|
||||
`SearchView` overlay (mirrors `DiagnosticView`) painting bg, attached
|
||||
via `pmacs.search._attach_view`.
|
||||
|
||||
## Q#SR4 — colors
|
||||
|
||||
**Stance: a single search palette.** SearchMatch = translucent yellow
|
||||
wash; SearchMatchActive = stronger amber/orange. GPU: the two
|
||||
`decoration_kind_to_bg_color` arms. TUI: reverse-ish colored bg in the
|
||||
`SearchView`. Distinct from selection (blue) and diagnostics
|
||||
(severity).
|
||||
|
||||
## Q#SR5 — input & modality (incremental)
|
||||
|
||||
**Stance: host the query in the minibuffer + a small `on_changed`
|
||||
hook + targeted next/prev interception.** Entry opens a minibuffer
|
||||
search session (prompt `I-search: `); `on_changed` (new optional
|
||||
session callback, fired after each content mutation in
|
||||
dispatch_minibuffer_key) recomputes matches → updates the store →
|
||||
re-decorate. While that session is active, the entry chord again =
|
||||
`search.next`, its shift/`C-r` variant = `search.prev` (control keys,
|
||||
not self-insert, so safe to intercept in the search branch of
|
||||
dispatch_minibuffer_key). `Enter` accepts (close, leave cursor at the
|
||||
active match); `Esc`/`C-g` cancels (close, restore the origin cursor
|
||||
saved at entry, clear the store). Reusing the minibuffer's input
|
||||
editing + prompt avoids reimplementing a modal query line.
|
||||
|
||||
## Q#SR6 — navigation
|
||||
|
||||
**Stance: `search.next`/`search.prev` mirror `diag.next/prev`.**
|
||||
Advance the active index with wrap, move the active window's cursor to
|
||||
the active match start, scroll it into view. The active index drives
|
||||
which match is `SearchMatchActive`. Usable both during the live
|
||||
session and afterward (matches persist until cleared / next search).
|
||||
|
||||
## Binding (proposal, flagged for veto)
|
||||
|
||||
`C-f` → search (CUA "Find"), rebound from `cursor.right`. Consistent
|
||||
with the editor's CUA direction (arrows move; Ctrl+F finds); the
|
||||
Emacs-holdover `C-f = forward-char` is the inconsistent one. Easy to
|
||||
change — call out in validation.
|
||||
|
||||
## Predicted findings (categorical bets)
|
||||
|
||||
1. **Stale-after-edit linger** (the squiggle lesson again): matches at
|
||||
pre-edit byte positions paint over shifted text until re-search —
|
||||
the store's stale gate + re-search-on-change must be right, or
|
||||
highlights drift during typing.
|
||||
2. **Minibuffer `on_changed` × completion**: the hook interacts with
|
||||
the existing per-keystroke candidate recompute; the search session
|
||||
must opt out of completion cleanly (a session "kind" seam).
|
||||
3. **Per-buffer active match across windows** surfaces as navigating
|
||||
in one window moving the active highlight in another — accepted for
|
||||
v1, but worth eyeballing.
|
||||
4. **Empty / all-match queries**: empty query → no matches (not all);
|
||||
a 1-char common letter → many matches → viewport-clipped emission
|
||||
must stay cheap (line cache + only-visible).
|
||||
|
||||
## Session plan
|
||||
|
||||
Three green commits:
|
||||
1. Core `SearchStore` + `find_all` smart-case primitive + unit tests.
|
||||
2. Producer emission + GPU bg colors + TUI `SearchView` + attach.
|
||||
3. Incremental UX: minibuffer `on_changed`, search session,
|
||||
next/prev interception + commands, cancel-restores-origin, binding.
|
||||
|
||||
Manual validation gate as usual (type to highlight live, step matches,
|
||||
edit mid-search, Esc restores).
|
||||
|
|
@ -551,14 +551,16 @@ struct StatusFactsLocal {
|
|||
diag_warnings: u32,
|
||||
}
|
||||
|
||||
/// The live incremental-search prompt (Q#SR5, protocol v9), mirrored
|
||||
/// from a `SearchPrompt` message whose `query` was `Some`.
|
||||
/// The live incremental-search prompt (Q#SR5/Q#RX6, protocol v10),
|
||||
/// mirrored from a `SearchPrompt` message whose `query` was `Some`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct SearchPromptLocal {
|
||||
buffer_id: BufferId,
|
||||
query: String,
|
||||
active: Option<u32>,
|
||||
total: u32,
|
||||
regex: bool,
|
||||
invalid: bool,
|
||||
}
|
||||
|
||||
/// pmacs-gpu's own cursor position, mirrored from `CursorByte`.
|
||||
|
|
@ -2097,23 +2099,27 @@ impl State {
|
|||
self.window.request_redraw();
|
||||
None
|
||||
}
|
||||
// Q#SR5 — the live isearch prompt (protocol v9). `query:
|
||||
// None` clears the band (search ended); `Some` shows
|
||||
// `I-search: <query> (n/m)` on the band's left side. The
|
||||
// matches themselves arrive as SearchMatch decorations and
|
||||
// the keys round-trip via the DispatchIdle gate, so this
|
||||
// Q#SR5 / Q#RX6 — the live isearch prompt (protocol v10).
|
||||
// `query: None` clears the band (search ended); `Some` shows
|
||||
// `[Regex] I-search: <query> (n/m)` on the band's left side.
|
||||
// The matches themselves arrive as SearchMatch decorations
|
||||
// and the keys round-trip via the intercept gate, so this
|
||||
// handler only drives the prompt text.
|
||||
InstanceMessage::SearchPrompt {
|
||||
buffer_id,
|
||||
query,
|
||||
active,
|
||||
total,
|
||||
regex,
|
||||
invalid,
|
||||
} => {
|
||||
self.search_prompt = query.map(|q| SearchPromptLocal {
|
||||
buffer_id,
|
||||
query: q,
|
||||
active,
|
||||
total,
|
||||
regex,
|
||||
invalid,
|
||||
});
|
||||
self.window.request_redraw();
|
||||
None
|
||||
|
|
@ -2628,14 +2634,21 @@ impl State {
|
|||
.as_ref()
|
||||
.filter(|s| Some(s.buffer_id) == self.current_buffer_id)
|
||||
{
|
||||
let label = if sp.regex {
|
||||
"Regex I-search: "
|
||||
} else {
|
||||
"I-search: "
|
||||
};
|
||||
let count = if sp.query.is_empty() {
|
||||
String::new()
|
||||
} else if sp.invalid {
|
||||
" [invalid]".to_string()
|
||||
} else if sp.total == 0 {
|
||||
" [no match]".to_string()
|
||||
} else {
|
||||
format!(" ({}/{})", sp.active.map_or(0, |a| a + 1), sp.total)
|
||||
};
|
||||
return format!("I-search: {}{}", sp.query, count);
|
||||
return format!("{}{}{}", label, sp.query, count);
|
||||
}
|
||||
match self
|
||||
.status_facts
|
||||
|
|
@ -4126,12 +4139,14 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool {
|
|||
)
|
||||
}
|
||||
|
||||
/// `C-s` / `C-r` — the chords that begin an incremental search
|
||||
/// (Q#SR5). Forwarded even when idle (they are otherwise withheld as
|
||||
/// command chords by [`should_forward_key`]) so a search can start;
|
||||
/// once it is running every key round-trips via the intercept path.
|
||||
/// The chords that begin an incremental search: `C-s` / `C-r` (literal)
|
||||
/// and `C-M-s` / `C-M-r` (regex, Q#RX5). Forwarded even when idle (they
|
||||
/// are otherwise withheld as command chords by [`should_forward_key`])
|
||||
/// so a search can start; once it is running every key round-trips via
|
||||
/// the intercept path (including `M-r`, the regex toggle).
|
||||
fn is_search_entry_chord(key: ProtocolKey, mods: Modifiers) -> bool {
|
||||
mods == Modifiers::CTRL && matches!(key, ProtocolKey::Char('s' | 'r'))
|
||||
matches!(key, ProtocolKey::Char('s' | 'r'))
|
||||
&& (mods == Modifiers::CTRL || mods == Modifiers::CTRL | Modifiers::ALT)
|
||||
}
|
||||
|
||||
fn is_plain_text_modifiers(mods: Modifiers) -> bool {
|
||||
|
|
@ -5175,9 +5190,11 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn search_entry_chord_is_ctrl_s_or_ctrl_r_only() {
|
||||
// C-s / C-r start a search (Q#SR5) — forwarded even though
|
||||
// `should_forward_key` withholds them as command chords.
|
||||
fn search_entry_chord_is_ctrl_or_ctrl_alt_s_r() {
|
||||
// C-s / C-r (literal) and C-M-s / C-M-r (regex, Q#RX5) start a
|
||||
// search — forwarded even though `should_forward_key` withholds
|
||||
// them as command chords.
|
||||
let ctrl_alt = Modifiers::CTRL | Modifiers::ALT;
|
||||
assert!(is_search_entry_chord(
|
||||
ProtocolKey::Char('s'),
|
||||
Modifiers::CTRL
|
||||
|
|
@ -5186,11 +5203,13 @@ mod tests {
|
|||
ProtocolKey::Char('r'),
|
||||
Modifiers::CTRL
|
||||
));
|
||||
assert!(is_search_entry_chord(ProtocolKey::Char('s'), ctrl_alt));
|
||||
assert!(is_search_entry_chord(ProtocolKey::Char('r'), ctrl_alt));
|
||||
assert!(
|
||||
!should_forward_key(ProtocolKey::Char('s'), Modifiers::CTRL),
|
||||
"C-s is otherwise a withheld chord; the search-entry path is what forwards it"
|
||||
);
|
||||
// Other Ctrl chords, and C-s without Ctrl, are not entry chords.
|
||||
// Other Ctrl chords, and s/r without Ctrl, are not entry chords.
|
||||
assert!(!is_search_entry_chord(
|
||||
ProtocolKey::Char('x'),
|
||||
Modifiers::CTRL
|
||||
|
|
@ -5201,7 +5220,7 @@ mod tests {
|
|||
));
|
||||
assert!(!is_search_entry_chord(
|
||||
ProtocolKey::Char('s'),
|
||||
Modifiers::CTRL | Modifiers::ALT
|
||||
Modifiers::ALT
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -823,9 +823,11 @@ pub enum InstanceMessage {
|
|||
/// `None` means no search is running — the frontend hides the band.
|
||||
///
|
||||
/// Emitted by the semantic producer when the search state changes
|
||||
/// (cached-compare suppressed, like [`Self::StatusFacts`]). Kept
|
||||
/// off wires negotiated `< 9` by the daemon's per-session filter
|
||||
/// (additive variant — an older peer would hard-error decoding it).
|
||||
/// (cached-compare suppressed, like [`Self::StatusFacts`]). The
|
||||
/// `regex` / `invalid` fields (Q#RX6) changed this variant's
|
||||
/// encoding, so the daemon's per-session filter now keeps it off
|
||||
/// wires negotiated `< 10` (was `< 9` for the original four-field
|
||||
/// shape — see `PROTOCOL_VERSION`).
|
||||
SearchPrompt {
|
||||
/// Buffer the search is anchored in (the active buffer).
|
||||
buffer_id: crate::BufferId,
|
||||
|
|
@ -837,6 +839,13 @@ pub enum InstanceMessage {
|
|||
active: Option<u32>,
|
||||
/// Total number of matches for the current query.
|
||||
total: u32,
|
||||
/// `true` when the search is in regex mode (Q#RX3) — the
|
||||
/// frontend prefixes the prompt with `Regex `.
|
||||
regex: bool,
|
||||
/// `true` when a regex pattern failed to compile — the frontend
|
||||
/// shows `[invalid]` instead of a match count. Always `false`
|
||||
/// in literal mode.
|
||||
invalid: bool,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1098,7 +1107,13 @@ pub enum ResourceBody {
|
|||
/// Q#SR5 (incremental search): bumped from 8 to 9 for
|
||||
/// [`InstanceMessage::SearchPrompt`]. Additive and daemon-gated per
|
||||
/// session, identical shape to the `StatusFacts` (v8) bump.
|
||||
pub const PROTOCOL_VERSION: u32 = 9;
|
||||
///
|
||||
/// Q#RX6 (regex search): bumped from 9 to 10 — `SearchPrompt` gained
|
||||
/// `regex` / `invalid` fields, changing that variant's postcard
|
||||
/// encoding. Still daemon-gated per session (now at `< 10`); a v9 peer
|
||||
/// negotiates v9 and simply receives no `SearchPrompt` (the decorations
|
||||
/// still highlight), rather than mis-decoding the wider shape.
|
||||
pub const PROTOCOL_VERSION: u32 = 10;
|
||||
|
||||
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
|
||||
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
|
||||
|
|
@ -1141,7 +1156,12 @@ pub const PROTOCOL_VERSION: u32 = 9;
|
|||
///
|
||||
/// Q#SR5: extended to `[6, 7, 8, 9]`. `InstanceMessage::SearchPrompt`
|
||||
/// is additive and daemon-gated per session.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9];
|
||||
///
|
||||
/// Q#RX6: extended to `[6, 7, 8, 9, 10]`. `SearchPrompt` gained
|
||||
/// `regex` / `invalid` (encoding change to that variant); v9 and v10
|
||||
/// interoperate because the variant is daemon-gated per session, so a
|
||||
/// v9 peer is simply never sent the wider shape.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10];
|
||||
|
||||
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||||
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
|
||||
|
|
|
|||
|
|
@ -1021,11 +1021,13 @@ fn dispatcher_loop(
|
|||
let peer_knows_status_facts = session_registry
|
||||
.session_state(*fid)
|
||||
.is_some_and(|s| s.negotiated_protocol_version >= 8);
|
||||
// Q#SR5 — `SearchPrompt` is a v9 variant; gate it the
|
||||
// same way so an < 9 peer never sees the new shape.
|
||||
// Q#SR5 / Q#RX6 — `SearchPrompt` gained regex/invalid
|
||||
// fields in v10 (encoding change); gate at >= 10 so a v9
|
||||
// peer is sent no SearchPrompt rather than the wider
|
||||
// shape it would mis-decode.
|
||||
let peer_knows_search_prompt = session_registry
|
||||
.session_state(*fid)
|
||||
.is_some_and(|s| s.negotiated_protocol_version >= 9);
|
||||
.is_some_and(|s| s.negotiated_protocol_version >= 10);
|
||||
for msg in &messages {
|
||||
if !peer_knows_status_facts
|
||||
&& matches!(msg, InstanceMessage::StatusFacts { .. })
|
||||
|
|
|
|||
|
|
@ -641,6 +641,7 @@ impl EditorState {
|
|||
/// * `RET` --- accept (keep cursor + highlights).
|
||||
/// * `C-g` / `Esc` --- cancel (restore origin cursor).
|
||||
/// * `BS` --- shorten the query by one char.
|
||||
/// * `M-r` --- toggle literal ↔ regex (Q#RX3).
|
||||
/// * a printable char --- extend the query.
|
||||
///
|
||||
/// Unrecognized chords are swallowed (an active isearch eats every
|
||||
|
|
@ -654,6 +655,7 @@ impl EditorState {
|
|||
SearchKey::Accept => self.core.borrow_mut().search_finish(true),
|
||||
SearchKey::Cancel => self.core.borrow_mut().search_finish(false),
|
||||
SearchKey::Backspace => self.core.borrow_mut().search_backspace(),
|
||||
SearchKey::ToggleRegex => self.core.borrow_mut().search_toggle_regex(),
|
||||
SearchKey::Insert(ch) => self.core.borrow_mut().search_input_char(ch),
|
||||
SearchKey::Ignore => {}
|
||||
}
|
||||
|
|
@ -1221,6 +1223,8 @@ enum SearchKey {
|
|||
Cancel,
|
||||
/// Shorten the query by one character (BS).
|
||||
Backspace,
|
||||
/// Toggle literal ↔ regex matching (M-r; Q#RX3).
|
||||
ToggleRegex,
|
||||
/// Extend the query with a printable character.
|
||||
Insert(char),
|
||||
/// Unhandled --- swallowed without complaint.
|
||||
|
|
@ -1260,6 +1264,14 @@ impl SearchKey {
|
|||
_ => Self::Ignore,
|
||||
};
|
||||
}
|
||||
// M-r toggles regex mode (Q#RX3). Alt-only chord, distinct from
|
||||
// the C-r (previous-match) above.
|
||||
if alt
|
||||
&& !ctrl
|
||||
&& let KeyCode::Char('r') = chord.code
|
||||
{
|
||||
return Self::ToggleRegex;
|
||||
}
|
||||
Self::Ignore
|
||||
}
|
||||
}
|
||||
|
|
@ -1726,24 +1738,28 @@ fn paint_minibuffer(
|
|||
|
||||
/// Paint the incremental-search prompt on the bottom row:
|
||||
/// `I-search: <query> (n/m)`. Backward searches read `I-search
|
||||
/// backward:`; a non-empty query with no matches reads `[no match]`.
|
||||
/// Overwrites the status line painted just before it. The terminal
|
||||
/// cursor is *not* returned here — it stays in the buffer at the
|
||||
/// active match (see [`paint_frame`]).
|
||||
/// backward:`; regex searches prefix `Regex `; a non-empty query with
|
||||
/// no matches reads `[no match]`, and an uncompilable regex reads
|
||||
/// `[invalid]`. Overwrites the status line painted just before it. The
|
||||
/// terminal cursor is *not* returned here — it stays in the buffer at
|
||||
/// the active match (see [`paint_frame`]).
|
||||
fn paint_search_prompt(
|
||||
grid: &mut crate::cell::CellGrid<'_>,
|
||||
core: &EditorCore,
|
||||
term_size: crate::cell::CellSize,
|
||||
) {
|
||||
let prompt = if core.search_forward() {
|
||||
"I-search: "
|
||||
} else {
|
||||
"I-search backward: "
|
||||
let prompt = match (core.search_is_regex(), core.search_forward()) {
|
||||
(false, true) => "I-search: ",
|
||||
(false, false) => "I-search backward: ",
|
||||
(true, true) => "Regex I-search: ",
|
||||
(true, false) => "Regex I-search backward: ",
|
||||
};
|
||||
let query = core.search_query();
|
||||
let (active, total) = core.search_match_summary();
|
||||
let suffix = if query.is_empty() {
|
||||
String::new()
|
||||
} else if core.search_is_invalid() {
|
||||
" [invalid]".to_string()
|
||||
} else if total == 0 {
|
||||
" [no match]".to_string()
|
||||
} else {
|
||||
|
|
@ -2106,6 +2122,40 @@ mod tests {
|
|||
assert_eq!(s.core.borrow().search_query(), "foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_isearch_via_dispatch_c_m_s() {
|
||||
let mut s = fresh_with(b"a1 b2 c3");
|
||||
s.core.borrow_mut().active_window_mut().cursor = 0;
|
||||
// C-M-s starts a regex search (search.forward-regex).
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(
|
||||
KeyCode::Char('s'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||||
),
|
||||
);
|
||||
assert!(s.core.borrow().search_active());
|
||||
assert!(s.core.borrow().search_is_regex());
|
||||
type_chars(&mut s, r"\d");
|
||||
assert_eq!(s.core.borrow().search_match_summary().1, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m_r_toggles_regex_mid_search() {
|
||||
let mut s = fresh_with(b"a.b axb");
|
||||
s.core.borrow_mut().active_window_mut().cursor = 0;
|
||||
s.dispatch_key(FrontendId::LOCAL, ctrl('s')); // literal
|
||||
type_chars(&mut s, "a.b");
|
||||
assert_eq!(s.core.borrow().search_match_summary().1, 1);
|
||||
// M-r toggles to regex (intercepted in dispatch_search_key).
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char('r'), KeyModifiers::ALT),
|
||||
);
|
||||
assert!(s.core.borrow().search_is_regex());
|
||||
assert_eq!(s.core.borrow().search_match_summary().1, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isearch_accumulates_across_renders_like_run_loop() {
|
||||
// Reproduce the real run loop: a render between every keystroke
|
||||
|
|
|
|||
|
|
@ -78,6 +78,14 @@ pub struct SearchSession {
|
|||
/// Drives the prompt label ("I-search" vs "I-search backward")
|
||||
/// and the wrap direction of an empty-query repeat.
|
||||
forward: bool,
|
||||
/// Whether the query is a regex (Q#RX3). `false` = smart-case
|
||||
/// substring (`find_all`); `true` = smart-case regex
|
||||
/// (`find_all_regex`). Toggled live by `M-r`.
|
||||
regex: bool,
|
||||
/// `true` when the last recompute's regex pattern failed to compile
|
||||
/// — the prompt shows `[invalid]` instead of a match count. Always
|
||||
/// `false` in literal mode (substring never "fails to compile").
|
||||
invalid: bool,
|
||||
}
|
||||
|
||||
/// The world state mutated by editor commands.
|
||||
|
|
@ -536,6 +544,21 @@ impl EditorCore {
|
|||
self.search.as_ref().is_none_or(|s| s.forward)
|
||||
}
|
||||
|
||||
/// `true` iff the active search is in regex mode (Q#RX3). `false`
|
||||
/// for literal substring, or when no search is running.
|
||||
#[must_use]
|
||||
pub fn search_is_regex(&self) -> bool {
|
||||
self.search.as_ref().is_some_and(|s| s.regex)
|
||||
}
|
||||
|
||||
/// `true` iff the active regex search's pattern failed to compile —
|
||||
/// the prompt shows `[invalid]` rather than a match count. Always
|
||||
/// `false` in literal mode / when no search is running.
|
||||
#[must_use]
|
||||
pub fn search_is_invalid(&self) -> bool {
|
||||
self.search.as_ref().is_some_and(|s| s.invalid)
|
||||
}
|
||||
|
||||
/// `(active_index, total)` for the active buffer's matches, for the
|
||||
/// prompt's "n/m" readout. `active_index` is 0-based and `None`
|
||||
/// when there are no matches.
|
||||
|
|
@ -552,11 +575,12 @@ impl EditorCore {
|
|||
}
|
||||
|
||||
/// Begin an incremental search anchored at the active buffer +
|
||||
/// cursor. `forward` sets the initial step direction. A no-op if a
|
||||
/// search is already running (the entry chord is intercepted while
|
||||
/// active, so this is only reached from an inactive state — the
|
||||
/// guard is belt-and-suspenders).
|
||||
pub fn search_begin(&mut self, forward: bool) {
|
||||
/// cursor. `forward` sets the initial step direction; `regex`
|
||||
/// selects regex (`true`) vs literal substring (`false`) matching.
|
||||
/// A no-op if a search is already running (the entry chord is
|
||||
/// intercepted while active, so this is only reached from an
|
||||
/// inactive state — the guard is belt-and-suspenders).
|
||||
pub fn search_begin(&mut self, forward: bool, regex: bool) {
|
||||
if self.search.is_some() {
|
||||
return;
|
||||
}
|
||||
|
|
@ -571,9 +595,22 @@ impl EditorCore {
|
|||
query: String::new(),
|
||||
origin,
|
||||
forward,
|
||||
regex,
|
||||
invalid: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggle the active search between literal and regex matching
|
||||
/// (Q#RX3, `M-r`), re-running the current query in the new mode. A
|
||||
/// no-op when no search is running.
|
||||
pub fn search_toggle_regex(&mut self) {
|
||||
let Some(session) = self.search.as_mut() else {
|
||||
return;
|
||||
};
|
||||
session.regex = !session.regex;
|
||||
self.search_recompute();
|
||||
}
|
||||
|
||||
/// Ensure the active window carries a [`crate::search::SearchView`]
|
||||
/// overlay, attaching one if absent (deduped by overlay kind). The
|
||||
/// view reads the per-buffer [`Self::search_store`] keyed on the
|
||||
|
|
@ -617,8 +654,23 @@ impl EditorCore {
|
|||
let bid = session.origin.0;
|
||||
let origin_byte = session.origin.1;
|
||||
let query = session.query.clone();
|
||||
let regex = session.regex;
|
||||
let bytes = self.buffer_bytes(bid);
|
||||
let matches = crate::search::find_all(&bytes, &query);
|
||||
// Regex: `None` ⇒ the pattern won't compile (mark invalid, drop
|
||||
// matches). Literal substring never fails. An invalid pattern
|
||||
// clears the store (no stale matches paint) and shows
|
||||
// `[invalid]` via the prompt.
|
||||
let (matches, invalid) = if regex {
|
||||
match crate::search::find_all_regex(&bytes, &query) {
|
||||
Some(m) => (m, false),
|
||||
None => (Vec::new(), true),
|
||||
}
|
||||
} else {
|
||||
(crate::search::find_all(&bytes, &query), false)
|
||||
};
|
||||
if let Some(session) = self.search.as_mut() {
|
||||
session.invalid = invalid;
|
||||
}
|
||||
let focus = {
|
||||
let mut guard = self
|
||||
.search_store
|
||||
|
|
@ -2422,7 +2474,7 @@ mod tests {
|
|||
let mut s = from_bytes(b"foo bar foo baz foo");
|
||||
let bid = s.active_buffer_id();
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
assert!(s.search_active());
|
||||
type_query(&mut s, "foo");
|
||||
// Three matches: 0..3, 8..11, 16..19; first (at/after origin 0)
|
||||
|
|
@ -2438,7 +2490,7 @@ mod tests {
|
|||
fn search_step_walks_matches_and_wraps() {
|
||||
let mut s = from_bytes(b"foo bar foo baz foo");
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
assert_eq!(s.cursor(), 0);
|
||||
s.search_step(true);
|
||||
|
|
@ -2455,7 +2507,7 @@ mod tests {
|
|||
fn search_focuses_first_match_at_or_after_origin() {
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
s.active_window_mut().cursor = 5; // inside "bar"
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
// First match with start >= 5 is the one at byte 8.
|
||||
assert_eq!(s.cursor(), 8);
|
||||
|
|
@ -2467,7 +2519,7 @@ mod tests {
|
|||
let mut s = from_bytes(b"foo bar foo");
|
||||
let bid = s.active_buffer_id();
|
||||
s.active_window_mut().cursor = 5;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
assert_eq!(s.cursor(), 8);
|
||||
s.search_finish(false); // cancel
|
||||
|
|
@ -2488,7 +2540,7 @@ mod tests {
|
|||
let mut s = from_bytes(b"foo bar foo");
|
||||
let bid = s.active_buffer_id();
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
s.search_step(true); // focus the match at byte 8
|
||||
assert_eq!(s.cursor(), 8);
|
||||
|
|
@ -2509,7 +2561,7 @@ mod tests {
|
|||
fn search_backspace_widens_the_match_set() {
|
||||
let mut s = from_bytes(b"fo foo food");
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo"); // matches "foo" at 3..6, 7..10
|
||||
assert_eq!(s.search_match_summary().1, 2);
|
||||
s.search_backspace(); // query "fo"
|
||||
|
|
@ -2521,7 +2573,7 @@ mod tests {
|
|||
fn search_smart_case_is_case_sensitive_with_uppercase() {
|
||||
let mut s = from_bytes(b"Foo foo FOO");
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "Foo"); // uppercase => case-sensitive
|
||||
assert_eq!(s.search_match_summary().1, 1);
|
||||
s.search_backspace();
|
||||
|
|
@ -2536,7 +2588,7 @@ mod tests {
|
|||
let mut s = from_bytes(b"foo foo");
|
||||
let bid = s.active_buffer_id();
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
s.search_finish(true); // matches persist after accept
|
||||
assert!(!s.search_store.lock().expect("store").is_stale(bid));
|
||||
|
|
@ -2546,4 +2598,50 @@ mod tests {
|
|||
"an edit marks the buffer's matches stale (linger fix)"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- regex search (Q#RX3) ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn regex_search_matches_pattern() {
|
||||
let mut s = from_bytes(b"a1 b2 c3");
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true, true);
|
||||
assert!(s.search_is_regex());
|
||||
type_query(&mut s, r"\d");
|
||||
assert_eq!(s.search_match_summary().1, 3, "\\d matches 1, 2, 3");
|
||||
assert!(!s.search_is_invalid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_invalid_pattern_flags_and_recovers() {
|
||||
let mut s = from_bytes(b"foo");
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true, true);
|
||||
type_query(&mut s, "fo("); // unbalanced group mid-typing
|
||||
assert!(s.search_is_invalid(), "incomplete group is invalid");
|
||||
assert_eq!(s.search_match_summary().1, 0, "invalid ⇒ no matches");
|
||||
type_query(&mut s, "o)"); // completes the group: regex fo(o) → "foo"
|
||||
assert!(!s.search_is_invalid(), "valid pattern recovers");
|
||||
assert_eq!(s.search_match_summary().1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_regex_reinterprets_the_query() {
|
||||
let mut s = from_bytes(b"a.b axb");
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true, false); // literal
|
||||
type_query(&mut s, "a.b");
|
||||
assert!(!s.search_is_regex());
|
||||
assert_eq!(
|
||||
s.search_match_summary().1,
|
||||
1,
|
||||
"literal '.' matches only a.b"
|
||||
);
|
||||
s.search_toggle_regex(); // → regex
|
||||
assert!(s.search_is_regex());
|
||||
assert_eq!(s.search_match_summary().1, 2, "regex '.' also matches axb");
|
||||
s.search_toggle_regex(); // back to literal
|
||||
assert!(!s.search_is_regex());
|
||||
assert_eq!(s.search_match_summary().1, 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11439,13 +11439,14 @@ fn install_history(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
|
|||
/// post-accept navigation commands can begin / step a search from Lua.
|
||||
fn install_search(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result<()> {
|
||||
{
|
||||
// search_start(forward): begin an isearch in the given
|
||||
// direction, anchored at the active buffer + cursor.
|
||||
// search_start(forward, regex): begin an isearch in the given
|
||||
// direction, anchored at the active buffer + cursor. `regex`
|
||||
// selects regex vs literal substring matching (Q#RX3).
|
||||
let cc = core.clone();
|
||||
editor.set(
|
||||
"search_start",
|
||||
lua.create_function(move |_, forward: bool| {
|
||||
cc.borrow_mut().search_begin(forward);
|
||||
lua.create_function(move |_, (forward, regex): (bool, bool)| {
|
||||
cc.borrow_mut().search_begin(forward, regex);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
|
|
|||
|
|
@ -1683,7 +1683,7 @@ mod tests {
|
|||
// --- M5.5a handshake & postcard round-trips ---
|
||||
|
||||
#[test]
|
||||
fn protocol_version_is_nine_for_search_prompt() {
|
||||
fn protocol_version_is_ten_for_regex_search_prompt() {
|
||||
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
||||
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
||||
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
||||
|
|
@ -1696,7 +1696,9 @@ mod tests {
|
|||
// Q#S1 bumped 7→8 (`InstanceMessage::StatusFacts`, additive
|
||||
// + daemon-gated per session). Q#SR5 bumped 8→9
|
||||
// (`InstanceMessage::SearchPrompt`, additive + daemon-gated).
|
||||
assert_eq!(PROTOCOL_VERSION, 9);
|
||||
// Q#RX6 bumped 9→10 (`SearchPrompt` gained regex/invalid;
|
||||
// encoding change to that variant, still daemon-gated).
|
||||
assert_eq!(PROTOCOL_VERSION, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1705,18 +1707,19 @@ mod tests {
|
|||
// every cell-carrying message, ending the v1–v5 ladder —
|
||||
// pre-v6 peers are refused at the handshake (a clean
|
||||
// VersionMismatch) rather than garbling postcard mid-session.
|
||||
// Q#M4 / Q#S1 / Q#SR5: the ladder resumes above that floor — v7
|
||||
// (`TripleDown`, frontend-gated), v8 (`StatusFacts`) and v9
|
||||
// (`SearchPrompt`, both daemon-gated) are additive, so v6
|
||||
// through v9 interoperate.
|
||||
// Q#M4 / Q#S1 / Q#SR5 / Q#RX6: the ladder resumes above that
|
||||
// floor — v7 (`TripleDown`, frontend-gated), v8 (`StatusFacts`),
|
||||
// v9 + v10 (`SearchPrompt` and its regex/invalid extension, both
|
||||
// daemon-gated) interoperate, so v6 through v10 talk.
|
||||
assert!(is_supported_protocol_version(6));
|
||||
assert!(is_supported_protocol_version(7));
|
||||
assert!(is_supported_protocol_version(8));
|
||||
assert!(is_supported_protocol_version(9));
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 10, u32::MAX] {
|
||||
assert!(is_supported_protocol_version(10));
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 11, u32::MAX] {
|
||||
assert!(
|
||||
!is_supported_protocol_version(rejected),
|
||||
"v{rejected} must be rejected by a v9 binary"
|
||||
"v{rejected} must be rejected by a v10 binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1882,20 +1885,24 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn search_prompt_round_trips_through_postcard() {
|
||||
// Q#SR5 — the v9 wire variant. Cover the three shapes the
|
||||
// producer emits: an active search with matches, a failing
|
||||
// search (active=None), and a cleared band (query=None).
|
||||
// Q#SR5 / Q#RX6 — the v10 wire variant. Cover the shapes the
|
||||
// producer emits: an active literal search with matches, an
|
||||
// invalid regex (regex=true, invalid=true, no matches), a regex
|
||||
// with matches, and a cleared band (query=None).
|
||||
let cases = [
|
||||
(Some("foo".to_owned()), Some(2u32), 5u32),
|
||||
(Some("zzz".to_owned()), None, 0u32),
|
||||
(None, None, 0u32),
|
||||
(Some("foo".to_owned()), Some(2u32), 5u32, false, false),
|
||||
(Some("foo(".to_owned()), None, 0u32, true, true),
|
||||
(Some(r"\d".to_owned()), Some(0u32), 3u32, true, false),
|
||||
(None, None, 0u32, false, false),
|
||||
];
|
||||
for (query, active, total) in cases {
|
||||
for (query, active, total, regex, invalid) in cases {
|
||||
let msg = InstanceMessage::SearchPrompt {
|
||||
buffer_id: crate::buffer::BufferId::next(),
|
||||
query: query.clone(),
|
||||
active,
|
||||
total,
|
||||
regex,
|
||||
invalid,
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||||
|
|
@ -1904,11 +1911,15 @@ mod tests {
|
|||
query: q,
|
||||
active: a,
|
||||
total: t,
|
||||
regex: rx,
|
||||
invalid: inv,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(q, query);
|
||||
assert_eq!(a, active);
|
||||
assert_eq!(t, total);
|
||||
assert_eq!(rx, regex);
|
||||
assert_eq!(inv, invalid);
|
||||
}
|
||||
other => panic!("expected SearchPrompt, got {other:?}"),
|
||||
}
|
||||
|
|
|
|||
246
src/search.rs
246
src/search.rs
|
|
@ -228,6 +228,51 @@ pub fn find_all(haystack: &[u8], query: &str) -> Vec<ByteRange> {
|
|||
out
|
||||
}
|
||||
|
||||
/// Smart-case regex search over `haystack` bytes for `pattern` (Q#RX1).
|
||||
///
|
||||
/// Returns `Some(matches)` — leftmost, non-overlapping, ascending byte
|
||||
/// ranges — for a valid pattern (possibly empty), or `None` when
|
||||
/// `pattern` fails to compile. The `Option` lets the caller tell an
|
||||
/// *invalid* pattern (show `[invalid]`) apart from a valid one with no
|
||||
/// matches (a failing search), which a flat `Vec` could not.
|
||||
///
|
||||
/// **Smart-case (Q#RX2).** Case-insensitive unless `pattern` contains
|
||||
/// an uppercase letter, applied by compiling `(?i)` ahead of the
|
||||
/// pattern. An uppercase letter inside an escape or class (`\D`,
|
||||
/// `[A-Z]`) trips case-sensitivity — the same coarse rule the literal
|
||||
/// path uses.
|
||||
///
|
||||
/// **Multi-line (Q#RX1)** falls out for free: the regex runs over the
|
||||
/// whole byte slice, so an explicit `\n` (or `(?s).`) spans lines. `.`
|
||||
/// keeps its default of not matching `\n`.
|
||||
///
|
||||
/// **Zero-width matches** (`a*`, `^`, `$`, anchors) are filtered — they
|
||||
/// wash nothing and would otherwise flood the match list. The `regex`
|
||||
/// crate's linear-time engine makes a pathological pattern slow at
|
||||
/// worst, never catastrophic.
|
||||
#[must_use]
|
||||
pub fn find_all_regex(haystack: &[u8], pattern: &str) -> Option<Vec<ByteRange>> {
|
||||
if pattern.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let case_insensitive = !pattern.chars().any(char::is_uppercase);
|
||||
let re = if case_insensitive {
|
||||
regex::bytes::Regex::new(&format!("(?i){pattern}"))
|
||||
} else {
|
||||
regex::bytes::Regex::new(pattern)
|
||||
}
|
||||
.ok()?;
|
||||
let matches = re
|
||||
.find_iter(haystack)
|
||||
.filter(|m| m.end() > m.start())
|
||||
.map(|m| ByteRange {
|
||||
start: m.start() as u64,
|
||||
end: m.end() as u64,
|
||||
})
|
||||
.collect();
|
||||
Some(matches)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TUI view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -318,45 +363,65 @@ impl View for SearchView {
|
|||
let cell_origin = viewport.cell_origin;
|
||||
|
||||
for m in &matches {
|
||||
let line = crate::diag::line_at_offset(&line_offsets, m.start as u32);
|
||||
if line < start_line_buf {
|
||||
continue;
|
||||
}
|
||||
let row_offset = line - start_line_buf;
|
||||
if row_offset >= max_rows {
|
||||
break;
|
||||
}
|
||||
let line_start = line_offsets[line as usize];
|
||||
let line_end = line_offsets
|
||||
.get(line as usize + 1)
|
||||
.copied()
|
||||
.unwrap_or(source.len() as u32);
|
||||
let line_end_no_nl = if line_end > line_start
|
||||
&& source.get(line_end as usize - 1).copied() == Some(b'\n')
|
||||
{
|
||||
line_end - 1
|
||||
} else {
|
||||
line_end
|
||||
};
|
||||
let line_bytes = &source[line_start as usize..line_end_no_nl as usize];
|
||||
let within_start = (m.start as u32).saturating_sub(line_start) as usize;
|
||||
let within_end = (m.end as u32).saturating_sub(line_start) as usize;
|
||||
let (start_col, end_col) =
|
||||
crate::diag::byte_range_to_display_cols(line_bytes, within_start, within_end);
|
||||
if end_col <= start_col {
|
||||
continue;
|
||||
}
|
||||
let style = if Some(*m) == active {
|
||||
active_match_style()
|
||||
} else {
|
||||
match_style()
|
||||
};
|
||||
let cell_row = cell_origin.row + row_offset;
|
||||
let clamped_start = start_col.min(max_cols);
|
||||
let clamped_end = end_col.min(max_cols);
|
||||
for col in clamped_start..clamped_end {
|
||||
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
|
||||
cell.style = merge_styles(cell.style, style);
|
||||
// A regex match may span multiple lines (Q#RX4); wash each
|
||||
// row's clipped slice, mirroring the selection renderer.
|
||||
// Single-line matches (every literal match) touch one row.
|
||||
let first_line = crate::diag::line_at_offset(&line_offsets, m.start as u32);
|
||||
// Matches are sorted ascending, so once one starts below the
|
||||
// viewport every later one does too — stop.
|
||||
if first_line >= start_line_buf.saturating_add(max_rows) {
|
||||
break;
|
||||
}
|
||||
let last_byte = m.end.saturating_sub(1).max(m.start) as u32;
|
||||
let last_line = crate::diag::line_at_offset(&line_offsets, last_byte);
|
||||
for line in first_line..=last_line {
|
||||
if line < start_line_buf {
|
||||
continue;
|
||||
}
|
||||
let row_offset = line - start_line_buf;
|
||||
if row_offset >= max_rows {
|
||||
break;
|
||||
}
|
||||
let line_start = line_offsets[line as usize];
|
||||
let line_end = line_offsets
|
||||
.get(line as usize + 1)
|
||||
.copied()
|
||||
.unwrap_or(source.len() as u32);
|
||||
let line_end_no_nl = if line_end > line_start
|
||||
&& source.get(line_end as usize - 1).copied() == Some(b'\n')
|
||||
{
|
||||
line_end - 1
|
||||
} else {
|
||||
line_end
|
||||
};
|
||||
// Clip the match to this line's content (newline excluded
|
||||
// so a multi-line match doesn't wash a phantom trailing
|
||||
// cell).
|
||||
let paint_start = (m.start as u32).max(line_start);
|
||||
let paint_end = (m.end as u32).min(line_end_no_nl);
|
||||
if paint_start >= paint_end {
|
||||
continue;
|
||||
}
|
||||
let line_bytes = &source[line_start as usize..line_end_no_nl as usize];
|
||||
let within_start = (paint_start - line_start) as usize;
|
||||
let within_end = (paint_end - line_start) as usize;
|
||||
let (start_col, end_col) =
|
||||
crate::diag::byte_range_to_display_cols(line_bytes, within_start, within_end);
|
||||
if end_col <= start_col {
|
||||
continue;
|
||||
}
|
||||
let cell_row = cell_origin.row + row_offset;
|
||||
let clamped_start = start_col.min(max_cols);
|
||||
let clamped_end = end_col.min(max_cols);
|
||||
for col in clamped_start..clamped_end {
|
||||
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
|
||||
cell.style = merge_styles(cell.style, style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -401,6 +466,59 @@ mod tests {
|
|||
assert!(find_all(b"", "x").is_empty());
|
||||
}
|
||||
|
||||
// ---- regex matcher (Q#RX1) -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn find_all_regex_matches_pattern() {
|
||||
// \d+ over "a1 bb 23 c" → "1" and "23".
|
||||
assert_eq!(
|
||||
find_all_regex(b"a1 bb 23 c", r"\d+"),
|
||||
Some(vec![r(1, 2), r(6, 8)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_all_regex_is_smart_case() {
|
||||
// Lowercase pattern folds case (matches "Foo", "foo", "FOO").
|
||||
assert_eq!(
|
||||
find_all_regex(b"Foo foo FOO", "foo"),
|
||||
Some(vec![r(0, 3), r(4, 7), r(8, 11)])
|
||||
);
|
||||
// An uppercase letter in the pattern makes it case-sensitive.
|
||||
assert_eq!(find_all_regex(b"Foo foo FOO", "Foo"), Some(vec![r(0, 3)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_all_regex_spans_newlines() {
|
||||
// An explicit \n in the pattern matches across the line break.
|
||||
assert_eq!(
|
||||
find_all_regex(b"foo\n bar", r"foo\n\s*bar"),
|
||||
Some(vec![r(0, 9)])
|
||||
);
|
||||
// Plain `.` does NOT cross the newline (default, not dotall).
|
||||
assert_eq!(find_all_regex(b"a\nb", "a.b"), Some(vec![]));
|
||||
// ...but `(?s)` opts into dotall.
|
||||
assert_eq!(find_all_regex(b"a\nb", "(?s)a.b"), Some(vec![r(0, 3)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_all_regex_invalid_pattern_is_none() {
|
||||
// Unbalanced group — the incremental-typing case (`foo(`).
|
||||
assert_eq!(find_all_regex(b"foo(", "foo("), None);
|
||||
// A valid pattern with zero matches is Some(empty), distinct
|
||||
// from invalid.
|
||||
assert_eq!(find_all_regex(b"abc", "zzz"), Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_all_regex_filters_zero_width_and_empty() {
|
||||
// `a*` matches empty at non-'a' positions; only the non-empty
|
||||
// runs survive the zero-width filter.
|
||||
assert_eq!(find_all_regex(b"baab", "a*"), Some(vec![r(1, 3)]));
|
||||
// An empty pattern yields no matches (not one-per-position).
|
||||
assert_eq!(find_all_regex(b"abc", ""), Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_set_clamps_active_and_clears_on_empty() {
|
||||
let mut s = SearchStore::new();
|
||||
|
|
@ -509,6 +627,66 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_view_washes_a_multiline_match_per_row() {
|
||||
use crate::cell::{Cell, CellGrid, CellSize};
|
||||
use crate::view::Viewport;
|
||||
|
||||
let store = make_shared_store();
|
||||
let bid = BufferId::next();
|
||||
let mut buf = Buffer::new(bid, "t.txt");
|
||||
// "foo\nbar\nbaz": match [0,7) = "foo\nbar" spans lines 0–1.
|
||||
buf.apply_edit(crate::buffer::EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"foo\nbar\nbaz",
|
||||
})
|
||||
.expect("seed");
|
||||
store.lock().unwrap().set(bid, r"foo\nbar", vec![r(0, 7)]);
|
||||
|
||||
let (rows, cols) = (3u32, 10u32);
|
||||
let mut backing = vec![Cell::default(); (rows * cols) as usize];
|
||||
let mut grid = CellGrid {
|
||||
cells: &mut backing,
|
||||
stride: cols,
|
||||
size: CellSize::new(rows, cols),
|
||||
};
|
||||
SearchView::new(store.clone()).render(
|
||||
&buf,
|
||||
Viewport {
|
||||
buffer_start: 0,
|
||||
buffer_end: buf.len(),
|
||||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(rows, cols),
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
||||
// Row 0 "foo" and row 1 "bar" both wash (the active match's
|
||||
// bright color); the newline cells and row 2 "baz" do not.
|
||||
for col in 0..3 {
|
||||
assert_eq!(
|
||||
grid.get(CellCoord::new(0, col)).style.bg,
|
||||
Color::Indexed(11),
|
||||
"row 0 col {col} should wash"
|
||||
);
|
||||
assert_eq!(
|
||||
grid.get(CellCoord::new(1, col)).style.bg,
|
||||
Color::Indexed(11),
|
||||
"row 1 col {col} should wash"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
grid.get(CellCoord::new(0, 3)).style.bg,
|
||||
Color::Default,
|
||||
"the newline cell past 'foo' is not washed"
|
||||
);
|
||||
assert_eq!(
|
||||
grid.get(CellCoord::new(2, 0)).style.bg,
|
||||
Color::Default,
|
||||
"row 2 'baz' is outside the match"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_staleness_tracks_per_buffer() {
|
||||
let mut s = SearchStore::new();
|
||||
|
|
|
|||
|
|
@ -76,6 +76,11 @@ struct LastFrame<T> {
|
|||
generation: u64,
|
||||
}
|
||||
|
||||
/// Cached `SearchPrompt` payload for cached-compare suppression
|
||||
/// (Q#SR5 / Q#RX6): `(query, active, total, regex, invalid)`. A `None`
|
||||
/// query means the last emission cleared the band.
|
||||
type SearchPromptFacts = (Option<String>, Option<u32>, u32, bool, bool);
|
||||
|
||||
/// Owns one `semantic_render` session's projection state: the last
|
||||
/// viewport the frontend declared, and the diff baseline per buffer
|
||||
/// for the `StyleSpans` and `Decorations` families.
|
||||
|
|
@ -121,10 +126,9 @@ pub struct SemanticRenderState {
|
|||
/// `(name, modified, diag_errors, diag_warnings)` last emitted as
|
||||
/// `StatusFacts` (Q#S1) — cached-compare suppression.
|
||||
last_status: HashMap<BufferId, (String, bool, u32, u32)>,
|
||||
/// `(query, active, total)` last emitted as `SearchPrompt`
|
||||
/// (Q#SR5) — cached-compare suppression. A `None` query means the
|
||||
/// last emission cleared the band (no active search).
|
||||
last_search_prompt: HashMap<BufferId, (Option<String>, Option<u32>, u32)>,
|
||||
/// Last emitted `SearchPrompt` payload per buffer, for
|
||||
/// cached-compare suppression (see [`SearchPromptFacts`]).
|
||||
last_search_prompt: HashMap<BufferId, SearchPromptFacts>,
|
||||
/// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs
|
||||
/// the tree-sitter highlights query over the *whole declared
|
||||
/// viewport* (which the GPU frontend sets to the entire buffer)
|
||||
|
|
@ -426,18 +430,17 @@ impl SemanticRenderState {
|
|||
Some(core.search_query().to_owned()),
|
||||
active_idx.and_then(|i| u32::try_from(i).ok()),
|
||||
u32::try_from(total).unwrap_or(u32::MAX),
|
||||
core.search_is_regex(),
|
||||
core.search_is_invalid(),
|
||||
)
|
||||
} else {
|
||||
// No search → a cleared band. active/total are zeroed so
|
||||
// the inactive state is one canonical tuple (the GPU only
|
||||
// reads them when `query` is `Some`). The accepted matches
|
||||
// keep highlighting via Decorations regardless.
|
||||
(None, None, 0)
|
||||
// No search → a cleared band. active/total/regex/invalid
|
||||
// are zeroed so the inactive state is one canonical tuple
|
||||
// (the GPU only reads them when `query` is `Some`). The
|
||||
// accepted matches keep highlighting via Decorations.
|
||||
(None, None, 0, false, false)
|
||||
}
|
||||
};
|
||||
if self.last_search_prompt.get(&buffer_id) == Some(&facts) {
|
||||
return None;
|
||||
}
|
||||
let cached = self.last_search_prompt.get(&buffer_id);
|
||||
if cached == Some(&facts) {
|
||||
return None;
|
||||
|
|
@ -456,6 +459,8 @@ impl SemanticRenderState {
|
|||
query: facts.0.clone(),
|
||||
active: facts.1,
|
||||
total: facts.2,
|
||||
regex: facts.3,
|
||||
invalid: facts.4,
|
||||
};
|
||||
self.last_search_prompt.insert(buffer_id, facts);
|
||||
Some(msg)
|
||||
|
|
@ -3096,7 +3101,7 @@ mod tests {
|
|||
// Begin + type "foo": the live query + active/total ship.
|
||||
{
|
||||
let mut core = state.core.borrow_mut();
|
||||
core.search_begin(true);
|
||||
core.search_begin(true, false);
|
||||
for ch in "foo".chars() {
|
||||
core.search_input_char(ch);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue