edamame 0.1.2

A fast TUI Markdown editor and viewer
Documentation
# Search and replace

> Part of the edamame contributor deep-dives. Index and project-wide conventions: [`AGENTS.md`](../../AGENTS.md). Sibling docs live in [`docs/dev/`](.).


- **The flow is gated on `EditorState::search.is_some()`, not a `Mode` variant.** Unlike diff (which replaces the whole view), search keeps the document rendering in the current view mode with highlights on top, so a `Mode::Search` would force an "effective view mode" indirection through every render-dispatch site.
- **Only a *replace* flow captures input; a navigate-only flow is a non-capturing overlay.** `App::search_flow_captures()` is `search.as_ref().is_some_and(|s| s.is_replace_flow())`, in vim and default mode alike. A replace flow needs the unmodified `Tab`/`r`/`a` keys, so it traps input at three choke points: `DefaultHandler::handle` intercepts the hard-bound flow keys (`search::search_keys`, the table-driven pattern `diff_keys` mirrors); `App::dispatch_action` default-denies everything off the `search_safe_action` allowlist *before* `handle_app_action` runs, flashing via `flash_action_unavailable`; `dispatch_mouse_event` drops all mouse input but wheel scroll and pointer moves. The allowlist covers the flow keys, read-only navigation (cursor moves, selection, `SelectAll`, `Copy`) and the always-safe set (scroll, overlay openers, save, quit, in-flow undo/redo), so a new buffer-mutating action is denied automatically. A **navigate-only** flow (vim's `/`, `Ctrl-F` with an empty replace field) does not capture: only `Tab`/`Shift+Tab` (plus vim's `n`/`N`) and `Esc` are intercepted ahead of the keymap — `DefaultHandler` returns only `SearchNext`/`SearchPrev`/`SearchExit`, `dispatch_action` routes just those to `dispatch_search_action`, and everything else falls through to normal editing.
- **A non-capturing flow's match list is refreshed every frame** — it allows edits outside the in-flow mutation paths, so `prepare_viewport` calls `ensure_search_fresh` each frame (version-guarded → a no-op when nothing changed).
- **Search exit is a motion — no scroll-back.** `exit_search` just drops the session, leaving cursor and viewport on the match the user navigated to.
- **Vim `/` `?` search is incremental.** `vim_ops::incsearch::update_incsearch` rebuilds a real navigate-only `SearchState` on every keystroke (typed, history-recalled or pasted — all three through `feed::cmdline_live_update`, shared with the `:s` preview), parks the cursor on `SearchState::focus_relative_to` (the same method submit uses) and scrolls it into view. Because the transient session *is* `EditorState::search`, the hlsearch painters, hint counter and raw-reveal suppression work unchanged — no incsearch-specific render code exists. `IncsearchSession` stashes the pre-prompt cursor/scroll and any prior hlsearch session; Esc restores all three, and Enter restores them *before* the `EnterSearch` outcome so the submit resolves against the original cursor, byte-identical to a preview-less submit. Incsearch never touches the buffer. The shared view primitives — `place_cursor`, `restore_view`, `scroll_cursor_comfortably_into_view` (the one TOP_MARGIN core behind the hunk/match focus scrolls too) — are the single implementations; don't hand-roll a cursor park or a context-margin scroll.
- **Match freshness is version-keyed.** `SearchState` stores byte ranges valid for the `Buffer::version()` they were computed against; every in-flow mutation (replace, replace-all, undo, redo) calls `ensure_search_fresh` afterwards. The render layer additionally clamps ranges against the live source so a stale list can't panic — don't rely on that. Wholesale content swaps (`replace_buffer`, diff entry) drop the session entirely.
- **Matching is smartcase for navigation, case-sensitive for replace.** `ensure_fresh` picks by `is_replace_flow()`: navigate uses `find_all` (case-insensitive unless the pattern has an uppercase char), so *every* user gets smartcase, not just vim; replace uses `find_all_cs`, so a lowercase find term never rewrites a casing variant the user didn't type. `find_all_ci` compares char-by-char against the **untouched** haystack so offsets stay on char boundaries for multibyte text. There is deliberately **no regex** in `/` search; regex is confined to `:s`/`:%s`.
- **The query is escape syntax, so `query` and `needle` are different strings.** Search stays literal-substring, but a one-row field can't hold a line break — so `search::escape` gives the query vim's backslash convention (`\n` `\t` `\r` `\\`), and `SearchState::new` splits input into the typed `query` (kept verbatim for *display*) and the decoded `needle` (what `ensure_fresh` matches); `replace` / `replacement` are the same pair. **Match with `needle`, display `query`** — swapping them either searches for a literal `\n` or carries a raw newline into a one-line surface. An unrecognized backslash is a `SearchError`, not a silent literal: `SearchState::new` returns `Result`, the modal reports it with focus on the offending field, `enter_vim_search` flashes it, and incsearch shows nothing (mid-typing, every prefix of a valid query is briefly invalid). **Text edamame supplies rather than the user types must go through `escape::escape` first** — `feed::search_word_outcome` (`*` / `#`), `search_modal::paste`, and `cmdline::paste_str` on a *search* prompt (`CmdLineKind::is_search`; an `:` prompt keeps the plain newline strip).
- **A match may span a line break, so no highlight consumer may assume one range sits on one line.** `RawView` clips every range (search, `:s` preview, yank flash) to the line being painted through the shared `push_clipped`; `paint_byte_range_overlay`'s per-raw-line intersection does the same for Rendered / Preview; `paint_search_overlays` re-selects overlapping matches per *block* so a match straddling two blocks paints in both.
- **Replace keeps the reveal beat.** A single replace goes through `apply_delta` (one undo delta) plus an immediate `flush_parsed_if_dirty` (overlays and match recompute need fresh source-map ranges next frame), refocuses past the inserted bytes (so a replacement containing the query can't trap the flow) and arms `search_advance` — mirror of `diff_advance` — so the cursor jumps to the next match only after a 350 ms reveal. Replace-all is a single coarse `EditDelta` on the normal history stack, preserving prior undo history unlike the diff merge's `reset_with`.
- **A replace flow leaves Preview** (browse-only): `enter_search_flow` transitions Preview → Rendered when the replace field was filled. Navigate-only flows and zero-match queries leave the mode untouched. **A read-only document drops the replacement at that same entry point**, so the flow it gets is navigate-only. Both halves matter: a capturing flow would hold `Tab` / `r` / `a` for commands `readonly_safe_action` then denies, taking the reading keys with them; and the mode transition is a bare `editor.mode = …` that neither `enter_edit_if_preview` nor `apply_delta` can refuse, since the modal reaches `enter_search_flow` directly and no gate over `Action` ever sees it. Dropping the replacement makes that branch unreachable rather than merely unproductive, and here is the one place to do it — every caller (the modal, vim `/`, the tests) passes through. Unlike the silent `readonly_safe_action` denials the drop is flashed: the user typed the replacement into a field the modal offered them.
- **Raw reveal is suppressed during search**, so blocks don't flip between rendered and raw under the highlights as the user tabs through matches.
- **Highlight painting is shared.** Rendered + Preview paint through `paint_search_overlays` → `paint_byte_range_overlay` (the generalized former `paint_selection_overlay`), called from `EditorView` as a post-pass; Raw paints per-char inside `RawView`. Focused match → `theme.selection`, the rest → `theme.selection_muted`. The painter's block-kind prefix shifts (heading space, code pad cell) resolve the block via `ParsedDoc::real_block_for_byte` — **never index `parsed.blocks` with a `source_map` block index**; the source map counts blank-line virtual blocks, so the index spaces diverge in any document with blank lines.