diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d8ef0a05..20794d98 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 | No | Backtracking DP matcher + score-ranked dedupe + panel toggle — see `feat/search-fuzzy-match` | | 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 eeb3e0b5..9006f325 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 | 否 | 回溯 DP 匹配 + 评分去重 + 搜索面板开关 — 见 `feat/search-fuzzy-match` | | 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/packages/plugin-search/package.json b/packages/plugin-search/package.json index 5f544ebb..76081d58 100644 --- a/packages/plugin-search/package.json +++ b/packages/plugin-search/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.41.0", "@floatboat/nexus-core": "workspace:*" }, diff --git a/packages/plugin-search/src/fuzzy-match.ts b/packages/plugin-search/src/fuzzy-match.ts new file mode 100644 index 00000000..962c9484 --- /dev/null +++ b/packages/plugin-search/src/fuzzy-match.ts @@ -0,0 +1,287 @@ +export interface FuzzyMatch { + from: number; + to: number; + text: string; + score: number; +} + +export interface FuzzyMatchOptions { + caseSensitive?: boolean; +} + +const SCORE_MATCH = 16; +const SCORE_CONSECUTIVE = 32; +const SCORE_WORD_START = 24; +const SCORE_GAP_PENALTY = 4; + +interface DpCell { + score: number; + consecutive: number; +} + +function normalizeForCompare(value: string, caseSensitive: boolean): string { + return caseSensitive ? value : value.toLowerCase(); +} + +function isWordBoundary(text: string, index: number): boolean { + if (index <= 0) return true; + const prev = text[index - 1]; + return !/[\p{L}\p{N}_]/u.test(prev); +} + +function scoreMatchStep( + doc: string, + index: number, + prevMatch: number, + consecutive: number +): { scoreDelta: number; consecutive: number } { + let scoreDelta = SCORE_MATCH; + let newConsecutive = 0; + + if (prevMatch === index - 1) { + newConsecutive = consecutive + 1; + scoreDelta += SCORE_CONSECUTIVE * newConsecutive; + } else if (prevMatch >= 0) { + scoreDelta -= (index - prevMatch - 1) * SCORE_GAP_PENALTY; + } + + if (isWordBoundary(doc, index)) { + scoreDelta += SCORE_WORD_START; + } + + return { scoreDelta, consecutive: newConsecutive }; +} + +/** + * Score a matched span. Higher is better. Rewards consecutive query + * characters, word-boundary hits, and shorter spans. + */ +export function scoreFuzzySpan( + doc: string, + from: number, + to: number, + query: string, + caseSensitive: boolean +): number { + const haystack = normalizeForCompare(doc, caseSensitive); + const needle = normalizeForCompare(query, caseSensitive); + + let score = 0; + let queryIndex = 0; + let previousMatch = -2; + let consecutive = 0; + + for (let i = from; i < to && queryIndex < needle.length; i++) { + if (haystack[i] !== needle[queryIndex]) continue; + + const step = scoreMatchStep(doc, i, previousMatch, consecutive); + score += step.scoreDelta; + consecutive = step.consecutive; + previousMatch = i; + queryIndex += 1; + } + + score -= Math.max(0, to - from - needle.length); + return score; +} + +function spansOverlap(a: FuzzyMatch, b: FuzzyMatch): boolean { + return a.from < b.to && b.from < a.to; +} + +function compareFuzzyMatchQuality(a: FuzzyMatch, b: FuzzyMatch): number { + if (b.score !== a.score) return b.score - a.score; + + const lengthDelta = a.to - a.from - (b.to - b.from); + if (lengthDelta !== 0) return lengthDelta; + + if (a.from !== b.from) return a.from - b.from; + return a.to - b.to; +} + +/** + * Drop overlapping spans, keeping the highest-quality match in each + * cluster, then return survivors in document order for navigation. + */ +export function rankFuzzyMatches(matches: FuzzyMatch[]): FuzzyMatch[] { + if (matches.length <= 1) { + return matches.slice(); + } + + const sortedByQuality = [...matches].sort(compareFuzzyMatchQuality); + const kept: FuzzyMatch[] = []; + + for (const match of sortedByQuality) { + if (kept.some((existing) => spansOverlap(existing, match))) continue; + kept.push(match); + } + + kept.sort((a, b) => a.from - b.from || a.to - b.to); + return kept; +} + +/** + * Backtracking DP: for a fixed start offset, explore every subsequence + * path and return the span with the highest score. + */ +export function findBestFuzzySpanFromStart( + doc: string, + query: string, + start: number, + options: FuzzyMatchOptions = {} +): FuzzyMatch | null { + const trimmed = query.trim(); + if (!trimmed || start < 0 || start >= doc.length) return null; + + const caseSensitive = options.caseSensitive ?? false; + const haystack = normalizeForCompare(doc, caseSensitive); + const needle = normalizeForCompare(trimmed, caseSensitive); + + if (haystack[start] !== needle[0]) return null; + + const firstStep = scoreMatchStep(doc, start, -1, 0); + let prevLayer = new Map([ + [start, { score: firstStep.scoreDelta, consecutive: firstStep.consecutive }] + ]); + + for (let j = 1; j < needle.length; j++) { + const nextLayer = new Map(); + + for (const [prevPos, cell] of prevLayer) { + for (let i = prevPos + 1; i < doc.length; i++) { + if (haystack[i] !== needle[j]) continue; + + const step = scoreMatchStep(doc, i, prevPos, cell.consecutive); + const newScore = cell.score + step.scoreDelta; + const existing = nextLayer.get(i); + + if (!existing || newScore > existing.score) { + nextLayer.set(i, { + score: newScore, + consecutive: step.consecutive + }); + } + } + } + + if (nextLayer.size === 0) return null; + prevLayer = nextLayer; + } + + let bestEnd = -1; + let bestScore = -Infinity; + + for (const [endPos, cell] of prevLayer) { + const spanLen = endPos + 1 - start; + const finalScore = cell.score - Math.max(0, spanLen - needle.length); + if (finalScore > bestScore) { + bestScore = finalScore; + bestEnd = endPos + 1; + } + } + + if (bestEnd < 0) return null; + + return { + from: start, + to: bestEnd, + text: doc.slice(start, bestEnd), + score: bestScore + }; +} + +/** + * Find subsequence matches using per-anchor DP backtracking, then dedupe + * overlapping spans and keep the highest-quality survivor in each cluster. + */ +export function findFuzzyMatches( + doc: string, + query: string, + options: FuzzyMatchOptions = {} +): FuzzyMatch[] { + const trimmed = query.trim(); + if (!trimmed) return []; + + const caseSensitive = options.caseSensitive ?? false; + const haystack = normalizeForCompare(doc, caseSensitive); + const needle = normalizeForCompare(trimmed, caseSensitive); + const matches: FuzzyMatch[] = []; + + for (let start = 0; start < doc.length; start++) { + if (haystack[start] !== needle[0]) continue; + + const match = findBestFuzzySpanFromStart(doc, trimmed, start, options); + if (match) matches.push(match); + } + + return rankFuzzyMatches(matches); +} + +export function selectionOverlapsFuzzyMatch( + selectionFrom: number, + selectionTo: number, + match: FuzzyMatch +): boolean { + const from = Math.min(selectionFrom, selectionTo); + const to = Math.max(selectionFrom, selectionTo); + return match.from < to && from < match.to; +} + +/** + * Pick the next fuzzy match for navigation. When the current selection does + * not overlap any match, jump to the globally highest-scoring span first. + */ +export function pickNextFuzzyMatch( + matches: FuzzyMatch[], + selectionFrom: number, + selectionTo: number +): FuzzyMatch | null { + if (matches.length === 0) return null; + + const activeIndex = matches.findIndex((match) => + selectionOverlapsFuzzyMatch(selectionFrom, selectionTo, match) + ); + + if (activeIndex === -1) { + return [...matches].sort(compareFuzzyMatchQuality)[0] ?? null; + } + + const active = matches[activeIndex]; + const afterActive = matches.find( + (match) => match.from > active.from || (match.from === active.from && match.to > active.to) + ); + + return afterActive ?? matches[0]; +} + +/** + * Pick the previous fuzzy match for navigation. When the current selection + * does not overlap any match, jump to the last match before the cursor. + */ +export function pickPreviousFuzzyMatch( + matches: FuzzyMatch[], + selectionFrom: number, + selectionTo: number +): FuzzyMatch | null { + if (matches.length === 0) return null; + + const from = Math.min(selectionFrom, selectionTo); + const activeIndex = matches.findIndex((match) => + selectionOverlapsFuzzyMatch(selectionFrom, selectionTo, match) + ); + + if (activeIndex === -1) { + return ( + [...matches].reverse().find((match) => match.to <= from) ?? matches[matches.length - 1] + ); + } + + const active = matches[activeIndex]; + const beforeActive = [...matches] + .reverse() + .find( + (match) => match.from < active.from || (match.from === active.from && match.to < active.to) + ); + + return beforeActive ?? matches[matches.length - 1]; +} diff --git a/packages/plugin-search/src/index.ts b/packages/plugin-search/src/index.ts index 705d1451..5c81bc17 100644 --- a/packages/plugin-search/src/index.ts +++ b/packages/plugin-search/src/index.ts @@ -13,10 +13,25 @@ import { selectMatches, setSearchQuery } from "@codemirror/search"; -import { keymap, runScopeHandlers, type EditorView, type Panel, type ViewUpdate } from "@codemirror/view"; +import { EditorSelection } from "@codemirror/state"; +import { EditorView, keymap, runScopeHandlers, type Panel, type ViewUpdate } from "@codemirror/view"; import type { NexusPlugin } from "@floatboat/nexus-core"; +import { findFuzzyMatches, pickNextFuzzyMatch, pickPreviousFuzzyMatch } from "./fuzzy-match"; + +export { + findBestFuzzySpanFromStart, + findFuzzyMatches, + pickNextFuzzyMatch, + pickPreviousFuzzyMatch, + rankFuzzyMatches, + scoreFuzzySpan, + selectionOverlapsFuzzyMatch, + type FuzzyMatch, + type FuzzyMatchOptions +} from "./fuzzy-match"; + export interface SearchMatch { from: number; to: number; @@ -27,6 +42,11 @@ export interface SearchOptions { caseSensitive?: boolean; wholeWord?: boolean; regexp?: boolean; + /** + * Subsequence matching with minimal enclosing spans. Incompatible with + * `regexp` and `wholeWord`. + */ + fuzzy?: boolean; } export interface SearchHistoryStorage { @@ -72,6 +92,7 @@ export interface SearchPluginLabels { matchCase: string; regexp: string; byWord: string; + fuzzy: string; replaceNext: string; replaceAll: string; close: string; @@ -88,6 +109,7 @@ const DEFAULT_LABELS: SearchPluginLabels = { matchCase: "Match case", regexp: "Regexp", byWord: "By word", + fuzzy: "Fuzzy", replaceNext: "Replace", replaceAll: "Replace all", close: "Close" @@ -233,6 +255,10 @@ function escapeRegExp(value: string): string { } function buildSearchPattern(query: string, options: SearchOptions = {}): RegExp | null { + if (options.fuzzy) { + return null; + } + const flags = options.caseSensitive ? "g" : "gi"; let pattern: string; @@ -259,6 +285,12 @@ export function findSearchMatches( return []; } + if (options.fuzzy) { + return findFuzzyMatches(doc, query, { + caseSensitive: options.caseSensitive + }).map(({ from, to, text }) => ({ from, to, text })); + } + const pattern = buildSearchPattern(query, options); if (!pattern) { return []; @@ -289,6 +321,21 @@ export function replaceAllMatches( return doc; } + if (options.fuzzy) { + const matches = findFuzzyMatches(doc, query, { + caseSensitive: options.caseSensitive + }); + if (matches.length === 0) { + return doc; + } + + let result = doc; + for (const match of [...matches].sort((a, b) => b.from - a.from)) { + result = result.slice(0, match.from) + replacement + result.slice(match.to); + } + return result; + } + const pattern = buildSearchPattern(query, options); if (!pattern) { return doc; @@ -321,6 +368,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) @@ -491,6 +539,88 @@ function createSearchRow(testId: string): HTMLDivElement { return row; } +function getFuzzyMatchesForQuery(view: EditorView, query: SearchQuery) { + return findFuzzyMatches(view.state.doc.toString(), query.search, { + caseSensitive: query.caseSensitive + }); +} + +function findNextFuzzy(view: EditorView, query: SearchQuery): boolean { + const matches = getFuzzyMatchesForQuery(view, query); + const next = pickNextFuzzyMatch( + matches, + view.state.selection.main.from, + view.state.selection.main.to + ); + if (!next) return false; + + view.dispatch({ + selection: EditorSelection.create([EditorSelection.range(next.from, next.to)]), + effects: EditorView.scrollIntoView(next.from, { y: "center" }) + }); + return true; +} + +function findPreviousFuzzy(view: EditorView, query: SearchQuery): boolean { + const matches = getFuzzyMatchesForQuery(view, query); + const previous = pickPreviousFuzzyMatch( + matches, + view.state.selection.main.from, + view.state.selection.main.to + ); + if (!previous) return false; + + view.dispatch({ + selection: EditorSelection.create([EditorSelection.range(previous.from, previous.to)]), + effects: EditorView.scrollIntoView(previous.from, { y: "center" }) + }); + return true; +} + +function selectFuzzyMatches(view: EditorView, query: SearchQuery): boolean { + const matches = getFuzzyMatchesForQuery(view, query); + if (matches.length === 0) return false; + + view.dispatch({ + selection: EditorSelection.create( + matches.map((match) => EditorSelection.range(match.from, match.to)), + 0 + ) + }); + return true; +} + +function replaceNextFuzzy(view: EditorView, query: SearchQuery): boolean { + const matches = getFuzzyMatchesForQuery(view, query); + if (matches.length === 0) return false; + + const { from, to } = view.state.selection.main; + const target = + matches.find((match) => match.from >= from && (match.from > from || match.to > to)) ?? matches[0]; + + view.dispatch({ + changes: { from: target.from, to: target.to, insert: query.replace }, + selection: EditorSelection.create([ + EditorSelection.range(target.from, target.from + query.replace.length) + ]) + }); + return true; +} + +function replaceAllFuzzy(view: EditorView, query: SearchQuery): boolean { + const doc = view.state.doc.toString(); + const next = replaceAllMatches(doc, query.search, query.replace, { + caseSensitive: query.caseSensitive, + fuzzy: true + }); + if (next === doc) return false; + + view.dispatch({ + changes: { from: 0, to: doc.length, insert: next } + }); + return true; +} + class NexusSearchPanel implements Panel { readonly dom: HTMLElement; readonly pos = 80; @@ -500,6 +630,7 @@ class NexusSearchPanel implements Panel { private readonly caseField: HTMLInputElement; private readonly regexpField: HTMLInputElement; private readonly wholeWordField: HTMLInputElement; + private readonly fuzzyField: HTMLInputElement; private readonly labels: SearchPluginLabels; private readonly history: SearchHistoryController; private readonly replaceRow?: HTMLDivElement; @@ -534,6 +665,7 @@ class NexusSearchPanel implements Panel { 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.fuzzyField = this.createCheckbox("markdown-search-fuzzy-toggle", "fuzzy", false); this.dom = document.createElement("div"); this.dom.className = "cm-search nexus-search-panel"; @@ -555,13 +687,13 @@ class NexusSearchPanel implements Panel { navigationGroup.className = "nexus-search-button-group"; navigationGroup.append( createIconButton("markdown-search-prev", "prev", resolvedLabels.previous, "previous", () => - this.submitSearch(() => findPrevious(view)) + this.submitSearch(() => this.runFindPrevious()) ), createIconButton("markdown-search-next", "next", resolvedLabels.next, "next", () => - this.submitSearch(() => findNext(view)) + this.submitSearch(() => this.runFindNext()) ), createIconButton("markdown-search-all", "select", resolvedLabels.all, "all", () => - this.submitSearch(() => selectMatches(view)) + this.submitSearch(() => this.runSelectMatches()) ) ); @@ -570,6 +702,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) { @@ -588,14 +721,14 @@ class NexusSearchPanel implements Panel { replaceRow.append( this.replaceField, createIconButton("markdown-search-replace", "replace", resolvedLabels.replaceNext, "replace", () => - replaceNext(view) + this.runReplaceNext() ), createIconButton( "markdown-search-replace-all", "replaceAll", resolvedLabels.replaceAll, "replaceAll", - () => replaceAll(view) + () => this.runReplaceAll() ) ); this.setReplaceExpanded(false); @@ -659,10 +792,64 @@ class NexusSearchPanel implements Panel { input.name = name; input.setAttribute("form", ""); input.checked = checked; - input.addEventListener("change", () => this.commit()); + input.addEventListener("change", () => { + if (name === "fuzzy" && input.checked) { + this.regexpField.checked = false; + this.wholeWordField.checked = false; + } else if ((name === "re" || name === "word") && input.checked) { + this.fuzzyField.checked = false; + } + this.commit(); + }); return input; } + private isFuzzySearchEnabled(): boolean { + return this.fuzzyField.checked; + } + + private runFindNext(): void { + if (this.isFuzzySearchEnabled()) { + findNextFuzzy(this.view, this.query); + return; + } + findNext(this.view); + } + + private runFindPrevious(): void { + if (this.isFuzzySearchEnabled()) { + findPreviousFuzzy(this.view, this.query); + return; + } + findPrevious(this.view); + } + + private runSelectMatches(): void { + if (this.isFuzzySearchEnabled()) { + selectFuzzyMatches(this.view, this.query); + return; + } + selectMatches(this.view); + } + + private runReplaceNext(): void { + this.commit(); + if (this.isFuzzySearchEnabled()) { + replaceNextFuzzy(this.view, this.query); + return; + } + replaceNext(this.view); + } + + private runReplaceAll(): void { + this.commit(); + if (this.isFuzzySearchEnabled()) { + replaceAllFuzzy(this.view, this.query); + return; + } + replaceAll(this.view); + } + private commit(): void { const query = new SearchQuery({ search: this.searchField.value, @@ -711,14 +898,20 @@ class NexusSearchPanel implements Panel { if (event.key === "Enter" && event.target === this.searchField) { event.preventDefault(); - this.submitSearch(() => (event.shiftKey ? findPrevious : findNext)(this.view)); + this.submitSearch(() => { + if (event.shiftKey) { + this.runFindPrevious(); + } else { + this.runFindNext(); + } + }); return; } if (event.key === "Enter" && event.target === this.replaceField) { event.preventDefault(); this.commit(); - replaceNext(this.view); + this.runReplaceNext(); } } diff --git a/packages/plugin-search/test/fuzzy-match.test.ts b/packages/plugin-search/test/fuzzy-match.test.ts new file mode 100644 index 00000000..c5d00785 --- /dev/null +++ b/packages/plugin-search/test/fuzzy-match.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; + +import { + findBestFuzzySpanFromStart, + findFuzzyMatches, + pickNextFuzzyMatch, + pickPreviousFuzzyMatch, + rankFuzzyMatches, + scoreFuzzySpan +} from "../src/fuzzy-match"; + +function findGreedyFuzzySpanFromStart( + doc: string, + query: string, + start: number, + caseSensitive = false +): { from: number; to: number; score: number; text: string } | null { + const haystack = caseSensitive ? doc : doc.toLowerCase(); + const needle = (caseSensitive ? query : query.toLowerCase()).trim(); + if (!needle || haystack[start] !== needle[0]) return null; + + let queryIndex = 0; + let end = start; + + for (let i = start; i < doc.length && queryIndex < needle.length; i++) { + if (haystack[i] !== needle[queryIndex]) continue; + end = i + 1; + queryIndex += 1; + } + + if (queryIndex < needle.length) return null; + + return { + from: start, + to: end, + text: doc.slice(start, end), + score: scoreFuzzySpan(doc, start, end, needle, caseSensitive) + }; +} + +describe("findBestFuzzySpanFromStart", () => { + it("scores higher than a greedy minimal-span path when a better subsequence exists", () => { + const optimal = findBestFuzzySpanFromStart("abbc", "abc", 0); + const greedy = findGreedyFuzzySpanFromStart("abbc", "abc", 0); + + expect(optimal).not.toBeNull(); + expect(greedy).not.toBeNull(); + expect(optimal!.score).toBeGreaterThan(greedy!.score); + expect(optimal!.text).toBe("abbc"); + }); +}); + +describe("findFuzzyMatches", () => { + it("returns minimal-span subsequence matches", () => { + expect(findFuzzyMatches("hello world", "hwd")).toEqual([ + { + from: 0, + to: 11, + text: "hello world", + score: expect.any(Number) + } + ]); + }); + + it("deduplicates overlapping spans and keeps the highest-quality match", () => { + const ranked = findFuzzyMatches("alpha beta", "ab"); + + expect(ranked).toHaveLength(1); + expect(ranked[0]?.text).toBe("alpha b"); + expect(ranked[0]?.from).toBe(0); + expect(ranked[0]?.to).toBe(7); + }); + + it("respects case sensitivity", () => { + expect(findFuzzyMatches("Hello hello", "h", { caseSensitive: true })).toEqual([ + { + from: 6, + to: 7, + text: "h", + score: expect.any(Number) + } + ]); + }); + + it("returns an empty list for whitespace-only queries", () => { + expect(findFuzzyMatches("hello", " ")).toEqual([]); + }); + + it("returns an empty list when no subsequence exists", () => { + expect(findFuzzyMatches("hello", "xyz")).toEqual([]); + }); +}); + +describe("pickNextFuzzyMatch", () => { + it("jumps to the highest-scoring match when the selection is not on a match", () => { + const matches = findFuzzyMatches("abbc xyz", "abc"); + const next = pickNextFuzzyMatch(matches, 8, 8); + + expect(next?.from).toBe(0); + expect(next?.text).toBe("abbc"); + }); + + it("moves to the next document-order match when already on a match", () => { + const matches = findFuzzyMatches("func call func cast", "fc"); + const first = matches[0]; + expect(first).toBeDefined(); + + const next = pickNextFuzzyMatch(matches, first!.from, first!.to); + + expect(next?.from).toBe(10); + }); +}); + +describe("pickPreviousFuzzyMatch", () => { + it("jumps to the last match before the cursor when not on a match", () => { + const matches = findFuzzyMatches("func call func cast", "fc"); + const previous = pickPreviousFuzzyMatch(matches, 20, 20); + + expect(previous?.from).toBe(10); + }); +}); + +describe("scoreFuzzySpan", () => { + it("rewards consecutive character hits over scattered gaps", () => { + const consecutive = scoreFuzzySpan("abc", 0, 3, "abc", false); + const scattered = scoreFuzzySpan("a-b-c", 0, 5, "abc", false); + expect(consecutive).toBeGreaterThan(scattered); + }); +}); + +describe("rankFuzzyMatches", () => { + it("keeps non-overlapping matches in document order", () => { + const ranked = rankFuzzyMatches([ + { from: 10, to: 12, text: "ca", score: 10 }, + { from: 0, to: 4, text: "func", score: 50 } + ]); + + expect(ranked.map((match) => match.text)).toEqual(["func", "ca"]); + }); +}); diff --git a/packages/plugin-search/test/plugin-search.test.ts b/packages/plugin-search/test/plugin-search.test.ts index 82892db0..cc60c0f9 100644 --- a/packages/plugin-search/test/plugin-search.test.ts +++ b/packages/plugin-search/test/plugin-search.test.ts @@ -166,6 +166,38 @@ function pressInputArrow(input: HTMLInputElement, key: "ArrowUp" | "ArrowDown"): return event; } +function setFuzzySearchEnabled(container: HTMLElement, enabled: boolean): HTMLInputElement { + const checkbox = container.querySelector('[data-test-id="markdown-search-fuzzy-toggle"]'); + expect(checkbox).not.toBeNull(); + if (checkbox!.checked !== enabled) { + checkbox!.checked = enabled; + checkbox!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + } + return checkbox!; +} + +function setupFuzzySearchPanel(initialValue = "function call") { + const container = document.createElement("div"); + document.body.append(container); + const editor = createEditor({ + container, + initialValue, + plugins: [createSearchPlugin()] + }); + const input = openSearchPanel(container); + setFuzzySearchEnabled(container, true); + + return { + editor, + container, + input, + destroy() { + editor.destroy(); + container.remove(); + } + }; +} + describe("@floatboat/nexus-plugin-search", () => { it("finds all case-insensitive matches in a document", () => { expect(findSearchMatches("Hello hello HELLO", "hello")).toEqual([ @@ -241,6 +273,72 @@ describe("@floatboat/nexus-plugin-search", () => { ]); }); + it("supports fuzzy subsequence search", () => { + expect(findSearchMatches("function call", "fc", { fuzzy: true })).toEqual([ + { from: 0, to: 4, text: "func" } + ]); + }); + + it("replaces deduplicated fuzzy matches in one pass", () => { + expect(replaceAllMatches("alpha beta", "ab", "X", { fuzzy: true })).toBe("Xeta"); + }); + + it("navigates to the top-ranked fuzzy match from the search panel", () => { + const harness = setupFuzzySearchPanel("function call"); + + submitSearch(harness.input, "fc"); + + const selection = harness.editor.getSelection(); + expect(Math.min(selection.anchor, selection.head)).toBe(0); + expect(harness.editor.getDocument().slice(selection.anchor, selection.head)).toBe("func"); + + harness.destroy(); + }); + + it("mutually excludes fuzzy and regexp toggles in the search panel", () => { + const harness = setupSearchPanel(); + const fuzzy = harness.container.querySelector('[data-test-id="markdown-search-fuzzy-toggle"]'); + const regexp = harness.container.querySelector('[data-test-id="markdown-search-regexp-toggle"]'); + expect(fuzzy).not.toBeNull(); + expect(regexp).not.toBeNull(); + + setFuzzySearchEnabled(harness.container, true); + expect(fuzzy!.checked).toBe(true); + expect(regexp!.checked).toBe(false); + + regexp!.checked = true; + regexp!.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + expect(fuzzy!.checked).toBe(false); + expect(regexp!.checked).toBe(true); + + harness.destroy(); + }); + + it("cycles fuzzy find-next across deduplicated matches in the editor", () => { + const harness = setupFuzzySearchPanel("func\nfunc"); + + submitSearch(harness.input, "fc"); + + let selection = harness.editor.getSelection(); + expect(harness.editor.getDocument().slice(selection.anchor, selection.head)).toBe("func"); + expect(Math.min(selection.anchor, selection.head)).toBe(0); + + harness.input.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + bubbles: true, + cancelable: true + }) + ); + + selection = harness.editor.getSelection(); + expect(harness.editor.getDocument().slice(selection.anchor, selection.head)).toBe("func"); + expect(Math.min(selection.anchor, selection.head)).toBe(5); + + harness.destroy(); + }); + it("creates a search plugin descriptor", () => { const plugin = createSearchPlugin(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec03cd69..945e1840 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: '@codemirror/search': specifier: ^6.6.0 version: 6.6.0 + '@codemirror/state': + specifier: ^6.5.2 + version: 6.6.0 '@codemirror/view': specifier: ^6.41.0 version: 6.41.0 @@ -977,66 +980,79 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}