Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ This document maps every planned feature to **package ownership / priority / sta
| 2 | Whole-word matching | `plugin-search` | P1 | done | No | `wholeWord` option via `buildSearchPattern` with `\b` boundaries |
| 15 | Regex search | `plugin-search` | P1 | done | No | `regexp` option landed alongside whole-word; invalid-regex guarded |
| 16 | Command / search history | `plugin-search` + `plugin-slash` | P2 | done | Yes | Host-injected storage (no implicit localStorage) — `add-search-query-history` + `add-slash-recent-command-history` |
| 17 | Fuzzy search | `plugin-search` | P2 | planned | No | Evaluate fzf-like algorithm vs. third-party lib; needs backtracking matcher |
| 17 | Fuzzy search | `plugin-search` | P2 | done | Yes | In-package line-scoped subsequence matcher with fzf-inspired score, wired into CM search pipeline — see `openspec/changes/add-search-fuzzy` |
| 3 | Slash command sorting + limit | `plugin-slash` | P0 | done | Yes | Landed alongside the floating menu UI — see `openspec/changes/add-slash-menu-ui` |
| 27 | Slash command floating menu UI | `plugin-slash` + `electron-demo` | P0 | done | Yes | `createSlashMenuUI(editor, options)` — see `openspec/changes/add-slash-menu-ui` |

Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
| 2 | whole-word 匹配 | `plugin-search` | P1 | done | 否 | 经 `buildSearchPattern` 的 `wholeWord` 选项,使用 `\b` 边界 |
| 15 | 正则搜索 | `plugin-search` | P1 | done | 否 | `regexp` 选项与 whole-word 一并落地;非法正则有保护 |
| 16 | 历史命令 / 搜索记忆 | `plugin-search` + `plugin-slash` | P2 | done | 是 | 宿主注入式存储(不隐式写 localStorage)—— `add-search-query-history` + `add-slash-recent-command-history` |
| 17 | 模糊搜索 | `plugin-search` | P2 | planned | 否 | 评估 fzf-like 算法 vs. 第三方 lib;需带回溯的匹配器 |
| 17 | 模糊搜索 | `plugin-search` | P2 | done | 是 | 包内实现按行扫描的子序列匹配器 + fzf 风格打分,已接入 CM 搜索管线 — 见 `openspec/changes/add-search-fuzzy` |
| 3 | Slash 命令排序与 limit | `plugin-slash` | P0 | done | 是 | 与浮层菜单 UI 一并落地 —— 见 `openspec/changes/add-slash-menu-ui` |
| 27 | Slash 命令浮层菜单 UI | `plugin-slash` + `electron-demo` | P0 | done | 是 | `createSlashMenuUI(editor, options)` —— 见 `openspec/changes/add-slash-menu-ui` |

Expand Down
44 changes: 44 additions & 0 deletions openspec/changes/add-search-fuzzy/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Change: Add Fuzzy Search

## Why

Roadmap #17 ("Fuzzy search") calls for fzf-like matching in `@floatboat/nexus-plugin-search`. Today the search panel only supports literal, whole-word, and regular expression queries, so a query such as `fbb` cannot find `foo bar baz`. This change adds an opt-in fuzzy mode that matches subsequence queries and drives the existing navigation, selection, and highlighting pipeline without introducing a third-party matching library.

## What Changes

- Add an opt-in `fuzzy` mode to `SearchOptions` and to the search panel as a fourth toggle.
- Match the query as an ordered subsequence **within a single line**; a match spans from its first to its last matched character, so multi-character gaps stay inside the highlighted range.
- Score every match with an fzf-inspired heuristic: per-character base score, word-boundary and camelCase bonuses, consecutive-character bonus, and a penalty that grows with the length of unmatched gaps. Exact-case matches score higher than case-folded ones when case-insensitive matching is used.
- Return results in document order, unchanged from the literal and regexp paths, with the score attached as an optional field.
- Wire fuzzy mode into the CodeMirror search pipeline so `findNext`, `findPrevious`, `selectMatches`, viewport match highlighting, and rendered table-cell highlights all follow fuzzy matches.
- Make `fuzzy` mutually exclusive with `regexp` and `wholeWord` in the panel.
- Hide the replace toggle and replace row while fuzzy mode is active; fuzzy replace is not defined because a subsequence match can be any span of text.
- Localize the fuzzy toggle label through the existing `labels` option.
- Mark Roadmap #17 as done in `docs/ROADMAP.md` and `docs/ROADMAP.zh.md`.

## Non-Goals

- No ranked results list, result counter, or score-based navigation. The score is surfaced as data for follow-up work; jumps follow document order.
- No cross-line matching. Matching a query across a newline is left for a later change.
- No typo tolerance (fzf's `typos: false` behavior is out of scope for this PR).
- No third-party fuzzy library. The matcher is implemented in-package, keeping the dependency set unchanged.
- No changes to `@floatboat/nexus-plugin-slash`, `preset-gfm`, or the electron-demo search bar.
- No new runtime dependency. `@codemirror/state` is deliberately not declared as a direct dependency; editor state types are derived from `@codemirror/view`.

## Design Notes

- **Why a `SearchQuery` subclass.** The CodeMirror search pipeline resolves matches through `SearchQuery.create()`, which returns an internal query-type object consumed by `findNext`, `findPrevious`, `selectMatches`, the match highlighter, and replace commands. Literal and regexp queries cannot express a subsequence match, so fuzzy mode is implemented as a `SearchQuery` subclass that supplies its own query type. This keeps the fuzzy matcher on the same pipeline instead of re-implementing the panel, decorations, and table highlighting in parallel.
- **Why the query type protocol is hand-implemented.** `QueryType` is not exported by `@codemirror/search`; only the runtime shape (`nextMatch`, `prevMatch`, `matchAll`, `highlight`, `getReplacement`) is stable. The handler is therefore implemented against that shape and covered by integration tests that exercise the real commands.
- **Why matching is line-scoped.** Subsequence matching over an entire document makes match ranges arbitrarily long and unbounded in scan cost; per-line matching matches the way every fzf-style tool treats candidates, keeps ranges readable in the editor, and keeps the scan at O(document length × query length).
- **Why replace is disabled.** Replacing a fuzzy match span would delete or overwrite unrelated text (`fbb` → `foo bar b`). The toggle hides the replace UI, and the query type's replacement resolves to the matched text itself so a programmatically invoked replace command is a no-op rather than destructive.
- **Why results stay in document order.** The literal and regexp paths return matches in document order and the highlighting pipeline depends on that contract; reordering by score would change the observable behavior of every existing caller.

## Impact

- Affected specs: `plugins`
- Affected code:
- `packages/plugin-search/src/index.ts`
- `packages/plugin-search/test/plugin-search.test.ts`
- Affected docs: `docs/ROADMAP.md`, `docs/ROADMAP.zh.md`
- Public API: additive only. `SearchOptions.fuzzy`, `SearchMatch.score`, `SearchPluginLabels.fuzzy`, and the exported `FuzzySearchQuery` class are new; no existing field is renamed, removed, or redefined.
- Affected roadmap: Roadmap #17 only. Undo/redo grouping (#8) and plugin event bus (#10) are untouched.
134 changes: 134 additions & 0 deletions openspec/changes/add-search-fuzzy/specs/plugins/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
## ADDED Requirements

### Requirement: Fuzzy Matching Scope

`@floatboat/nexus-plugin-search` SHALL support an opt-in fuzzy mode that matches a query as an ordered subsequence of a single line. A match SHALL span from its first to its last matched character, and matches SHALL NOT cross a newline.

#### Scenario: Subsequence match across gaps
- **WHEN** fuzzy mode is enabled and the query is `fbb` in the document `foo bar baz`
- **THEN** the result SHALL contain the span from the leading `f` through the second `b`
- **AND** the span SHALL include the unmatched characters between the matches

#### Scenario: No match across a newline
- **GIVEN** the document contains `foo\nbar`
- **WHEN** fuzzy mode is enabled with the query `fb`
- **THEN** no match SHALL be returned

#### Scenario: Query longer than a line
- **GIVEN** a document whose longest line is shorter than the query
- **WHEN** fuzzy mode is enabled
- **THEN** no match SHALL be returned for that line

### Requirement: Fuzzy Scoring

Each fuzzy match SHALL carry a numeric score computed from per-character match points, word-boundary and camelCase bonuses, a consecutive-character bonus, an exact-case bonus under case-insensitive matching, and a penalty that grows with the length of unmatched gaps.

#### Scenario: Boundary start scores higher than mid-word start
- **GIVEN** two equally shaped matches where one starts at the beginning of a word and the other starts in the middle of a word
- **WHEN** fuzzy mode is enabled
- **THEN** the boundary match SHALL score higher

#### Scenario: Consecutive characters score higher than gapped ones
- **GIVEN** two matches of the same query where one aligns to adjacent characters and the other spans a longer gap
- **WHEN** fuzzy mode is enabled
- **THEN** the adjacent match SHALL score higher

#### Scenario: Exact case scores higher than case-folded case
- **GIVEN** case-insensitive fuzzy mode enabled
- **AND** two matches of the same span where one preserves the query casing and the other does not
- **WHEN** the matches are scored
- **THEN** the exact-case match SHALL score higher

#### Scenario: Case-sensitive mode requires exact casing
- **GIVEN** fuzzy mode enabled with case sensitivity enabled
- **WHEN** the document contains `Foo` and the query is `foo`
- **THEN** no match SHALL be returned

### Requirement: Fuzzy Mode Is Find-Only

Fuzzy mode SHALL NOT participate in replacement. Enabling fuzzy mode SHALL hide the replace toggle and replace row, and fuzzy replacement SHALL NOT be performed.

#### Scenario: Replace UI is hidden while fuzzy mode is active
- **WHEN** the user enables fuzzy mode in the search panel
- **THEN** the replace toggle SHALL be hidden
- **AND** the replace row SHALL NOT be expandable

#### Scenario: Replace control is restored after fuzzy mode
- **GIVEN** fuzzy mode is enabled in the search panel
- **WHEN** the user disables fuzzy mode
- **THEN** the replace toggle SHALL be visible again

#### Scenario: Fuzzy replacement is not destructive
- **GIVEN** fuzzy mode is enabled
- **WHEN** a replacement operation runs for that query
- **THEN** the matched text SHALL be written back unchanged

### Requirement: Fuzzy and Regexp Are Mutually Exclusive

Fuzzy mode SHALL be mutually exclusive with the regexp and whole-word options in the search panel. Enabling one SHALL clear the others.

#### Scenario: Fuzzy clears regexp and whole-word
- **GIVEN** regexp and whole-word are enabled in the search panel
- **WHEN** the user enables fuzzy mode
- **THEN** regexp and whole-word SHALL be cleared

#### Scenario: Regexp clears fuzzy
- **GIVEN** fuzzy mode is enabled in the search panel
- **WHEN** the user enables regexp
- **THEN** fuzzy mode SHALL be cleared

### Requirement: Existing Search Paths Are Unchanged

Fuzzy mode SHALL be opt-in. When fuzzy mode is disabled, literal, whole-word, and regexp matching SHALL return matches in document order with the same result shape and no score field.

#### Scenario: Literal search keeps its result shape
- **WHEN** fuzzy mode is not enabled and a literal query is searched
- **THEN** each result SHALL contain only its range and matched text

#### Scenario: Fuzzy results stay in document order
- **WHEN** fuzzy mode is enabled with multiple matches in the document
- **THEN** the results SHALL be ordered by document position rather than by score

#### Scenario: Empty query returns no results
- **WHEN** fuzzy mode is enabled with an empty query
- **THEN** no match SHALL be returned

### Requirement: Fuzzy Mode Drives the CodeMirror Search Pipeline

Fuzzy mode SHALL drive the editor's existing navigation, selection, and highlighting behavior so that `findNext`, `findPrevious`, select-all, viewport match highlighting, and rendered table-cell search highlights follow fuzzy matches.

#### Scenario: Next and previous navigate fuzzy matches
- **GIVEN** fuzzy mode is enabled with multiple matches in the document
- **WHEN** the user presses Enter in the search input and presses the next control again
- **THEN** the selection SHALL move from the first fuzzy match to the second in document order

#### Scenario: Select-all selects fuzzy matches
- **GIVEN** fuzzy mode is enabled with multiple matches in the document
- **WHEN** the user runs select-all for the current query
- **THEN** the editor selection SHALL contain one range per fuzzy match

#### Scenario: Table cell highlights follow fuzzy matches
- **GIVEN** a rendered table cell whose source text contains the fuzzy match
- **WHEN** fuzzy mode is enabled and the query is submitted
- **THEN** the rendered cell SHALL be highlighted at the mapped source range

### Requirement: Fuzzy Toggle Label Is Localizable

The fuzzy toggle label SHALL resolve through the existing search label options with a built-in default.

#### Scenario: Default fuzzy label
- **WHEN** the search panel is created without label overrides
- **THEN** the fuzzy toggle SHALL be labeled `Fuzzy`

#### Scenario: Localized fuzzy label
- **GIVEN** the host passes a label for the fuzzy toggle
- **WHEN** the search panel is opened
- **THEN** the fuzzy toggle SHALL use the host-provided label

### Requirement: No New Runtime Dependency

Fuzzy matching SHALL be implemented in-package without adding a runtime dependency and without declaring `@codemirror/state` as a direct dependency of `@floatboat/nexus-plugin-search`.

#### Scenario: Dependency set is unchanged
- **WHEN** this change is applied
- **THEN** `packages/plugin-search/package.json` SHALL keep the same dependency list
47 changes: 47 additions & 0 deletions openspec/changes/add-search-fuzzy/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
## 1. OpenSpec and Scope

- [x] 1.1 Create OpenSpec change `add-search-fuzzy`.
- [x] 1.2 Define line-scoped subsequence matching and score semantics.
- [x] 1.3 Record non-goals: ranked navigation, cross-line matching, typo tolerance, third-party libraries.

## 2. Red Tests Before Implementation

- [x] 2.1 Add matcher tests for subsequence hits, per-line scope, and document order.
- [x] 2.2 Add matcher tests for case-sensitive and case-insensitive behavior.
- [x] 2.3 Add scoring tests comparing boundary, consecutive, gap, and case-folded matches.
- [x] 2.4 Add edge-case tests: blank query, empty document, query longer than a line, CRLF line endings.
- [x] 2.5 Add tests proving non-fuzzy results keep their existing shape and order.
- [x] 2.6 Add panel tests for the fuzzy toggle, label localization, and option exclusivity.
- [x] 2.7 Add panel tests for navigation, select-all, replace hiding, and history recall.

## 3. Fuzzy Matcher

- [x] 3.1 Implement a line-scoped subsequence matcher with a minimal-span alignment.
- [x] 3.2 Implement the fzf-inspired score: match, boundary, camelCase, consecutive, gap penalty, exact-case bonus.
- [x] 3.3 Surface matches through `findSearchMatches()` with document-order ranges and scores.
- [x] 3.4 Make `replaceAllMatches()` treat `fuzzy` as a find-only option.

## 4. CodeMirror Integration

- [x] 4.1 Add `FuzzySearchQuery` with fuzzy-aware equality and a fuzzy match cursor.
- [x] 4.2 Add the fuzzy query type implementing `nextMatch`, `prevMatch`, `matchAll`, `highlight`, and `getReplacement`.
- [x] 4.3 Keep rendered table-cell search highlights on the fuzzy pipeline.

## 5. Panel UI

- [x] 5.1 Add the fuzzy toggle with a localized label.
- [x] 5.2 Enforce `fuzzy` mutual exclusion with `regexp` and `wholeWord`.
- [x] 5.3 Hide the replace toggle and row while fuzzy mode is active.
- [x] 5.4 Preserve case-sensitive, replace text, and history recall state across fuzzy toggling.

## 6. Documentation

- [x] 6.1 Mark Roadmap #17 as done in `docs/ROADMAP.md`.
- [x] 6.2 Mark Roadmap #17 as done in `docs/ROADMAP.zh.md`.

## 7. Verification

- [x] 7.1 Run the targeted `plugin-search` Vitest suite and confirm the new tests pass (58/58).
- [x] 7.2 Run the `plugin-search` TypeScript typecheck (clean).
- [x] 7.3 Run the full Vitest suite: 921 passed, 1 failed — the failure is the pre-existing Windows-only `plugin-host-broker` `O_NONBLOCK` assertion that also fails on `main` before this change.
- [x] 7.4 Run the `@floatboat/nexus-plugin-search` build (ESM + d.ts, clean).
Loading