From d05248a1e864fcebdf62c98396bb7cdfde7b4b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=E7=9A=84GitHub=E7=94=A8=E6=88=B7=E5=90=8D?= =?UTF-8?q?=E6=88=96=E5=A7=93=E5=90=8D?= <2777901608@qq.com> Date: Tue, 7 Jul 2026 10:42:18 +0800 Subject: [PATCH] feat(search): add fuzzy search helpers --- package.json | 10 +-- packages/plugin-search/README.md | 59 +++++++++++++ packages/plugin-search/src/index.ts | 82 +++++++++++++++++++ .../plugin-search/test/plugin-search.test.ts | 33 ++++++++ 4 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 packages/plugin-search/README.md diff --git a/package.json b/package.json index 93aebe0a..14c58812 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ "version": "0.0.13", "packageManager": "pnpm@9.15.4", "scripts": { - "build": "pnpm --filter @floatboat/nexus-core build && pnpm --filter @floatboat/nexus-react build && pnpm --filter @floatboat/nexus-vue build && pnpm --filter @floatboat/nexus-preset-gfm build && pnpm --filter @floatboat/nexus-plugin-slash build && pnpm --filter @floatboat/nexus-plugin-history build && pnpm --filter @floatboat/nexus-plugin-search build && pnpm --filter @floatboat/nexus-plugin-toolbar build && pnpm --filter @floatboat/nexus-plugin-math build && pnpm --filter @floatboat/nexus-plugin-vim build && pnpm --filter @floatboat/nexus-plugin-wordcount build", - "typecheck": "pnpm -r exec tsc --noEmit", + "build": "corepack pnpm --filter \"./packages/**\" --workspace-concurrency=1 build", + "typecheck": "corepack pnpm -r exec tsc --noEmit", "test": "vitest run", - "dev:electron-demo": "pnpm --filter @floatboat/nexus-electron-demo dev", - "build:electron-demo": "pnpm --filter @floatboat/nexus-electron-demo build", - "publish:packages": "pnpm -r --filter \"./packages/*\" publish --access public --no-git-checks" + "dev:electron-demo": "corepack pnpm --filter @floatboat/nexus-electron-demo dev", + "build:electron-demo": "corepack pnpm --filter @floatboat/nexus-electron-demo build", + "publish:packages": "corepack pnpm -r --filter \"./packages/*\" publish --access public --no-git-checks" }, "devDependencies": { "@testing-library/react": "^16.3.0", diff --git a/packages/plugin-search/README.md b/packages/plugin-search/README.md new file mode 100644 index 00000000..4ff3fe35 --- /dev/null +++ b/packages/plugin-search/README.md @@ -0,0 +1,59 @@ +# @floatboat/nexus-plugin-search + +Search panel integration and headless search helpers for +[Nexus-Editor](https://github.com/floatboatai/Nexus-Editor). + +## Install + +```bash +pnpm add @floatboat/nexus-plugin-search @floatboat/nexus-core +``` + +## Editor plugin + +```ts +import { createEditor } from "@floatboat/nexus-core"; +import { createSearchPlugin } from "@floatboat/nexus-plugin-search"; + +const editor = createEditor({ + container, + plugins: [createSearchPlugin()] +}); +``` + +The plugin registers CodeMirror's search panel with Nexus styling, +test-friendly attributes, optional query history, replace controls, +case-sensitive search, regular expressions, and whole-word matching. + +## Headless helpers + +```ts +import { findSearchMatches, replaceAllMatches } from "@floatboat/nexus-plugin-search"; + +findSearchMatches("Nexus note next", "ne", { fuzzy: true }); +// [ +// { from: 0, to: 2, text: "Ne" }, +// { from: 6, to: 10, text: "note" }, +// { from: 11, to: 13, text: "ne" } +// ] + +replaceAllMatches("Nexus note next", "ne", "X", { fuzzy: true }); +// "Xxus X Xxt" +``` + +`fuzzy: true` applies to literal searches only. It treats the query as +an ordered subsequence, returns non-overlapping ranges, honors +`caseSensitive` and `wholeWord`, and is ignored when `regexp` is true +so regular-expression behavior stays unchanged. + +## API + +| Export | Purpose | +|---|---| +| `createSearchPlugin(options?)` | Registers the editor search panel. | +| `findSearchMatches(doc, query, options?)` | Returns match ranges for literal, regexp, whole-word, or fuzzy search. | +| `replaceAllMatches(doc, query, replacement, options?)` | Replaces every supported match and returns the next document. | + +## License + +MIT diff --git a/packages/plugin-search/src/index.ts b/packages/plugin-search/src/index.ts index 705d1451..63a6cabd 100644 --- a/packages/plugin-search/src/index.ts +++ b/packages/plugin-search/src/index.ts @@ -27,6 +27,11 @@ export interface SearchOptions { caseSensitive?: boolean; wholeWord?: boolean; regexp?: boolean; + /** + * Match literal queries as an ordered subsequence, allowing characters + * between query letters. Ignored when `regexp` is true. + */ + fuzzy?: boolean; } export interface SearchHistoryStorage { @@ -250,6 +255,66 @@ function buildSearchPattern(query: string, options: SearchOptions = {}): RegExp } } +function isWordChar(value: string): boolean { + return /[\p{Letter}\p{Number}_]/u.test(value); +} + +function isWholeWordMatch(doc: string, from: number, to: number): boolean { + const before = from > 0 ? doc.slice(from - 1, from) : ""; + const after = to < doc.length ? doc.slice(to, to + 1) : ""; + return (!before || !isWordChar(before)) && (!after || !isWordChar(after)); +} + +function findFuzzyMatchAt( + doc: string, + source: string, + needle: string, + start: number, +): SearchMatch | null { + let queryIndex = 0; + let to = start; + + while (to < source.length && queryIndex < needle.length) { + if (source[to] === needle[queryIndex]) { + queryIndex++; + } + to++; + } + + if (queryIndex !== needle.length) return null; + return { from: start, to, text: doc.slice(start, to) }; +} + +function findFuzzySearchMatches( + doc: string, + query: string, + options: SearchOptions +): SearchMatch[] { + const matches: SearchMatch[] = []; + const caseSensitive = options.caseSensitive === true; + const source = caseSensitive ? doc : doc.toLowerCase(); + const needle = caseSensitive ? query : query.toLowerCase(); + const first = needle[0]; + let cursor = 0; + + while (cursor < doc.length) { + const start = source.indexOf(first, cursor); + if (start === -1) break; + + const match = findFuzzyMatchAt(doc, source, needle, start); + if (!match) break; + + if (!options.wholeWord || isWholeWordMatch(doc, match.from, match.to)) { + matches.push(match); + cursor = match.to; + } else { + cursor = start + 1; + } + } + + return matches; +} + export function findSearchMatches( doc: string, query: string, @@ -259,6 +324,10 @@ export function findSearchMatches( return []; } + if (options.fuzzy && !options.regexp) { + return findFuzzySearchMatches(doc, query, options); + } + const pattern = buildSearchPattern(query, options); if (!pattern) { return []; @@ -289,6 +358,19 @@ export function replaceAllMatches( return doc; } + if (options.fuzzy && !options.regexp) { + const matches = findFuzzySearchMatches(doc, query, options); + if (matches.length === 0) return doc; + + let next = ""; + let cursor = 0; + for (const match of matches) { + next += doc.slice(cursor, match.from) + replacement; + cursor = match.to; + } + return next + doc.slice(cursor); + } + const pattern = buildSearchPattern(query, options); if (!pattern) { return doc; diff --git a/packages/plugin-search/test/plugin-search.test.ts b/packages/plugin-search/test/plugin-search.test.ts index 82892db0..69f4cedc 100644 --- a/packages/plugin-search/test/plugin-search.test.ts +++ b/packages/plugin-search/test/plugin-search.test.ts @@ -241,6 +241,39 @@ describe("@floatboat/nexus-plugin-search", () => { ]); }); + it("supports fuzzy literal search as ordered subsequence matches", () => { + expect(findSearchMatches("Nexus note next edit", "ne", { fuzzy: true })).toEqual([ + { from: 0, to: 2, text: "Ne" }, + { from: 6, to: 10, text: "note" }, + { from: 11, to: 13, text: "ne" } + ]); + }); + + it("supports case-sensitive fuzzy search", () => { + expect(findSearchMatches("Nexus next NE", "Ne", { fuzzy: true, caseSensitive: true })).toEqual([ + { from: 0, to: 2, text: "Ne" } + ]); + }); + + it("supports whole-word fuzzy search", () => { + expect(findSearchMatches("fb f-b fxb", "fb", { fuzzy: true, wholeWord: true })).toEqual([ + { from: 0, to: 2, text: "fb" }, + { from: 3, to: 6, text: "f-b" }, + { from: 7, to: 10, text: "fxb" } + ]); + expect(findSearchMatches("prefixfb", "fb", { fuzzy: true, wholeWord: true })).toEqual([]); + }); + + it("replaces fuzzy matches without overlapping ranges", () => { + expect(replaceAllMatches("Nexus note next", "ne", "X", { fuzzy: true })).toBe("Xxus X Xxt"); + }); + + it("keeps regexp search semantics when fuzzy is also set", () => { + expect(findSearchMatches("foo123 bar", "\\d+", { fuzzy: true, regexp: true })).toEqual([ + { from: 3, to: 6, text: "123" } + ]); + }); + it("creates a search plugin descriptor", () => { const plugin = createSearchPlugin();