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:
Levi Neuwirth 2026-06-27 15:04:01 -04:00 committed by GitHub
commit 5f342ed2c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 653 additions and 242 deletions

1
Cargo.lock generated
View File

@ -2434,6 +2434,7 @@ dependencies = [
"proptest",
"rand 0.8.6",
"rand_distr",
"regex",
"rmp-serde",
"semver",
"serde",

View File

@ -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`.

View File

@ -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 --------------------------------------------------------------------

View File

@ -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

View File

@ -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.

View File

@ -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).

View File

@ -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
));
}

View File

@ -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`].

View File

@ -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 { .. })

View File

@ -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

View File

@ -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);
}
}

View File

@ -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(())
})?,
)?;

View File

@ -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 v1v5 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:?}"),
}

View File

@ -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 01.
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();

View File

@ -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);
}