From c66670c071815fdfd15a1b9190287458983a5cb8 Mon Sep 17 00:00:00 2001 From: zdx <1123951395@qq.com> Date: Fri, 18 Sep 2026 09:08:50 +0800 Subject: [PATCH] feat(search): add fuzzy subsequence matching --- docs/ROADMAP.md | 2 +- docs/ROADMAP.zh.md | 2 +- openspec/changes/add-search-fuzzy/proposal.md | 44 ++ .../add-search-fuzzy/specs/plugins/spec.md | 134 ++++++ openspec/changes/add-search-fuzzy/tasks.md | 47 ++ packages/plugin-search/src/index.ts | 424 +++++++++++++++++- .../plugin-search/test/plugin-search.test.ts | 296 ++++++++++++ 7 files changed, 933 insertions(+), 16 deletions(-) create mode 100644 openspec/changes/add-search-fuzzy/proposal.md create mode 100644 openspec/changes/add-search-fuzzy/specs/plugins/spec.md create mode 100644 openspec/changes/add-search-fuzzy/tasks.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7c04d0e6..a9578c46 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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` | diff --git a/docs/ROADMAP.zh.md b/docs/ROADMAP.zh.md index 11d0f65b..bde0d405 100644 --- a/docs/ROADMAP.zh.md +++ b/docs/ROADMAP.zh.md @@ -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` | diff --git a/openspec/changes/add-search-fuzzy/proposal.md b/openspec/changes/add-search-fuzzy/proposal.md new file mode 100644 index 00000000..b4f60b16 --- /dev/null +++ b/openspec/changes/add-search-fuzzy/proposal.md @@ -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. diff --git a/openspec/changes/add-search-fuzzy/specs/plugins/spec.md b/openspec/changes/add-search-fuzzy/specs/plugins/spec.md new file mode 100644 index 00000000..c56ef7ed --- /dev/null +++ b/openspec/changes/add-search-fuzzy/specs/plugins/spec.md @@ -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 diff --git a/openspec/changes/add-search-fuzzy/tasks.md b/openspec/changes/add-search-fuzzy/tasks.md new file mode 100644 index 00000000..f6a4cdf8 --- /dev/null +++ b/openspec/changes/add-search-fuzzy/tasks.md @@ -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). diff --git a/packages/plugin-search/src/index.ts b/packages/plugin-search/src/index.ts index 8e928815..d2e97555 100644 --- a/packages/plugin-search/src/index.ts +++ b/packages/plugin-search/src/index.ts @@ -29,12 +29,20 @@ export interface SearchMatch { from: number; to: number; text: string; + /** + * Relevance score, present only for fuzzy matches. + */ + score?: number; } export interface SearchOptions { caseSensitive?: boolean; wholeWord?: boolean; regexp?: boolean; + /** + * Match the query as an ordered subsequence within a line. + */ + fuzzy?: boolean; } export interface SearchHistoryStorage { @@ -80,12 +88,13 @@ export interface SearchPluginLabels { matchCase: string; regexp: string; byWord: string; + fuzzy?: string; replaceNext: string; replaceAll: string; close: string; } -const DEFAULT_LABELS: SearchPluginLabels = { +const DEFAULT_LABELS: SearchPluginLabels & { fuzzy: string } = { find: "Find", replace: "Replace", showReplace: "Show replace", @@ -96,6 +105,7 @@ const DEFAULT_LABELS: SearchPluginLabels = { matchCase: "Match case", regexp: "Regexp", byWord: "By word", + fuzzy: "Fuzzy", replaceNext: "Replace", replaceAll: "Replace all", close: "Close" @@ -619,6 +629,343 @@ class SearchHistoryController { } } +const FUZZY_SCORE_MATCH = 10; +const FUZZY_BONUS_BOUNDARY = 8; +const FUZZY_BONUS_CONSECUTIVE = 5; +const FUZZY_BONUS_CASE_MATCH = 1; +const FUZZY_PENALTY_GAP_START = 2; +const FUZZY_PENALTY_GAP_EXTEND = 1; +const FUZZY_SEPARATORS = " \t/\\|-_:.,\"'({[<>"; + +type SearchEditorState = EditorView["state"]; + +type FuzzyCursorState = Parameters[0]; + +interface FuzzyTextDocument { + length: number; + sliceString(from: number, to?: number): string; + lineAt(pos: number): { from: number; to: number; text: string }; +} + +interface FuzzyLineMatch { + from: number; + to: number; + score: number; +} + +function isFuzzySeparator(value: string): boolean { + return FUZZY_SEPARATORS.includes(value); +} + +function isFuzzyLowerAlpha(value: string): boolean { + return value >= "a" && value <= "z"; +} + +function isFuzzyUpperAlpha(value: string): boolean { + return value >= "A" && value <= "Z"; +} + +function isFuzzyBoundary(line: string, pos: number): boolean { + if (pos === 0) return true; + const previous = line[pos - 1]; + if (isFuzzySeparator(previous)) return true; + return isFuzzyLowerAlpha(previous) && isFuzzyUpperAlpha(line[pos]); +} + +function fuzzyCharsEqual( + caseSensitive: boolean +): (lineChar: string, queryChar: string) => boolean { + if (caseSensitive) { + return (lineChar, queryChar) => lineChar === queryChar; + } + return (lineChar, queryChar) => lineChar.toLowerCase() === queryChar.toLowerCase(); +} + +function scoreFuzzyMatch( + line: string, + query: string, + positions: number[], + caseSensitive: boolean +): number { + let score = 0; + + for (let index = 0; index < positions.length; index++) { + const pos = positions[index]; + score += FUZZY_SCORE_MATCH; + + if (!caseSensitive && line[pos] === query[index]) { + score += FUZZY_BONUS_CASE_MATCH; + } + + if (isFuzzyBoundary(line, pos)) { + score += index === 0 ? FUZZY_BONUS_BOUNDARY * 2 : FUZZY_BONUS_BOUNDARY; + } + + if (index > 0) { + if (pos === positions[index - 1] + 1) { + score += FUZZY_BONUS_CONSECUTIVE; + } + const gap = pos - positions[index - 1] - 1; + if (gap > 0) { + score -= FUZZY_PENALTY_GAP_START + (gap - 1) * FUZZY_PENALTY_GAP_EXTEND; + } + } + } + + return score; +} + +function matchFuzzyLine( + line: string, + query: string, + caseSensitive: boolean, + start: number +): FuzzyLineMatch | null { + const queryLength = query.length; + if (queryLength === 0 || line.length < queryLength || start >= line.length) { + return null; + } + + const charsEqual = fuzzyCharsEqual(caseSensitive); + const positions = new Array(queryLength); + + let queryIndex = 0; + for (let pos = start; pos < line.length && queryIndex < queryLength; pos++) { + if (charsEqual(line[pos], query[queryIndex])) { + positions[queryIndex++] = pos; + } + } + if (queryIndex < queryLength) { + return null; + } + + let cursor = positions[queryLength - 1]; + for (let index = queryLength - 1; index >= 0; index--) { + let found = -1; + for (let pos = cursor; pos >= start; pos--) { + if (charsEqual(line[pos], query[index])) { + found = pos; + break; + } + } + if (found < 0) { + return null; + } + positions[index] = found; + cursor = found - 1; + } + + return { + from: positions[0], + to: positions[queryLength - 1] + 1, + score: scoreFuzzyMatch(line, query, positions, caseSensitive) + }; +} + +function matchAllFuzzyInLine( + line: string, + query: string, + caseSensitive: boolean +): FuzzyLineMatch[] { + const matches: FuzzyLineMatch[] = []; + + for (let start = 0; start < line.length; ) { + const match = matchFuzzyLine(line, query, caseSensitive, start); + if (!match) break; + matches.push(match); + start = match.to; + } + + return matches; +} + +function forEachDocumentLine(doc: string, visit: (line: string, start: number) => void): void { + let start = 0; + + for (let index = 0; index < doc.length; index++) { + const char = doc[index]; + if (char !== "\r" && char !== "\n") continue; + visit(doc.slice(start, index), start); + if (char === "\r" && doc[index + 1] === "\n") index++; + start = index + 1; + } + + visit(doc.slice(start), start); +} + +function findFuzzyMatches(doc: string, query: string, caseSensitive: boolean): SearchMatch[] { + const matches: SearchMatch[] = []; + + forEachDocumentLine(doc, (line, lineStart) => { + for (const match of matchAllFuzzyInLine(line, query, caseSensitive)) { + matches.push({ + from: lineStart + match.from, + to: lineStart + match.to, + text: line.slice(match.from, match.to), + score: match.score + }); + } + }); + + return matches; +} + +function fuzzyDocumentOf(state: FuzzyCursorState): FuzzyTextDocument { + if ("doc" in state) { + return (state as { doc: FuzzyTextDocument }).doc; + } + return state as unknown as FuzzyTextDocument; +} + +function isFuzzySearchQuery(query: SearchQuery): query is FuzzySearchQuery { + return (query as Partial).fuzzy === true; +} + +function fuzzyMatchesInDocument( + doc: FuzzyTextDocument, + query: string, + caseSensitive: boolean, + from: number, + to: number +): SearchMatch[] { + const matches: SearchMatch[] = []; + if (doc.length === 0 || query.length === 0) return matches; + + const rangeStart = Math.max(0, Math.min(from, doc.length)); + const rangeEnd = Math.max(rangeStart, Math.min(to, doc.length)); + + let line = doc.lineAt(Math.min(rangeStart, doc.length - 1)); + while (line.from <= rangeEnd) { + if (line.to > rangeStart) { + for (const match of matchAllFuzzyInLine(line.text, query, caseSensitive)) { + const matchFrom = line.from + match.from; + const matchTo = line.from + match.to; + if (matchFrom < rangeStart || matchTo > rangeEnd) continue; + matches.push({ + from: matchFrom, + to: matchTo, + text: doc.sliceString(matchFrom, matchTo), + score: match.score + }); + } + } + if (line.to >= doc.length) break; + line = doc.lineAt(line.to + 1); + } + + return matches; +} + +/** + * A search query that matches an ordered subsequence within a line instead of + * a literal or regular expression. + */ +export class FuzzySearchQuery extends SearchQuery { + readonly fuzzy = true; + + constructor(config: ConstructorParameters[0]) { + super({ ...config, literal: true, regexp: false, wholeWord: false }); + } + + override eq(other: SearchQuery): boolean { + return super.eq(other) && (other as Partial).fuzzy === this.fuzzy; + } + + getCursor( + state: FuzzyCursorState, + from = 0, + to?: number + ): Iterator<{ from: number; to: number }> { + const doc = fuzzyDocumentOf(state); + const rangeEnd = to ?? doc.length; + const search = this.search; + const caseSensitive = this.caseSensitive; + + function* matches(): Generator<{ from: number; to: number }> { + for (const match of fuzzyMatchesInDocument(doc, search, caseSensitive, from, rangeEnd)) { + yield { from: match.from, to: match.to }; + } + } + + return matches(); + } + + create(): FuzzyQueryHandler { + return new FuzzyQueryHandler(this); + } +} + +class FuzzyQueryHandler { + readonly spec: FuzzySearchQuery; + + constructor(spec: FuzzySearchQuery) { + this.spec = spec; + } + + nextMatch( + state: SearchEditorState, + curFrom: number, + curTo: number + ): { from: number; to: number } | null { + const doc = fuzzyDocumentOf(state); + const forward = this.matches(doc, curTo, doc.length); + if (forward.length > 0) { + return { from: forward[0].from, to: forward[0].to }; + } + + const wrapped = this.matches(doc, 0, curFrom); + const first = wrapped[0]; + return first && (first.from !== curFrom || first.to !== curTo) + ? { from: first.from, to: first.to } + : null; + } + + prevMatch( + state: SearchEditorState, + curFrom: number, + curTo: number + ): { from: number; to: number } | null { + const doc = fuzzyDocumentOf(state); + const before = this.matches(doc, 0, curFrom); + const previous = before[before.length - 1]; + if (previous) { + return { from: previous.from, to: previous.to }; + } + + const all = this.matches(doc, 0, doc.length); + const last = all[all.length - 1]; + return last && (last.from !== curFrom || last.to !== curTo) + ? { from: last.from, to: last.to } + : null; + } + + matchAll(state: SearchEditorState, limit: number): SearchMatch[] | null { + const doc = fuzzyDocumentOf(state); + const matches = this.matches(doc, 0, doc.length); + return matches.length > limit ? null : matches; + } + + highlight( + state: SearchEditorState, + from: number, + to: number, + add: (from: number, to: number) => void + ): void { + const doc = fuzzyDocumentOf(state); + for (const match of this.matches(doc, from, to)) { + add(match.from, match.to); + } + } + + getReplacement(result: SearchMatch): string { + return result.text; + } + + private matches(doc: FuzzyTextDocument, from: number, to: number): SearchMatch[] { + return fuzzyMatchesInDocument(doc, this.spec.search, this.spec.caseSensitive, from, to); + } +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -650,6 +997,10 @@ export function findSearchMatches( return []; } + if (options.fuzzy) { + return findFuzzyMatches(doc, query, options.caseSensitive ?? false); + } + const pattern = buildSearchPattern(query, options); if (!pattern) { return []; @@ -700,7 +1051,10 @@ function resolveLabel( return phrase || fallback; } -function resolveLabels(view: EditorView, labels: Partial | undefined): SearchPluginLabels { +function resolveLabels( + view: EditorView, + labels: Partial | undefined +): SearchPluginLabels & { fuzzy: string } { return { find: resolveLabel(view, labels, "find", DEFAULT_LABELS.find), replace: resolveLabel(view, labels, "replace", DEFAULT_LABELS.replace), @@ -712,6 +1066,7 @@ function resolveLabels(view: EditorView, labels: Partial | u matchCase: resolveLabel(view, labels, "matchCase", DEFAULT_LABELS.matchCase), regexp: resolveLabel(view, labels, "regexp", DEFAULT_LABELS.regexp), byWord: resolveLabel(view, labels, "byWord", DEFAULT_LABELS.byWord), + fuzzy: resolveLabel(view, labels, "fuzzy", DEFAULT_LABELS.fuzzy), replaceNext: resolveLabel(view, labels, "replaceNext", DEFAULT_LABELS.replaceNext), replaceAll: resolveLabel(view, labels, "replaceAll", DEFAULT_LABELS.replaceAll), close: resolveLabel(view, labels, "close", DEFAULT_LABELS.close) @@ -891,7 +1246,8 @@ class NexusSearchPanel implements Panel { private readonly caseField: HTMLInputElement; private readonly regexpField: HTMLInputElement; private readonly wholeWordField: HTMLInputElement; - private readonly labels: SearchPluginLabels; + private readonly fuzzyField: HTMLInputElement; + private readonly labels: SearchPluginLabels & { fuzzy: string }; private readonly history: SearchHistoryController; private readonly replaceRow?: HTMLDivElement; private readonly replaceToggle?: IconButtonElements; @@ -923,8 +1279,16 @@ class NexusSearchPanel implements Panel { false ); this.caseField = this.createCheckbox("markdown-search-case-toggle", "case", this.query.caseSensitive); - this.regexpField = this.createCheckbox("markdown-search-regexp-toggle", "re", this.query.regexp); - this.wholeWordField = this.createCheckbox("markdown-search-word-toggle", "word", this.query.wholeWord); + this.regexpField = this.createCheckbox("markdown-search-regexp-toggle", "re", this.query.regexp, (input) => { + if (input.checked) this.fuzzyField.checked = false; + }); + this.wholeWordField = this.createCheckbox("markdown-search-word-toggle", "word", this.query.wholeWord, (input) => { + if (input.checked) this.fuzzyField.checked = false; + }); + this.fuzzyField = this.createCheckbox("markdown-search-fuzzy-toggle", "fuzzy", isFuzzySearchQuery(this.query), () => { + this.regexpField.checked = false; + this.wholeWordField.checked = false; + }); this.dom = document.createElement("div"); this.dom.className = "cm-search nexus-search-panel"; @@ -961,6 +1325,7 @@ class NexusSearchPanel implements Panel { createLabel(this.caseField, resolvedLabels.matchCase), createLabel(this.regexpField, resolvedLabels.regexp), createLabel(this.wholeWordField, resolvedLabels.byWord), + createLabel(this.fuzzyField, resolvedLabels.fuzzy), navigationGroup ]; if (this.replaceToggle) { @@ -993,6 +1358,8 @@ class NexusSearchPanel implements Panel { this.dom.append(replaceRow); } + this.setFuzzyActive(this.fuzzyField.checked); + const closeButton = createButton("markdown-search-close", "close", "×", () => closeSearchPanel(view)); closeButton.setAttribute("aria-label", resolvedLabels.close); closeButton.title = resolvedLabels.close; @@ -1043,30 +1410,47 @@ class NexusSearchPanel implements Panel { return input; } - private createCheckbox(testId: string, name: string, checked: boolean): HTMLInputElement { + private createCheckbox( + testId: string, + name: string, + checked: boolean, + onToggle?: (input: HTMLInputElement) => void + ): HTMLInputElement { const input = document.createElement("input"); input.dataset.testId = testId; input.type = "checkbox"; input.name = name; input.setAttribute("form", ""); input.checked = checked; - input.addEventListener("change", () => this.commit()); + input.addEventListener("change", () => { + onToggle?.(input); + this.commit(); + }); return input; } private commit(): void { - const query = new SearchQuery({ - search: this.searchField.value, - caseSensitive: this.caseField.checked, - regexp: this.regexpField.checked, - wholeWord: this.wholeWordField.checked, - replace: this.replaceField.value - }); + const fuzzy = this.fuzzyField.checked; + const query = fuzzy + ? new FuzzySearchQuery({ + search: this.searchField.value, + caseSensitive: this.caseField.checked, + replace: this.replaceField.value + }) + : new SearchQuery({ + search: this.searchField.value, + caseSensitive: this.caseField.checked, + regexp: this.regexpField.checked, + wholeWord: this.wholeWordField.checked, + replace: this.replaceField.value + }); if (!query.eq(this.query)) { this.query = query; this.view.dispatch({ effects: setSearchQuery.of(query) }); } + + this.setFuzzyActive(fuzzy); } private submitSearch(runSearchCommand: () => void): void { @@ -1130,13 +1514,25 @@ class NexusSearchPanel implements Panel { return true; } + private setFuzzyActive(fuzzy: boolean): void { + if (this.replaceToggle) { + this.replaceToggle.wrapper.hidden = fuzzy; + } + if (fuzzy && this.replaceExpanded) { + this.setReplaceExpanded(false); + } + } + private setQuery(query: SearchQuery): void { + const fuzzy = isFuzzySearchQuery(query); this.query = query; this.searchField.value = query.search; this.replaceField.value = query.replace; this.caseField.checked = query.caseSensitive; this.regexpField.checked = query.regexp; this.wholeWordField.checked = query.wholeWord; + this.fuzzyField.checked = fuzzy; + this.setFuzzyActive(fuzzy); } } diff --git a/packages/plugin-search/test/plugin-search.test.ts b/packages/plugin-search/test/plugin-search.test.ts index b97128b9..21b36d08 100644 --- a/packages/plugin-search/test/plugin-search.test.ts +++ b/packages/plugin-search/test/plugin-search.test.ts @@ -887,4 +887,300 @@ describe("@floatboat/nexus-plugin-search", () => { editor.destroy(); container.remove(); }); + + it("finds fuzzy subsequence matches with a minimal span", () => { + expect(findSearchMatches("foo bar baz", "fbb", { fuzzy: true })).toEqual([ + { from: 0, to: 9, text: "foo bar b", score: 57 } + ]); + }); + + it("finds every non-overlapping fuzzy match in a line in document order", () => { + expect(findSearchMatches("cat cats", "ct", { fuzzy: true })).toEqual([ + { from: 0, to: 3, text: "cat", score: 36 }, + { from: 4, to: 7, text: "cat", score: 36 } + ]); + }); + + it("keeps fuzzy matches in document order across lines", () => { + expect(findSearchMatches("foo bar baz\nfoo bar baz", "fbb", { fuzzy: true })).toEqual([ + { from: 0, to: 9, text: "foo bar b", score: 57 }, + { from: 12, to: 21, text: "foo bar b", score: 57 } + ]); + }); + + it("does not match a fuzzy query across a newline", () => { + expect(findSearchMatches("foo\nbar", "fb", { fuzzy: true })).toEqual([]); + }); + + it("returns no fuzzy matches when the query is longer than every line", () => { + expect(findSearchMatches("ab\ncde", "abcd", { fuzzy: true })).toEqual([]); + }); + + it("returns no fuzzy matches for an empty query", () => { + expect(findSearchMatches("foo bar", "", { fuzzy: true })).toEqual([]); + }); + + it("returns no fuzzy matches for an empty document", () => { + expect(findSearchMatches("", "foo", { fuzzy: true })).toEqual([]); + }); + + it("keeps fuzzy match offsets correct across CRLF line endings", () => { + expect(findSearchMatches("abcd\r\nef", "ad", { fuzzy: true })).toEqual([ + { from: 0, to: 4, text: "abcd", score: 35 } + ]); + }); + + it("folds case by default and scores exact-case matches higher", () => { + expect(findSearchMatches("Foo\nfoo", "fo", { fuzzy: true })).toEqual([ + { from: 0, to: 2, text: "Fo", score: 42 }, + { from: 4, to: 6, text: "fo", score: 43 } + ]); + }); + + it("requires exact casing for fuzzy matches when case sensitivity is enabled", () => { + expect(findSearchMatches("FooBar", "foobar", { fuzzy: true, caseSensitive: true })).toEqual([]); + }); + + it("scores a word-boundary match higher than a mid-word match", () => { + const matches = findSearchMatches("xcat cats", "ct", { fuzzy: true }); + + expect(matches.map((match) => match.from)).toEqual([1, 5]); + expect(matches[1].score!).toBeGreaterThan(matches[0].score!); + }); + + it("scores adjacent fuzzy characters higher than gapped ones", () => { + expect(findSearchMatches("abXb\naXXbXXb", "abb", { fuzzy: true })).toEqual([ + { from: 0, to: 4, text: "abXb", score: 52 }, + { from: 5, to: 12, text: "aXXbXXb", score: 43 } + ]); + }); + + it("penalizes larger gaps between fuzzy matches", () => { + expect(findSearchMatches("aXbYc\naXXXXbYYYYc", "abc", { fuzzy: true })).toEqual([ + { from: 0, to: 5, text: "aXbYc", score: 45 }, + { from: 6, to: 17, text: "aXXXXbYYYYc", score: 39 } + ]); + }); + + it("treats a camelCase transition as a fuzzy boundary", () => { + const matches = findSearchMatches("getFooBar\ngetfoobar", "fb", { fuzzy: true }); + + expect(matches.map((match) => match.from)).toEqual([3, 13]); + expect(matches[0].score!).toBeGreaterThan(matches[1].score!); + }); + + it("does not add a score to literal search results", () => { + expect(findSearchMatches("alpha beta alpha", "alpha")).toEqual([ + { from: 0, to: 5, text: "alpha" }, + { from: 11, to: 16, text: "alpha" } + ]); + }); + + it("treats fuzzy as a find-only option in replaceAllMatches", () => { + expect(replaceAllMatches("cat scatter cat", "cat", "dog", { fuzzy: true })).toBe( + "dog sdogter dog" + ); + }); + + it("adds the fuzzy toggle with the default label", () => { + const harness = setupSearchPanel(); + const fuzzyField = harness.container.querySelector( + '[data-test-id="markdown-search-fuzzy-toggle"]' + ); + + expect(fuzzyField).not.toBeNull(); + expect(fuzzyField!.closest("label")?.textContent).toBe("Fuzzy"); + + harness.destroy(); + }); + + it("labels the fuzzy toggle through the label options", () => { + const harness = setupSearchPanel({ labels: { fuzzy: "模糊" } }); + const fuzzyField = harness.container.querySelector( + '[data-test-id="markdown-search-fuzzy-toggle"]' + ); + + expect(fuzzyField!.closest("label")?.textContent).toBe("模糊"); + + harness.destroy(); + }); + + it("navigates fuzzy matches from the search panel", () => { + const harness = setupSearchPanel(); + const fuzzyField = harness.container.querySelector( + '[data-test-id="markdown-search-fuzzy-toggle"]' + ); + fuzzyField!.checked = true; + fuzzyField!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + + submitSearch(harness.input, "alp"); + let selection = harness.editor.getSelection(); + expect(Math.min(selection.anchor, selection.head)).toBe(0); + expect(Math.max(selection.anchor, selection.head)).toBe(3); + + harness.container.querySelector('[data-test-id="markdown-search-next"]')!.click(); + selection = harness.editor.getSelection(); + expect(Math.min(selection.anchor, selection.head)).toBe(17); + expect(Math.max(selection.anchor, selection.head)).toBe(20); + + harness.destroy(); + }); + + it("selects all fuzzy matches with the select-all control", () => { + const container = document.createElement("div"); + document.body.append(container); + const editor = createEditor({ + container, + initialValue: "alpha beta gamma alpha", + multiCursor: true, + plugins: [createSearchPlugin()] + }); + + try { + const input = openSearchPanel(container); + const fuzzyField = container.querySelector( + '[data-test-id="markdown-search-fuzzy-toggle"]' + ); + fuzzyField!.checked = true; + fuzzyField!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + + submitSearch(input, "alp"); + container.querySelector('[data-test-id="markdown-search-all"]')!.click(); + + expect(editor.getSelections().ranges.map((range) => [ + Math.min(range.anchor, range.head), + Math.max(range.anchor, range.head) + ])).toEqual([ + [0, 3], + [17, 20] + ]); + } finally { + editor.destroy(); + container.remove(); + } + }); + + it("keeps fuzzy, regexp, and whole-word mutually exclusive", () => { + const harness = setupSearchPanel(); + const fuzzyField = harness.container.querySelector( + '[data-test-id="markdown-search-fuzzy-toggle"]' + ); + const regexpField = harness.container.querySelector( + '[data-test-id="markdown-search-regexp-toggle"]' + ); + const wholeWordField = harness.container.querySelector( + '[data-test-id="markdown-search-word-toggle"]' + ); + + regexpField!.checked = true; + wholeWordField!.checked = true; + regexpField!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + wholeWordField!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + + fuzzyField!.checked = true; + fuzzyField!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + expect(regexpField!.checked).toBe(false); + expect(wholeWordField!.checked).toBe(false); + expect(fuzzyField!.checked).toBe(true); + + fuzzyField!.checked = false; + fuzzyField!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + regexpField!.checked = true; + regexpField!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + expect(fuzzyField!.checked).toBe(false); + expect(regexpField!.checked).toBe(true); + + harness.destroy(); + }); + + it("hides the replace controls while fuzzy mode is active", () => { + const harness = setupSearchPanel(); + const fuzzyField = harness.container.querySelector( + '[data-test-id="markdown-search-fuzzy-toggle"]' + ); + const replaceButton = harness.container.querySelector( + '[data-test-id="markdown-search-toggle-replace"]' + ); + const replaceWrap = replaceButton!.parentElement!; + const replaceRow = harness.container.querySelector( + '[data-test-id="markdown-search-replace-row"]' + ); + + expect(replaceWrap.hidden).toBe(false); + expect(replaceRow!.hidden).toBe(true); + + replaceButton!.click(); + expect(replaceRow!.hidden).toBe(false); + + fuzzyField!.checked = true; + fuzzyField!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + expect(replaceWrap.hidden).toBe(true); + expect(replaceRow!.hidden).toBe(true); + + fuzzyField!.checked = false; + fuzzyField!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + expect(replaceWrap.hidden).toBe(false); + expect(replaceRow!.hidden).toBe(true); + + harness.destroy(); + }); + + it("keeps fuzzy mode when recalling a query from history", () => { + const storage = createMemoryHistoryStorage(JSON.stringify(["alpha"])); + const harness = setupSearchPanel(historyOptions({ storage })); + const fuzzyField = harness.container.querySelector( + '[data-test-id="markdown-search-fuzzy-toggle"]' + ); + const regexpField = harness.container.querySelector( + '[data-test-id="markdown-search-regexp-toggle"]' + ); + + fuzzyField!.checked = true; + fuzzyField!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + harness.input.value = "draft"; + harness.input.dispatchEvent(new Event("input", { bubbles: true, cancelable: true })); + + pressInputArrow(harness.input, "ArrowUp"); + + expect(harness.input.value).toBe("alpha"); + expect(fuzzyField!.checked).toBe(true); + expect(regexpField!.checked).toBe(false); + + harness.destroy(); + }); + + it("maps fuzzy matches into rendered table cell highlights", () => { + const highlightSupport = installCssHighlightMock(); + const container = document.createElement("div"); + document.body.append(container); + const editor = createEditor({ + container, + initialValue: [ + "| Name | Status |", + "| --- | --- |", + "| SearchNeedle070 | Todo |", + "| Other | Done |" + ].join("\n"), + livePreview: true, + plugins: [createGfmPreset(), createSearchPlugin()] + }); + + try { + const input = openSearchPanel(container); + const fuzzyField = container.querySelector( + '[data-test-id="markdown-search-fuzzy-toggle"]' + ); + fuzzyField!.checked = true; + fuzzyField!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + + submitSearch(input, "SN"); + + expect(readTableHighlightTexts(highlightSupport.registry, "selected")).toEqual(["SearchN"]); + expect(readTableHighlightTexts(highlightSupport.registry, "match")).toEqual([]); + } finally { + editor.destroy(); + container.remove(); + highlightSupport.restore(); + } + }); });