diff --git a/packages/autocomplete/package.json b/packages/autocomplete/package.json index 1b728352df..759d552d81 100644 --- a/packages/autocomplete/package.json +++ b/packages/autocomplete/package.json @@ -37,6 +37,7 @@ "stub": "unbuild --stub" }, "dependencies": { + "fzf": "^0.5.2", "lru-cache": "^10.0.0" }, "devDependencies": { diff --git a/packages/autocomplete/src/create.ts b/packages/autocomplete/src/create.ts index ea6c0419e5..eefb8fb787 100644 --- a/packages/autocomplete/src/create.ts +++ b/packages/autocomplete/src/create.ts @@ -1,17 +1,22 @@ import type { AutoCompleteExtractorResult, AutoCompleteFunction, AutoCompleteTemplate, SuggestResult, UnoGenerator, Variant } from '@unocss/core' import { escapeRegExp, toArray, uniq } from '@unocss/core' import { LRUCache } from 'lru-cache' +import { Fzf } from 'fzf' import { parseAutocomplete } from './parse' -import type { ParsedAutocompleteTemplate, UnocssAutocomplete } from './types' +import type { AutocompleteOptions, ParsedAutocompleteTemplate, UnocssAutocomplete } from './types' import { searchAttrKey, searchUsageBoundary } from './utils' -export function createAutocomplete(uno: UnoGenerator): UnocssAutocomplete { +export function createAutocomplete(uno: UnoGenerator, options: AutocompleteOptions = {}): UnocssAutocomplete { const templateCache = new Map() const cache = new LRUCache({ max: 5000 }) let staticUtils: string[] = [] + let staticFzf = new Fzf([]) + const templates: (AutoCompleteTemplate | AutoCompleteFunction)[] = [] + const matchType = options.matchType ?? 'prefix' + reset() return { @@ -133,6 +138,8 @@ export function createAutocomplete(uno: UnoGenerator): UnocssAutocomplete { } async function suggestStatic(input: string) { + if (matchType === 'fuzzy') + return staticFzf.find(input).map(i => i.item) return staticUtils.filter(i => i.startsWith(input)) } @@ -146,7 +153,7 @@ export function createAutocomplete(uno: UnoGenerator): UnocssAutocomplete { return templates.map(fn => typeof fn === 'function' ? fn(input) - : getParsed(fn)(input), + : getParsed(fn)(input, matchType), ) || [] } @@ -157,7 +164,7 @@ export function createAutocomplete(uno: UnoGenerator): UnocssAutocomplete { .map(fn => typeof fn === 'function' ? fn(input) - : getParsed(fn)(input), + : getParsed(fn)(input, matchType), ) } @@ -168,6 +175,7 @@ export function createAutocomplete(uno: UnoGenerator): UnocssAutocomplete { ...Object.keys(uno.config.rulesStaticMap), ...uno.config.shortcuts.filter(i => typeof i[0] === 'string').map(i => i[0] as string), ] + staticFzf = new Fzf(staticUtils) templates.length = 0 templates.push( ...uno.config.autocomplete.templates || [], diff --git a/packages/autocomplete/src/parse.ts b/packages/autocomplete/src/parse.ts index 78f6de4566..f918d7602a 100644 --- a/packages/autocomplete/src/parse.ts +++ b/packages/autocomplete/src/parse.ts @@ -1,4 +1,7 @@ -import type { AutocompleteTemplatePart, ParsedAutocompleteTemplate } from './types' +import { uniq } from '@unocss/core' +import { Fzf } from 'fzf' +import type { AutoCompleteMatchType, AutocompleteTemplatePart, ParsedAutocompleteTemplate } from './types' +import { cartesian } from './utils' export const shorthands: Record = { num: `(${[0, 1, 2, 3, 4, 5, 6, 8, 10, 12, 24, 36].join('|')})`, @@ -39,6 +42,8 @@ export function parseAutocomplete(template: string, theme: any = {}): ParsedAuto handleGroups(template) + const fzf = new Fzf(getAllCombination(parts)) + return { parts, suggest, @@ -85,7 +90,9 @@ export function parseAutocomplete(template: string, theme: any = {}): ParsedAuto ) } - function suggest(input: string) { + function suggest(input: string, matchType: AutoCompleteMatchType = 'prefix') { + if (input.length && matchType === 'fuzzy') + return fzf.find(input).map(i => i.item) let rest = input let matched = '' let combinations: string[] = [] @@ -179,3 +186,36 @@ export function parseAutocomplete(template: string, theme: any = {}): ParsedAuto .filter(i => i.length >= input.length) } } + +function getValuesFromPartTemplate(part: AutocompleteTemplatePart): string[] { + if (part.type === 'static') + return [part.value] + if (part.type === 'theme') { + return part.objects.flatMap((i) => { + const keys = Object.keys(i).filter(i => i && i[0] !== '_') + for (const key in i) { + const value = i[key] + if (value === null || value === undefined) + continue + if (typeof value === 'object' && !Array.isArray(value)) { + const subKeys = getValuesFromPartTemplate({ + type: 'theme', + objects: [value as Record], + }).map(i => `${key}-${i}`) + + keys.push(...subKeys) + } + } + return keys + }) + } + if (part.type === 'group') + return [...part.values] + return [] +} + +function getAllCombination(parts: AutocompleteTemplatePart[]) { + const values = parts.map(i => getValuesFromPartTemplate(i)) + const list = uniq(cartesian(values).flatMap(i => i.join('').replace('-DEFAULT', ''))) + return list +} diff --git a/packages/autocomplete/src/types.ts b/packages/autocomplete/src/types.ts index 0aa1baee65..e51f8d3afa 100644 --- a/packages/autocomplete/src/types.ts +++ b/packages/autocomplete/src/types.ts @@ -1,6 +1,12 @@ import type { AutoCompleteFunction, SuggestResult } from '@unocss/core' import type { LRUCache } from 'lru-cache' +export type AutoCompleteMatchType = 'prefix' | 'fuzzy' + +export interface AutocompleteOptions { + matchType?: AutoCompleteMatchType +} + export type AutocompleteTemplatePart = AutocompleteTemplateStatic | AutocompleteTemplateGroup | AutocompleteTemplateTheme export interface AutocompleteTemplateStatic { @@ -20,11 +26,11 @@ export interface AutocompleteTemplateTheme { export interface ParsedAutocompleteTemplate { parts: AutocompleteTemplatePart[] - suggest(input: string): string[] | undefined + suggest(input: string, matchType?: AutoCompleteMatchType): string[] | undefined } export interface UnocssAutocomplete { - suggest: (input: string) => Promise + suggest: (input: string, allowsEmptyInput?: boolean) => Promise suggestInFile: (content: string, cursor: number) => Promise templates: (string | AutoCompleteFunction)[] cache: LRUCache diff --git a/packages/autocomplete/src/utils.ts b/packages/autocomplete/src/utils.ts index d776b19d5c..0f7f820e4e 100644 --- a/packages/autocomplete/src/utils.ts +++ b/packages/autocomplete/src/utils.ts @@ -18,3 +18,20 @@ export function searchAttrKey(content: string, cursor: number) { if (text.match(/(<\w+\s*)[^>]*$/) !== null) return text.match(/\S+(?=\s*=\s*["']?[^"']*$)/)?.[0] } + +export function cartesian(arr: T[][]): T[][] { + if (arr.length < 2) + return arr + return arr.reduce( + (a, b) => { + const ret: T[][] = [] + a.forEach((a) => { + b.forEach((b) => { + ret.push(a.concat([b])) + }) + }) + return ret + }, + [[]] as T[][], + ) +} diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 5bd20d4d21..0d48661763 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -72,6 +72,20 @@ "type": "boolean", "default": true, "description": "Enable/disable selection style decorations" + }, + "unocss.autocomplete.matchType": { + "type": "string", + "default": "prefix", + "enum": [ + "prefix", + "fuzzy" + ], + "description": "The matching type for autocomplete" + }, + "unocss.autocomplete.maxItems": { + "type": "number", + "default": 1000, + "description": "The maximum number of items to show in autocomplete" } } } diff --git a/packages/vscode/src/autocomplete.ts b/packages/vscode/src/autocomplete.ts index 8af2b216bf..de81482435 100644 --- a/packages/vscode/src/autocomplete.ts +++ b/packages/vscode/src/autocomplete.ts @@ -1,4 +1,4 @@ -import type { UnocssAutocomplete } from '@unocss/autocomplete' +import type { AutoCompleteMatchType, UnocssAutocomplete } from '@unocss/autocomplete' import { createAutocomplete } from '@unocss/autocomplete' import type { CompletionItemProvider, Disposable, ExtensionContext } from 'vscode' import { CompletionItem, CompletionItemKind, CompletionList, MarkdownString, Range, languages, window, workspace } from 'vscode' @@ -59,12 +59,18 @@ export async function registerAutoComplete( autoCompletes.delete(ctx) }) + let matchType = workspace.getConfiguration().get('unocss.autocomplete.matchType', 'prefix') + + let maxItems = workspace.getConfiguration().get('unocss.autocomplete.maxItems', 1000) + function getAutocomplete(ctx: UnocssPluginContext) { const cached = autoCompletes.get(ctx) if (cached) return cached - const autocomplete = createAutocomplete(ctx.uno) + const autocomplete = createAutocomplete(ctx.uno, { + matchType, + }) autoCompletes.set(ctx, autocomplete) return autocomplete @@ -117,7 +123,10 @@ export async function registerAutoComplete( return const completionItems: UnoCompletionItem[] = [] - for (const [value, label] of result.suggestions) { + + const suggestions = result.suggestions.slice(0, maxItems) + + for (const [value, label] of suggestions) { const css = await getCSS(ctx!.uno, value) const colorString = getColorString(css) const itemKind = colorString ? CompletionItemKind.Color : CompletionItemKind.EnumMember @@ -175,6 +184,14 @@ export async function registerAutoComplete( registerProvider(), ) } + if (event.affectsConfiguration('unocss.autocomplete.matchType')) { + autoCompletes.clear() + matchType = workspace.getConfiguration().get('unocss.autocomplete.matchType', 'prefix') + } + if (event.affectsConfiguration('unocss.autocomplete.maxItems')) { + autoCompletes.clear() + maxItems = workspace.getConfiguration().get('unocss.autocomplete.maxItems', 1000) + } })) ext.subscriptions.push( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d8dc8ff1d..edde11f73d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -484,6 +484,9 @@ importers: packages/autocomplete: dependencies: + fzf: + specifier: ^0.5.2 + version: 0.5.2 lru-cache: specifier: ^10.0.0 version: 10.0.0 @@ -12342,6 +12345,10 @@ packages: resolution: {integrity: sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==} engines: {node: '>=10'} + /fzf@0.5.2: + resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + dev: false + /gauge@3.0.2: resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} engines: {node: '>=10'} diff --git a/test/autocomplete-fuzzy.test.ts b/test/autocomplete-fuzzy.test.ts new file mode 100644 index 0000000000..ae8a9e6e9f --- /dev/null +++ b/test/autocomplete-fuzzy.test.ts @@ -0,0 +1,46 @@ +import { createAutocomplete } from '@unocss/autocomplete' +import { createGenerator } from '@unocss/core' +import { describe, expect, it } from 'vitest' +import presetUno from '@unocss/preset-uno' + +describe('autocomplete-fuzzy', () => { + const uno = createGenerator({ + presets: [ + presetUno(), + ], + shortcuts: [ + { + 'foo': 'text-red', + 'foo-bar': 'text-red', + }, + [/^bg-mode-(.+)$/, ([, mode]) => `bg-blend-${mode}`, { autocomplete: ['bg-mode-(color|normal)'] }], + ], + }) + + const ac = createAutocomplete(uno, { + matchType: 'fuzzy', + }) + + it('static', async () => { + expect(await ac.suggest('itct')) + .includes('items-center') + + expect(await ac.suggest('jc')) + .includes('justify-center') + }) + + it('variant', async () => { + expect(await ac.suggest('tsm')) + .includes('text-sm') + + expect(await ac.suggest('tbl5')) + .includes('text-blue-500') + }) + + it('shortcuts', async () => { + expect(await ac.suggest('fb')) + .includes('foo-bar') + expect(await ac.suggest('bmc')) + .includes('bg-mode-color') + }) +})