134 lines
6.8 KiB
Markdown
134 lines
6.8 KiB
Markdown
# 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.
|