diff --git a/internal-plugins/setting/src/env.d.ts b/internal-plugins/setting/src/env.d.ts index d7967c26..30734c5d 100644 --- a/internal-plugins/setting/src/env.d.ts +++ b/internal-plugins/setting/src/env.d.ts @@ -401,6 +401,8 @@ declare global { updatePinnedRows: (rows: number) => Promise updateClipboardConfig: (config: { retentionDays: number }) => Promise updateSearchMode: (searchMode: 'aggregate' | 'list') => Promise + updateTokenSearchEnabled: (enabled: boolean) => Promise + updateMatchInsideWord: (enabled: boolean) => Promise updateTabKeyFunction: (mode: 'navigate' | 'target-command') => Promise updateTabTarget: (target: string) => Promise updateSpaceOpenCommand: (enabled: boolean) => Promise diff --git a/internal-plugins/setting/src/views/GeneralSetting/GeneralSetting.vue b/internal-plugins/setting/src/views/GeneralSetting/GeneralSetting.vue index 88b78f70..2c6e5190 100644 --- a/internal-plugins/setting/src/views/GeneralSetting/GeneralSetting.vue +++ b/internal-plugins/setting/src/views/GeneralSetting/GeneralSetting.vue @@ -185,6 +185,8 @@ const localAppSearch = ref(true) const recentRows = ref(2) const pinnedRows = ref(2) const searchMode = ref<'aggregate' | 'list'>('aggregate') +const tokenSearchEnabled = ref(false) +const matchInsideWord = ref(false) const clipboardRetentionDays = ref(180) // Tab 键目标指令 @@ -646,7 +648,6 @@ async function handlePinnedRowsChange(): Promise { } } -// 处理搜索框模式变化 async function handleSearchModeChange(): Promise { try { await saveSettings() @@ -658,6 +659,28 @@ async function handleSearchModeChange(): Promise { } } +// 处理分词搜索开关变化 +async function handleTokenSearchEnabledChange(): Promise { + try { + await saveSettings() + await window.ztools.internal.updateTokenSearchEnabled(tokenSearchEnabled.value) + console.log('分词搜索配置已更新:', tokenSearchEnabled.value) + } catch (error) { + console.error('保存分词搜索配置失败:', error) + } +} + +// 处理匹配单词内部开关变化 +async function handleMatchInsideWordChange(): Promise { + try { + await saveSettings() + await window.ztools.internal.updateMatchInsideWord(matchInsideWord.value) + console.log('匹配单词内部配置已更新:', matchInsideWord.value) + } catch (error) { + console.error('保存匹配单词内部配置失败:', error) + } +} + // 处理空格打开指令变化 async function handleSpaceOpenCommandChange(): Promise { try { @@ -1309,6 +1332,8 @@ async function loadSettings(): Promise { theme.value = data.theme ?? 'system' primaryColor.value = data.primaryColor ?? 'green' searchMode.value = data.searchMode ?? 'aggregate' + tokenSearchEnabled.value = data.tokenSearchEnabled ?? false + matchInsideWord.value = data.matchInsideWord ?? false autoCheckUpdate.value = data.autoCheckUpdate ?? true tabKeyFunction.value = data.tabKeyFunction ?? (data.tabTargetCommand ? 'target-command' : 'navigate') @@ -1403,6 +1428,8 @@ async function saveSettings(): Promise { recentRows: recentRows.value, pinnedRows: pinnedRows.value, searchMode: searchMode.value, + tokenSearchEnabled: tokenSearchEnabled.value, + matchInsideWord: matchInsideWord.value, tabKeyFunction: tabKeyFunction.value, tabTargetCommand: tabTargetCommand.value, spaceOpenCommand: spaceOpenCommand.value, @@ -1897,6 +1924,42 @@ onUnmounted(() => { +
+
+ 分词模式 + 开启后支持更丰富的词首匹配,例如 tasm 匹配 Task Manager +
+
+ +
+
+ +
+
+ 匹配单词内部 + 开启后允许非词首匹配,例如 ps 和 shop 都可以匹配 Photoshop (噪音较多不建议开启) +
+
+ +
+
+
搜索框显示最近使用 diff --git a/resources/preload.js b/resources/preload.js index 1c03beae..b63e41c7 100644 --- a/resources/preload.js +++ b/resources/preload.js @@ -1110,6 +1110,12 @@ window.ztools = { // 通知主渲染进程更新搜索框模式 updateSearchMode: async (mode) => await electron.ipcRenderer.invoke('internal:update-search-mode', mode), + // 通知主渲染进程更新分词搜索开关 + updateTokenSearchEnabled: async (enabled) => + await electron.ipcRenderer.invoke('internal:update-token-search-enabled', enabled), + // 通知主渲染进程更新匹配单词内部开关 + updateMatchInsideWord: async (enabled) => + await electron.ipcRenderer.invoke('internal:update-match-inside-word', enabled), // 通知主渲染进程更新 Tab 键功能配置 updateTabKeyFunction: async (mode) => await electron.ipcRenderer.invoke('internal:update-tab-key-function', mode), diff --git a/src/main/api/plugin/internal.ts b/src/main/api/plugin/internal.ts index e494c816..9aa2fb8c 100644 --- a/src/main/api/plugin/internal.ts +++ b/src/main/api/plugin/internal.ts @@ -1113,6 +1113,24 @@ export class InternalPluginAPI { return { success: true } }) + // 通知主渲染进程更新分词搜索开关 + ipcMain.handle('internal:update-token-search-enabled', async (event, enabled: boolean) => { + if (!requireInternalPlugin(this.pluginManager, event)) { + throw new PermissionDeniedError('internal:update-token-search-enabled') + } + this.mainWindow?.webContents.send('update-token-search-enabled', enabled) + return { success: true } + }) + + // 通知主渲染进程更新匹配单词内部开关 + ipcMain.handle('internal:update-match-inside-word', async (event, enabled: boolean) => { + if (!requireInternalPlugin(this.pluginManager, event)) { + throw new PermissionDeniedError('internal:update-match-inside-word') + } + this.mainWindow?.webContents.send('update-match-inside-word', enabled) + return { success: true } + }) + // 通知主渲染进程更新 Tab 键目标指令 ipcMain.handle('internal:update-tab-target', async (event, target: string) => { if (!requireInternalPlugin(this.pluginManager, event)) { diff --git a/src/preload/index.ts b/src/preload/index.ts index 78954ec7..ce4f22be 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -298,6 +298,12 @@ const api = { onUpdateSearchMode: (callback: (mode: string) => void) => { ipcRenderer.on('update-search-mode', (_event, mode) => callback(mode)) }, + onUpdateTokenSearchEnabled: (callback: (enabled: boolean) => void) => { + ipcRenderer.on('update-token-search-enabled', (_event, enabled) => callback(enabled)) + }, + onUpdateMatchInsideWord: (callback: (enabled: boolean) => void) => { + ipcRenderer.on('update-match-inside-word', (_event, enabled) => callback(enabled)) + }, onUpdatePrimaryColor: ( callback: (data: { primaryColor: string; customColor?: string }) => void ) => { @@ -723,6 +729,8 @@ declare global { onUpdateSubInputPlaceholder: ( callback: (data: { pluginPath: string; placeholder: string }) => void ) => void + onUpdateTokenSearchEnabled: (callback: (enabled: boolean) => void) => void + onUpdateMatchInsideWord: (callback: (enabled: boolean) => void) => void onUpdateSubInputVisible: (callback: (visible: boolean) => void) => void onUpdateTabTarget: (callback: (target: string) => void) => void onUpdateTabKeyFunction: (callback: (mode: 'navigate' | 'target-command') => void) => void diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index e813282d..3b4b161e 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -805,6 +805,16 @@ onMounted(async () => { windowStore.updateSearchMode(mode as 'aggregate' | 'list') }) + // 监听分词搜索开关更新事件 + window.ztools.onUpdateTokenSearchEnabled((enabled: boolean) => { + windowStore.updateTokenSearchEnabled(enabled) + }) + + // 监听匹配单词内部开关更新事件 + window.ztools.onUpdateMatchInsideWord((enabled: boolean) => { + windowStore.updateMatchInsideWord(enabled) + }) + // 监听主题色更新事件 window.ztools.onUpdatePrimaryColor((data: { primaryColor: string; customColor?: string }) => { console.log('更新主题色:', data) diff --git a/src/renderer/src/composables/useSearchResults.ts b/src/renderer/src/composables/useSearchResults.ts index c287ff17..3be2f6a7 100644 --- a/src/renderer/src/composables/useSearchResults.ts +++ b/src/renderer/src/composables/useSearchResults.ts @@ -1,5 +1,6 @@ import { computed, ref, watch } from 'vue' import { useCommandDataStore } from '../stores/commandDataStore' +import { matchCommand } from '../utils/tokenSearch' import { useWindowStore } from '../stores/windowStore' /** @@ -256,7 +257,31 @@ export function useSearchResults(props: { // 无搜索词(如仅粘贴文本)时,返回去重后的原始顺序结果 if (!query) return deduped - // 排序:完全匹配 > 前缀匹配 > 系统应用 > 其他 + // 分词搜索开启时:按分词匹配分数排序,同分按使用频率降序 + if (windowStore.tokenSearchEnabled) { + const matchInsideWord = windowStore.matchInsideWord + return deduped + .map((cmd) => { + const outcome = matchCommand(cmd.name, query, { matchInsideWord }) + const isApp = cmd.type === 'direct' && cmd.subType === 'app' + // matchCommand 只看 name,系统应用加权在集合层补一次(对齐 tokenSearch) + const score = outcome + ? outcome.pattern === 'exact' + ? outcome.score + : outcome.score + (isApp ? 300 : 0) + : -1 + return { cmd, score } + }) + .sort((a, b) => { + if (a.score !== b.score) return b.score - a.score + const keyA = `${a.cmd.path}:${a.cmd.featureCode || ''}` + const keyB = `${b.cmd.path}:${b.cmd.featureCode || ''}` + return (usageStatsMap.value.get(keyB) || 0) - (usageStatsMap.value.get(keyA) || 0) + }) + .map((item) => item.cmd) + } + + // 原有排序:完全匹配 > 前缀匹配 > 系统应用 > 其他 return deduped.sort((a, b) => { const nameA = a.name.toLowerCase() const nameB = b.name.toLowerCase() diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 8c69f8d4..d871f83e 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -296,6 +296,8 @@ declare global { onUpdateSpaceOpenCommand: (callback: (enabled: boolean) => void) => void onUpdateFloatingBallDoubleClickCommand?: (callback: (command: string) => void) => void onUpdateSearchMode: (callback: (mode: string) => void) => void + onUpdateTokenSearchEnabled: (callback: (enabled: boolean) => void) => void + onUpdateMatchInsideWord: (callback: (enabled: boolean) => void) => void onUpdatePrimaryColor: ( callback: (data: { primaryColor: string; customColor?: string }) => void ) => void diff --git a/src/renderer/src/stores/commandDataStore.ts b/src/renderer/src/stores/commandDataStore.ts index eaf55a26..cafb4c08 100644 --- a/src/renderer/src/stores/commandDataStore.ts +++ b/src/renderer/src/stores/commandDataStore.ts @@ -9,6 +9,8 @@ import { applySpecialConfig as _applySpecialConfig, calculateMatchScore as _calculateMatchScore } from './commandUtils' +import { tokenSearch } from '../utils/tokenSearch' +import { useWindowStore } from './windowStore' import { COMMAND_ALIASES_KEY, getLegacyDirectAppCommandId, @@ -1368,72 +1370,83 @@ export const useCommandDataStore = defineStore('commandData', () => { // 如果没有指定搜索范围,使用全局指令 const searchTarget = commandList || commands.value - if (!query || !fuse.value) { + const windowStore = useWindowStore() + const useTokenSearch = windowStore.tokenSearchEnabled + + if (!query || (!fuse.value && !useTokenSearch)) { return { bestMatches: searchTarget.filter((cmd) => cmd.type === 'direct' && cmd.subType === 'app'), // 无搜索时只显示应用 regexMatches: [] } } - // 1. Fuse.js 模糊搜索 - // 搜索词过长时跳过 Fuse.js(应用名/指令名通常很短,超长输入走模糊搜索无意义且浪费性能) + // 1. 命令搜索 + // 搜索词过长时跳过(应用名/指令名通常很短,超长输入走搜索无意义且浪费性能) const FUSE_MAX_QUERY_LENGTH = 32 let bestMatches: SearchResult[] = [] if (query.length <= FUSE_MAX_QUERY_LENGTH) { - // 如果指定了搜索范围,创建临时 Fuse 实例 - const searchFuse = commandList - ? new Fuse(commandList, { - keys: [ - { name: 'name', weight: 2 }, - { name: 'pinyin', weight: 1.5 }, - { name: 'pinyinAbbr', weight: 1 }, - { name: 'acronym', weight: 1.5 } - ], - threshold: 0, - ignoreLocation: true, - includeScore: true, - includeMatches: true - }) - : fuse.value - - const fuseResults = searchFuse.search(query) - const scoredMatches: SearchResultScoreMeta[] = fuseResults.map((r) => { - const displayMatches = (r.matches || []) as MatchInfo[] - - // 检测匹配类型(用于前端高亮算法选择) - let matchType: 'acronym' | 'name' | 'pinyin' | 'pinyinAbbr' | undefined - if (displayMatches.length > 0) { - // 优先级:acronym > name > pinyin > pinyinAbbr - if (displayMatches.some((m) => m.key === 'acronym')) { - matchType = 'acronym' - } else if (displayMatches.some((m) => m.key === 'name')) { - matchType = 'name' - } else if (displayMatches.some((m) => m.key === 'pinyin')) { - matchType = 'pinyin' - } else if (displayMatches.some((m) => m.key === 'pinyinAbbr')) { - matchType = 'pinyinAbbr' + if (useTokenSearch) { + // 分词搜索:直接产出按分词匹配分数排序的命中项 + bestMatches = tokenSearch(searchTarget, query, { + matchInsideWord: windowStore.matchInsideWord + }) as unknown as SearchResult[] + } else { + // Fuse.js 模糊搜索 + // 如果指定了搜索范围,创建临时 Fuse 实例 + const searchFuse = commandList + ? new Fuse(commandList, { + keys: [ + { name: 'name', weight: 2 }, + { name: 'pinyin', weight: 1.5 }, + { name: 'pinyinAbbr', weight: 1 }, + { name: 'acronym', weight: 1.5 } + ], + threshold: 0, + ignoreLocation: true, + includeScore: true, + includeMatches: true + }) + : fuse.value! + + const fuseResults = searchFuse.search(query) + const scoredMatches: SearchResultScoreMeta[] = fuseResults.map((r) => { + const displayMatches = (r.matches || []) as MatchInfo[] + + // 检测匹配类型(用于前端高亮算法选择) + let matchType: 'acronym' | 'name' | 'pinyin' | 'pinyinAbbr' | undefined + if (displayMatches.length > 0) { + // 优先级:acronym > name > pinyin > pinyinAbbr + if (displayMatches.some((m) => m.key === 'acronym')) { + matchType = 'acronym' + } else if (displayMatches.some((m) => m.key === 'name')) { + matchType = 'name' + } else if (displayMatches.some((m) => m.key === 'pinyin')) { + matchType = 'pinyin' + } else if (displayMatches.some((m) => m.key === 'pinyinAbbr')) { + matchType = 'pinyinAbbr' + } } - } - return { - result: { - ...r.item, - matches: displayMatches, - matchType - }, - scoreText: r.item.name, - scoreMatches: displayMatches - } - }) - bestMatches = scoredMatches - .sort((a, b) => { - // 自定义排序:优先连续匹配,系统应用权重略高 - const scoreA = calculateMatchScore(a.scoreText, query, a.scoreMatches, a.result) - const scoreB = calculateMatchScore(b.scoreText, query, b.scoreMatches, b.result) - return scoreB - scoreA // 分数高的排前面 + return { + result: { + ...r.item, + matches: displayMatches, + matchType + }, + scoreText: r.item.name, + scoreMatches: displayMatches + } }) - .map((item) => item.result) + bestMatches = scoredMatches + .sort((a, b) => { + // 自定义排序:优先连续匹配,系统应用权重略高 + const scoreA = calculateMatchScore(a.scoreText, query, a.scoreMatches, a.result) + const scoreB = calculateMatchScore(b.scoreText, query, b.scoreMatches, b.result) + return scoreB - scoreA // 分数高的排前面 + }) + .map((item) => item.result) + } // 搜索偏好置顶:将上次选中的指令移到第一位 const prefKey = query.trim().toLowerCase() diff --git a/src/renderer/src/stores/windowStore.ts b/src/renderer/src/stores/windowStore.ts index 7ee020e6..3707b49b 100644 --- a/src/renderer/src/stores/windowStore.ts +++ b/src/renderer/src/stores/windowStore.ts @@ -101,6 +101,9 @@ export const useWindowStore = defineStore('window', () => { const pinnedRows = ref(2) // 搜索框模式 const searchMode = ref('aggregate') + // 分词搜索 + const tokenSearchEnabled = ref(false) + const matchInsideWord = ref(false) const theme = ref('system') // system, light, dark const primaryColor = ref('green') // blue, purple, green, orange, red, pink, custom @@ -228,6 +231,14 @@ export const useWindowStore = defineStore('window', () => { searchMode.value = mode } + function updateTokenSearchEnabled(value: boolean): void { + tokenSearchEnabled.value = value + } + + function updateMatchInsideWord(value: boolean): void { + matchInsideWord.value = value + } + function updateTabTargetCommand(value: string): void { tabTargetCommand.value = value } @@ -658,6 +669,12 @@ export const useWindowStore = defineStore('window', () => { if (data.searchMode) { searchMode.value = data.searchMode } + if (data.tokenSearchEnabled !== undefined) { + tokenSearchEnabled.value = data.tokenSearchEnabled + } + if (data.matchInsideWord !== undefined) { + matchInsideWord.value = data.matchInsideWord + } if (data.tabKeyFunction !== undefined) { tabKeyFunction.value = data.tabKeyFunction } else { @@ -735,6 +752,10 @@ export const useWindowStore = defineStore('window', () => { updatePinnedRows, searchMode, updateSearchMode, + tokenSearchEnabled, + updateTokenSearchEnabled, + matchInsideWord, + updateMatchInsideWord, tabKeyFunction, updateTabKeyFunction, tabTargetCommand, diff --git a/src/renderer/src/utils/tokenSearch.ts b/src/renderer/src/utils/tokenSearch.ts new file mode 100644 index 00000000..02e956b4 --- /dev/null +++ b/src/renderer/src/utils/tokenSearch.ts @@ -0,0 +1,672 @@ +/** + * 基于分词的模糊搜索。 + * + * 把 command.name 编码(literal / pinyin)后切成 tokens, + * 再把 query 按 MECE 模式(精确 / multi tokens 连续-非连续 × 全词-首字母或全词-词首 / + * single token 全词-首字母-词首子串-…)对齐到 tokens 上,按模式精度打分排序。 + * + * 通过 `matchInsideWord` 选项开启 single token 的「词首子序列、词内/词尾子串」2 种噪音模式。 + * pinyin 编码只允许 whole / head 两种 chunk class(音节必须完整)。 + */ +import { pinyin } from 'pinyin-pro' + +// ===== 对外类型 ===== + +export interface MatchInfo { + indices: Array<[number, number]> + value: string + key: string +} + +export interface TokenSearchOptions { + matchInsideWord: boolean +} + +// 命中起始 token 为 name 首词时追加 -at-start +type AtStartAwarePattern = + | 'single-whole' + | 'single-head' + | 'single-prefix' + | 'single-prefix-seq' + | 'multi-consecutive-whole' + | 'multi-consecutive-head-or-whole' + | 'multi-consecutive-prefix' + | 'multi-nonconsecutive-whole' + | 'multi-nonconsecutive-head-or-whole' + | 'multi-nonconsecutive-prefix' + | 'multi-fallback' + +// 不区分 at-start 的 pattern +type AtStartNeutralPattern = 'exact' | 'single-infix-substr' | 'single-infix-seq' + +export type TokenPattern = + | AtStartNeutralPattern + | AtStartAwarePattern + | `${AtStartAwarePattern}-at-start` + +export interface TokenSearchOutcome { + score: number + matches: MatchInfo[] + matchType: 'name' + pattern: TokenPattern +} + +export interface TokenSearchEntry { + name: string + type?: string + subType?: string + [k: string]: unknown +} + +// ===== 内部类型 ===== + +type ChunkClass = 'whole' | 'head' | 'prefix' | 'prefix-seq' | 'infix-substr' | 'infix-seq' + +interface Encoded { + text: string + // encoded.text[i] 在原 name 中的索引(用于把命中位置反推回 name 做高亮) + mapToOriginal: number[] +} + +interface Chunk { + tokenIdx: number + // 在 token 内的字符位置(升序),非连续子序列也允许 + positions: number[] +} + +const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf]/ +const ASCII_LETTER_RE = /[A-Za-z]/ +const DELIM_RE = /[\s\u3000!-/:-@[-`{-~]/ +const MAX_QUERY_LENGTH = 32 + +// ===== 编码 ===== + +function encodeLiteral(name: string): Encoded { + const mapToOriginal: number[] = new Array(name.length) + for (let i = 0; i < name.length; i++) mapToOriginal[i] = i + return { text: name, mapToOriginal } +} + +function encodePinyin(name: string): Encoded { + let text = '' + const mapToOriginal: number[] = [] + for (let i = 0; i < name.length; i++) { + const ch = name[i] + if (CJK_RE.test(ch)) { + let syl = '' + try { + syl = pinyin(ch, { toneType: 'none', type: 'string' }) as string + } catch { + syl = '' + } + // 多音字可能返回空格分隔音节,直接拼接成一个 token + syl = (syl || ch).replace(/\s+/g, '').toLowerCase() + for (const p of syl) { + text += p + mapToOriginal.push(i) + } + // 音节后补一个空格做分隔,方便后续分词 + text += ' ' + mapToOriginal.push(i) + } else { + text += ch + mapToOriginal.push(i) + } + } + return { text, mapToOriginal } +} + +export function encodeName( + name: string, + encoder: 'literal' | 'pinyin' +): { text: string; mapToOriginal: number[] } { + return encoder === 'literal' ? encodeLiteral(name) : encodePinyin(name) +} + +// ===== 分词 ===== + +/** 驼峰切分:lower→Upper 断开;Upper→Upper→lower 在第二个 Upper 前断开(VS|Code、HTTP|Server)。 */ +function splitCamel(word: string): string[] { + if (!word) return [] + const parts: string[] = [] + let start = 0 + for (let i = 1; i < word.length; i++) { + const prev = word[i - 1] + const curr = word[i] + const lowerPrev = prev >= 'a' && prev <= 'z' + const upperPrev = prev >= 'A' && prev <= 'Z' + const upperCurr = curr >= 'A' && curr <= 'Z' + const lowerNext = i + 1 < word.length && word[i + 1] >= 'a' && word[i + 1] <= 'z' + if (lowerPrev && upperCurr) { + parts.push(word.slice(start, i)) + start = i + } else if (upperPrev && upperCurr && lowerNext) { + parts.push(word.slice(start, i)) + start = i + } + } + parts.push(word.slice(start)) + return parts +} + +export function tokenize(encoded: string): string[] { + return tokenizeInternal(encoded).tokens +} + +function tokenizeInternal(encoded: string): { tokens: string[]; starts: number[] } { + const tokens: string[] = [] + const starts: number[] = [] + let run = '' + let runStart = -1 + const flush = (): void => { + if (!run) return + const parts = splitCamel(run) + let offset = 0 + for (const p of parts) { + if (p) { + tokens.push(p) + starts.push(runStart + offset) + offset += p.length + } + } + run = '' + runStart = -1 + } + for (let i = 0; i < encoded.length; ) { + const cp = encoded.codePointAt(i)! + const size = cp > 0xffff ? 2 : 1 + const ch = encoded.substr(i, size) + if (DELIM_RE.test(ch)) { + flush() + } else if (ASCII_LETTER_RE.test(ch)) { + if (run === '') runStart = i + run += ch + } else { + // 数字、CJK、世界各语言字母:每个码点单独成 token + flush() + tokens.push(ch) + starts.push(i) + } + i += size + } + flush() + return { tokens, starts } +} + +// ===== 对齐 ===== + +/** [off, off+1, …, off+n-1]。 */ +function range(off: number, n: number): number[] { + return Array.from({ length: n }, (_, i) => off + i) +} + +/** 在 tok 中从 minStart 起按顺序匹配 seg 的每个字符(子序列)。失败返回 null。 */ +function subseq(seg: string, tok: string, minStart: number): number[] | null { + const positions: number[] = [] + let p = minStart + for (let i = 0; i < seg.length; i++) { + const next = tok.indexOf(seg[i], p) + if (next < 0) return null + positions.push(next) + p = next + 1 + } + return positions +} + +/** 词首连续命中:tok 正好等于 seg(whole)或以 seg 开头(head / prefix)。 */ +function matchSingleAtHead(seg: string, tokens: string[], startTok: number): Chunk[] | null { + for (let t = startTok; t < tokens.length; t++) { + const tok = tokens[t].toLowerCase() + if (tok === seg) return [{ tokenIdx: t, positions: range(0, seg.length) }] + } + for (let t = startTok; t < tokens.length; t++) { + const tok = tokens[t].toLowerCase() + if (tok.length > seg.length && tok.substring(0, seg.length) === seg) { + return [{ tokenIdx: t, positions: range(0, seg.length) }] + } + } + return null +} + +/** + * 跨 token 对齐:首字符落在某个 token 词首,之后每个字符按以下优先级落点: + * 1. 相邻 token 词首(保持 token 连续) + * 2. 当前 token 连续延伸(构成 whole / prefix) + * 3. 后续 token 词首(允许 gap) + * 只走「词首 + 连续」两种落点;词内 infix / 子序列完全交给 single-token 策略。 + */ +function matchMultiDfs(seg: string, tokens: string[], startTok: number): Chunk[] | null { + const find = ( + segIdx: number, + tokIdx: number, + charIdx: number, + chunks: Chunk[] + ): Chunk[] | null => { + if (segIdx === seg.length) return chunks + const target = seg[segIdx] + const tok = tokens[tokIdx].toLowerCase() + const last = chunks[chunks.length - 1] + + // 1. 相邻 token 词首(优先保持 token 连续,避免当前 token 贪心吞掉本该属于下一个词首的字符) + if (tokIdx + 1 < tokens.length) { + const nt = tokens[tokIdx + 1].toLowerCase() + if (nt.length > 0 && nt[0] === target) { + const r = find(segIdx + 1, tokIdx + 1, 1, [ + ...chunks, + { tokenIdx: tokIdx + 1, positions: [0] } + ]) + if (r) return r + } + } + // 2. 当前 token 连续延伸 + if (last.tokenIdx === tokIdx && charIdx < tok.length && tok[charIdx] === target) { + const extended: Chunk = { tokenIdx: tokIdx, positions: [...last.positions, charIdx] } + const r = find(segIdx + 1, tokIdx, charIdx + 1, [...chunks.slice(0, -1), extended]) + if (r) return r + } + // 3. 后续 token 词首(允许 gap) + for (let t = tokIdx + 2; t < tokens.length; t++) { + const tk = tokens[t].toLowerCase() + if (tk.length > 0 && tk[0] === target) { + const r = find(segIdx + 1, t, 1, [...chunks, { tokenIdx: t, positions: [0] }]) + if (r) return r + } + } + return null + } + + for (let t = startTok; t < tokens.length; t++) { + const tk = tokens[t].toLowerCase() + if (tk.length > 0 && tk[0] === seg[0]) { + const r = find(1, t, 1, [{ tokenIdx: t, positions: [0] }]) + if (r) return r + } + } + return null +} + +/** + * single token 的 2 种噪音落点(均受 matchInsideWord 门控): + * 词内连续子串(infix-substr)、词首子序列(prefix-seq)。 + */ +function matchSingleInsideWord(seg: string, tokens: string[], startTok: number): Chunk[] | null { + // 词内连续子串 + for (let t = startTok; t < tokens.length; t++) { + const tok = tokens[t].toLowerCase() + for (let off = 1; off + seg.length <= tok.length; off++) { + if (tok.substring(off, off + seg.length) === seg) { + return [{ tokenIdx: t, positions: range(off, seg.length) }] + } + } + } + // 词首子序列 + for (let t = startTok; t < tokens.length; t++) { + const tok = tokens[t].toLowerCase() + if (tok.length > 0 && tok[0] === seg[0]) { + const positions = subseq(seg, tok, 0) + if (positions) return [{ tokenIdx: t, positions }] + } + } + return null +} + +/** + * 把一个 query segment 对齐到 tokens[startTok:],返回精度最高的那种落点。 + * 策略顺序保证:非门控的 whole/head/prefix/multi 先于 matchInsideWord 门控的词内/子序列噪音。 + */ +function alignSegment(seg: string, tokens: string[], startTok: number): Chunk[] | null { + return ( + matchSingleAtHead(seg, tokens, startTok) ?? + matchMultiDfs(seg, tokens, startTok) ?? + matchSingleInsideWord(seg, tokens, startTok) + ) +} + +// ===== 分类 ===== + +function classifyChunk(chunk: Chunk, tokenLen: number): ChunkClass { + const pos = chunk.positions + if (pos.length === 0) return 'infix-seq' + const startsAtHead = pos[0] === 0 + let contiguous = true + for (let i = 1; i < pos.length; i++) { + if (pos[i] !== pos[i - 1] + 1) { + contiguous = false + break + } + } + if (startsAtHead && contiguous && pos.length === tokenLen) return 'whole' + if (startsAtHead && contiguous) return pos.length === 1 ? 'head' : 'prefix' + if (startsAtHead && !contiguous) return 'prefix-seq' + if (!startsAtHead && contiguous) return 'infix-substr' + return 'infix-seq' +} + +const SINGLE_PATTERN_FOR_CLASS: Record = { + whole: 'single-whole', + head: 'single-head', + prefix: 'single-prefix', + 'prefix-seq': 'single-prefix-seq', + 'infix-substr': 'single-infix-substr', + 'infix-seq': 'single-infix-seq' +} + +// literal 下受 matchInsideWord 门控的 single chunk class +const GATED_SINGLE_CLASS: Record = { + whole: false, + head: false, + prefix: false, + 'prefix-seq': true, + 'infix-substr': true, + 'infix-seq': false +} + +// 归为 multi-fallback 的 chunk class +const MULTI_FALLBACK_CLASS: Record = { + whole: false, + head: false, + prefix: false, + 'prefix-seq': true, + 'infix-substr': true, + 'infix-seq': true +} + +function withAtStart(pattern: AtStartAwarePattern, atStart: boolean): TokenPattern { + return atStart ? (`${pattern}-at-start` as TokenPattern) : pattern +} + +/** MECE 分类,返回 null 表示该 encoder 不产出 */ +function classifyAlignment( + allChunks: Chunk[], + tokens: string[], + encoder: 'literal' | 'pinyin', + options: TokenSearchOptions +): TokenPattern | null { + const distinctTokens = new Set(allChunks.map((c) => c.tokenIdx)) + const isSingle = distinctTokens.size === 1 + const atStart = allChunks[0].tokenIdx === 0 + const classes = allChunks.map((c) => classifyChunk(c, tokens[c.tokenIdx].length)) + + // pinyin 音节必须完整:只允许 whole / head + if (encoder === 'pinyin' && classes.some((cl) => cl !== 'whole' && cl !== 'head')) { + return null + } + + if (isSingle) { + const cls = classes[0] + // literal 下,词首子序列 / 词内子串受 matchInsideWord 门控 + if (encoder === 'literal' && GATED_SINGLE_CLASS[cls] && !options.matchInsideWord) { + return null + } + // infix-seq 无 matcher 产出(词首子序列已覆盖;非词首子序列精度过低未实现),保留 class 仅供类型完整 + if (cls === 'infix-seq') return null + const base = SINGLE_PATTERN_FOR_CLASS[cls] + // 非词首类(infix-substr)不区分 at-start + return cls === 'infix-substr' ? base : withAtStart(base as AtStartAwarePattern, atStart) + } + + // multi:计算 token 连续性 + const sortedIdx = [...distinctTokens].sort((a, b) => a - b) + let isConsecutive = true + for (let i = 1; i < sortedIdx.length; i++) { + if (sortedIdx[i] !== sortedIdx[i - 1] + 1) { + isConsecutive = false + break + } + } + if (classes.some((cl) => MULTI_FALLBACK_CLASS[cl])) return withAtStart('multi-fallback', atStart) + + const hasPrefix = classes.some((cl) => cl === 'prefix') + const allHeadOrWhole = classes.every((cl) => cl === 'whole' || cl === 'head') + const allWhole = classes.every((cl) => cl === 'whole') + + if (isConsecutive) { + if (allWhole) return withAtStart('multi-consecutive-whole', atStart) + if (allHeadOrWhole) return withAtStart('multi-consecutive-head-or-whole', atStart) + if (hasPrefix) return withAtStart('multi-consecutive-prefix', atStart) + return null + } + if (allWhole) return withAtStart('multi-nonconsecutive-whole', atStart) + if (allHeadOrWhole) return withAtStart('multi-nonconsecutive-head-or-whole', atStart) + if (hasPrefix) return withAtStart('multi-nonconsecutive-prefix', atStart) + return null +} + +// ===== 打分 ===== + +// 分数 = 各维度系数之和,调整系数即调整所有相关 pattern +const SCORE = { + base: { single: 5500, multi: 5000 }, + continuity: { consecutive: 500, nonconsecutive: 0 }, + atStart: { yes: 1000, no: 0 }, + chunk: { + whole: 500, + head: 350, + headOrWhole: 350, + prefix: 300, + prefixSeq: 200, + infixSubstr: 100, + infixSeq: 0, + fallback: 0 + }, + consecutiveWholeBonus: 1000, + exact: 10000, + coverageWeight: 300, + tokenPosMax: 150, + tokenPosStep: 25 +} as const + +const PATTERN_SCORES: Record = { + exact: SCORE.exact, + + // single token + 'single-whole': SCORE.base.single + SCORE.chunk.whole + SCORE.atStart.no, + 'single-whole-at-start': SCORE.base.single + SCORE.chunk.whole + SCORE.atStart.yes, + 'single-head': SCORE.base.single + SCORE.chunk.head + SCORE.atStart.no, + 'single-head-at-start': SCORE.base.single + SCORE.chunk.head + SCORE.atStart.yes, + 'single-prefix': SCORE.base.single + SCORE.chunk.prefix + SCORE.atStart.no, + 'single-prefix-at-start': SCORE.base.single + SCORE.chunk.prefix + SCORE.atStart.yes, + 'single-prefix-seq': SCORE.base.single + SCORE.chunk.prefixSeq + SCORE.atStart.no, + 'single-prefix-seq-at-start': SCORE.base.single + SCORE.chunk.prefixSeq + SCORE.atStart.yes, + 'single-infix-substr': SCORE.base.single + SCORE.chunk.infixSubstr + SCORE.atStart.no, + 'single-infix-seq': SCORE.base.single + SCORE.chunk.infixSeq + SCORE.atStart.no, + + // multi tokens 连续 + 'multi-consecutive-whole': + SCORE.base.multi + + SCORE.continuity.consecutive + + SCORE.chunk.whole + + SCORE.consecutiveWholeBonus + + SCORE.atStart.no, + 'multi-consecutive-whole-at-start': + SCORE.base.multi + + SCORE.continuity.consecutive + + SCORE.chunk.whole + + SCORE.consecutiveWholeBonus + + SCORE.atStart.yes, + 'multi-consecutive-head-or-whole': + SCORE.base.multi + SCORE.continuity.consecutive + SCORE.chunk.headOrWhole + SCORE.atStart.no, + 'multi-consecutive-head-or-whole-at-start': + SCORE.base.multi + SCORE.continuity.consecutive + SCORE.chunk.headOrWhole + SCORE.atStart.yes, + 'multi-consecutive-prefix': + SCORE.base.multi + SCORE.continuity.consecutive + SCORE.chunk.prefix + SCORE.atStart.no, + 'multi-consecutive-prefix-at-start': + SCORE.base.multi + SCORE.continuity.consecutive + SCORE.chunk.prefix + SCORE.atStart.yes, + + // multi tokens 非连续 + 'multi-nonconsecutive-whole': + SCORE.base.multi + SCORE.continuity.nonconsecutive + SCORE.chunk.whole + SCORE.atStart.no, + 'multi-nonconsecutive-whole-at-start': + SCORE.base.multi + SCORE.continuity.nonconsecutive + SCORE.chunk.whole + SCORE.atStart.yes, + 'multi-nonconsecutive-head-or-whole': + SCORE.base.multi + SCORE.continuity.nonconsecutive + SCORE.chunk.headOrWhole + SCORE.atStart.no, + 'multi-nonconsecutive-head-or-whole-at-start': + SCORE.base.multi + + SCORE.continuity.nonconsecutive + + SCORE.chunk.headOrWhole + + SCORE.atStart.yes, + 'multi-nonconsecutive-prefix': + SCORE.base.multi + SCORE.continuity.nonconsecutive + SCORE.chunk.prefix + SCORE.atStart.no, + 'multi-nonconsecutive-prefix-at-start': + SCORE.base.multi + SCORE.continuity.nonconsecutive + SCORE.chunk.prefix + SCORE.atStart.yes, + + // multi tokens 剩余情形没必要再分类 + 'multi-fallback': + SCORE.base.multi + SCORE.continuity.nonconsecutive + SCORE.chunk.fallback + SCORE.atStart.no, + 'multi-fallback-at-start': + SCORE.base.multi + SCORE.continuity.nonconsecutive + SCORE.chunk.fallback + SCORE.atStart.yes +} + +// 动态加权 +function dynamicBonus(allChunks: Chunk[], encodedLen: number): number { + const matchedLen = allChunks.reduce((s, c) => s + c.positions.length, 0) + const coverage = encodedLen > 0 ? matchedLen / encodedLen : 0 + const firstTokenIdx = allChunks.reduce((m, c) => Math.min(m, c.tokenIdx), Infinity) + const tokenPos = Math.max(0, SCORE.tokenPosMax - firstTokenIdx * SCORE.tokenPosStep) + return Math.round(coverage * SCORE.coverageWeight) + tokenPos +} + +// ===== 高亮 ===== + +function buildMatches( + name: string, + allChunks: Chunk[], + encoded: Encoded, + tokenStarts: number[] +): MatchInfo[] { + const points: number[] = [] + for (const chunk of allChunks) { + const tokenStart = tokenStarts[chunk.tokenIdx] + for (const pos of chunk.positions) { + const encodedPos = tokenStart + pos + if (encodedPos >= 0 && encodedPos < encoded.mapToOriginal.length) { + points.push(encoded.mapToOriginal[encodedPos]) + } + } + } + points.sort((a, b) => a - b) + const indices: Array<[number, number]> = [] + for (const p of points) { + if (indices.length > 0 && p <= indices[indices.length - 1][1] + 1) { + indices[indices.length - 1][1] = Math.max(indices[indices.length - 1][1], p) + } else { + indices.push([p, p]) + } + } + return [{ indices, value: name, key: 'name' }] +} + +// ===== 对外入口 ===== + +/** + * 判定单个 command 是否匹配 query,返回打分与高亮信息;不匹配返回 null。 + * + * query 先转小写并按空格切分为 segments;超过 {@link MAX_QUERY_LENGTH} 直接返回 null。 + * 对 literal / pinyin 两个编码器各跑一次,取分数较高者。 + */ +export function matchCommand( + name: string, + query: string, + options: TokenSearchOptions +): TokenSearchOutcome | null { + if (!name) return null + // 尾部空格 = 强制分词:最后一个 query 字符必须落在 token 边界(首或末) + const forceLastBoundary = /\s$/.test(query) + const q = query.trim().toLowerCase() + if (!q) return null + if (q.length > MAX_QUERY_LENGTH) return null + + if (name.toLowerCase() === q) { + return { + score: PATTERN_SCORES.exact, + matches: [{ indices: [[0, name.length - 1]], value: name, key: 'name' }], + matchType: 'name', + pattern: 'exact' + } + } + + const segments = q.split(/\s+/).filter((s) => s.length > 0) + if (segments.length === 0) return null + + let best: { outcome: TokenSearchOutcome; score: number } | null = null + + for (const encoderName of ['literal', 'pinyin'] as const) { + const encoded = encoderName === 'literal' ? encodeLiteral(name) : encodePinyin(name) + const { tokens, starts } = tokenizeInternal(encoded.text) + if (tokens.length === 0) continue + const lowerTokens = tokens.map((t) => t.toLowerCase()) + + const allChunks: Chunk[] = [] + let cursor = 0 + let failed = false + for (const seg of segments) { + const r = alignSegment(seg, lowerTokens, cursor) + if (!r) { + failed = true + break + } + allChunks.push(...r) + cursor = r[r.length - 1].tokenIdx + 1 + } + if (failed) continue + if (forceLastBoundary) { + const lastChunk = allChunks[allChunks.length - 1] + const lastPos = lastChunk.positions[lastChunk.positions.length - 1] + const tokenLen = tokens[lastChunk.tokenIdx].length + if (lastPos !== 0 && lastPos !== tokenLen - 1) continue + } + + const pattern = classifyAlignment(allChunks, tokens, encoderName, options) + if (!pattern) continue + + const score = PATTERN_SCORES[pattern] + dynamicBonus(allChunks, encoded.text.length) + if (!best || score > best.score) { + best = { + score, + outcome: { + score, + matches: buildMatches(name, allChunks, encoded, starts), + matchType: 'name', + pattern + } + } + } + } + + return best?.outcome ?? null +} + +/** + * 对一组 commands 跑分词搜索,按 score 降序返回命中项。 + * 未命中或抛错的 command 不入选。空 query 返回 []。 + */ +export function tokenSearch( + commands: T[], + query: string, + options: TokenSearchOptions +): Array { + const q = query.trim() + if (!q) return [] + const results: Array = [] + for (const cmd of commands) { + if (!cmd || typeof cmd.name !== 'string') continue + let outcome: TokenSearchOutcome | null + try { + outcome = matchCommand(cmd.name, query, options) + } catch { + outcome = null + } + if (!outcome) continue + const { score, matches, matchType, pattern } = outcome + const systemAppBonus = cmd.type === 'direct' && cmd.subType === 'app' ? 300 : 0 + results.push({ + ...cmd, + score: pattern === 'exact' ? score : score + systemAppBonus, + matches, + matchType, + pattern + }) + } + results.sort((a, b) => b.score - a.score) + return results +} diff --git a/tests/renderer/tokenSearch.test.ts b/tests/renderer/tokenSearch.test.ts new file mode 100644 index 00000000..aa6380d5 --- /dev/null +++ b/tests/renderer/tokenSearch.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect } from 'vitest' +import { + tokenize, + encodeName, + matchCommand, + tokenSearch +} from '../../src/renderer/src/utils/tokenSearch' + +describe('tokenize', () => { + it('英文驼峰切分', () => { + expect(tokenize('VSCode')).toEqual(['VS', 'Code']) + expect(tokenize('HTTPServer')).toEqual(['HTTP', 'Server']) + expect(tokenize('fooBar')).toEqual(['foo', 'Bar']) + }) + + it('CJK 与英文混排', () => { + expect(tokenize('冰与火之舞 A Dance of Fire and Ice')).toEqual([ + '冰', + '与', + '火', + '之', + '舞', + 'A', + 'Dance', + 'of', + 'Fire', + 'and', + 'Ice' + ]) + }) + + it('符号与数字作为分隔符', () => { + expect(tokenize('7-Zip File Manager')).toEqual(['7', 'Zip', 'File', 'Manager']) + expect(tokenize('Dism++')).toEqual(['Dism']) + }) +}) + +describe('encodeName (pinyin)', () => { + it('CJK 转音节,非 CJK 原样', () => { + const { text } = encodeName('冰A', 'pinyin') + expect(text.replace(/\s+$/, '')).toBe('bing A') + }) + + it('多 CJK 音节用空格分隔,方便后续分词', () => { + const { text } = encodeName('冰与火', 'pinyin') + expect(text.replace(/\s+$/, '')).toBe('bing yu huo') + }) +}) + +describe('matchCommand 分词匹配', () => { + const opt = { matchInsideWord: false } + + it('精确匹配', () => { + expect(matchCommand('Task Manager', 'task manager', opt)?.pattern).toBe('exact') + }) + + it('multi 连续 全词 (literal): fire and ice', () => { + expect(matchCommand('A Dance of Fire and Ice', 'fire and ice', opt)?.pattern).toBe( + 'multi-consecutive-whole' + ) + }) + + it('multi 连续 首字母或全词 at-start (literal): adofai', () => { + expect(matchCommand('A Dance of Fire and Ice', 'adofai', opt)?.pattern).toBe( + 'multi-consecutive-head-or-whole-at-start' + ) + }) + + it('multi 非连续 全词 (literal): fire ice', () => { + expect(matchCommand('A Dance of Fire and Ice', 'fire ice', opt)?.pattern).toBe( + 'multi-nonconsecutive-whole' + ) + }) + + it('multi 非连续 首字母或全词 (literal): f i', () => { + expect(matchCommand('A Dance of Fire and Ice', 'f i', opt)?.pattern).toBe( + 'multi-nonconsecutive-head-or-whole' + ) + }) + + it('single 全词: dance', () => { + expect(matchCommand('A Dance of Fire and Ice', 'dance', opt)?.pattern).toBe('single-whole') + }) + + it('single 词首子串: dan', () => { + expect(matchCommand('A Dance of Fire and Ice', 'dan', opt)?.pattern).toBe('single-prefix') + }) + + it('single 词首子序列 默认禁用: dne -> null', () => { + expect(matchCommand('A Dance of Fire and Ice', 'dne', opt)).toBeNull() + }) + + it('single 词首子序列 启用后命中', () => { + expect(matchCommand('A Dance of Fire and Ice', 'dne', { matchInsideWord: true })?.pattern).toBe( + 'single-prefix-seq' + ) + }) + + it('single 非词首子串 启用后命中: anc', () => { + expect(matchCommand('A Dance of Fire and Ice', 'anc', { matchInsideWord: true })?.pattern).toBe( + 'single-infix-substr' + ) + }) + + it('pinyin multi 连续 全词 at-start: bing yu huo', () => { + expect(matchCommand('冰与火之舞', 'bing yu huo', opt)?.pattern).toBe( + 'multi-consecutive-whole-at-start' + ) + }) + + it('pinyin multi 连续 首字母或全词 at-start: byhzw', () => { + expect(matchCommand('冰与火之舞', 'byhzw', opt)?.pattern).toBe( + 'multi-consecutive-head-or-whole-at-start' + ) + }) + + it('pinyin 音节必须完整: bi 不应命中 bing', () => { + expect(matchCommand('冰与火之舞', 'bi', opt)).toBeNull() + }) + + it('pinyin 非连续 全词 at-start: bing huo', () => { + expect(matchCommand('冰与火之舞', 'bing huo', opt)?.pattern).toBe( + 'multi-nonconsecutive-whole-at-start' + ) + }) + + it('pinyin 单字符首字母 at-start: b', () => { + expect(matchCommand('冰与火之舞', 'b', opt)?.pattern).toBe('single-head-at-start') + }) + + it('含符号 query 不命中: 7-Zip', () => { + expect(matchCommand('7-Zip File Manager', '7-zip', opt)).toBeNull() + }) + + it('数字 query at-start 命中: 7', () => { + expect(matchCommand('7-Zip File Manager', '7', opt)?.pattern).toBe('single-whole-at-start') + }) + + it('query 超长(>32)返回 null', () => { + expect(matchCommand('Chrome', 'c'.repeat(33), opt)).toBeNull() + }) + + it('空 query 返回 null', () => { + expect(matchCommand('Chrome', '', opt)).toBeNull() + expect(matchCommand('Chrome', ' ', opt)).toBeNull() + }) + + it('尾部空格: 末位落在词中不命中 - ado vs Adobe', () => { + expect(matchCommand('Adobe Photoshop', 'ado ', opt)).toBeNull() + }) + + it('尾部空格: 末位是词末字符通过 - dance 命中 Dance', () => { + expect(matchCommand('A Dance of Fire and Ice', 'dance ', opt)?.pattern).toBe('single-whole') + }) + + it('尾部空格: 单字符落词首通过 at-start - a 命中 Adobe', () => { + expect(matchCommand('Adobe Photoshop', 'a ', opt)?.pattern).toBe('single-head-at-start') + }) + + it('尾部空格: 末位落在词中不命中 - ph vs Photoshop', () => { + expect(matchCommand('Adobe Photoshop', 'ph ', opt)).toBeNull() + }) + + it('multi-fallback: anc ice', () => { + expect( + matchCommand('A Dance of Fire and Ice', 'anc ice', { matchInsideWord: true })?.pattern + ).toBe('multi-fallback') + }) +}) + +describe('tokenSearch 排序', () => { + it('系统应用在同 pattern 下加分排前', () => { + const r = tokenSearch( + [ + { name: 'Dancer', type: 'plugin', path: '/p', featureCode: 'd' }, + { name: 'Dancer', type: 'direct', subType: 'app', path: '/app' } + ], + 'danc', + { matchInsideWord: false } + ) + expect(r[0].path).toBe('/app') + }) + + it('未命中项被过滤', () => { + const r = tokenSearch( + [ + { name: 'Chrome', type: 'direct', subType: 'app', path: '/ch' }, + { name: 'Firefox', type: 'direct', subType: 'app', path: '/ff' } + ], + 'chrome', + { matchInsideWord: false } + ) + expect(r).toHaveLength(1) + expect(r[0].name).toBe('Chrome') + }) + + it('同 pattern 覆盖率高者排前', () => { + const r = tokenSearch( + [ + { name: 'Dance Revolution', type: 'plugin', path: '/a', featureCode: 'a' }, + { name: 'Dance', type: 'plugin', path: '/b', featureCode: 'b' } + ], + 'dance', + { matchInsideWord: false } + ) + expect(r[0].name).toBe('Dance') + }) + + it('同 pattern 首 token 越靠前分越高', () => { + const a = matchCommand('A Dance', 'dance', { matchInsideWord: false })!.score + const b = matchCommand('A X Dance', 'dance', { matchInsideWord: false })!.score + expect(a).toBeGreaterThan(b) + }) +})