Path: blob/main/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts
5283 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { distinct } from '../../../../base/common/arrays.js';6import { CancellationToken } from '../../../../base/common/cancellation.js';7import { IStringDictionary } from '../../../../base/common/collections.js';8import { IMatch, matchesBaseContiguousSubString, matchesContiguousSubString, matchesSubString, matchesWords } from '../../../../base/common/filters.js';9import { Disposable } from '../../../../base/common/lifecycle.js';10import * as strings from '../../../../base/common/strings.js';11import { TfIdfCalculator, TfIdfDocument } from '../../../../base/common/tfIdf.js';12import { IRange } from '../../../../editor/common/core/range.js';13import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';14import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';15import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';16import { IAiSettingsSearchService } from '../../../services/aiSettingsSearch/common/aiSettingsSearch.js';17import { IGroupFilter, ISearchResult, ISetting, ISettingMatch, ISettingMatcher, ISettingsEditorModel, ISettingsGroup, SettingKeyMatchTypes, SettingMatchType } from '../../../services/preferences/common/preferences.js';18import { nullRange } from '../../../services/preferences/common/preferencesModels.js';19import { EMBEDDINGS_SEARCH_PROVIDER_NAME, IAiSearchProvider, IPreferencesSearchService, IRemoteSearchProvider, ISearchProvider, IWorkbenchSettingsConfiguration, LLM_RANKED_SEARCH_PROVIDER_NAME, STRING_MATCH_SEARCH_PROVIDER_NAME, TF_IDF_SEARCH_PROVIDER_NAME } from '../common/preferences.js';2021export interface IEndpointDetails {22urlBase?: string;23key?: string;24}2526export class PreferencesSearchService extends Disposable implements IPreferencesSearchService {27declare readonly _serviceBrand: undefined;2829private _remoteSearchProvider: IRemoteSearchProvider | undefined;30private _aiSearchProvider: IAiSearchProvider | undefined;3132constructor(33@IInstantiationService private readonly instantiationService: IInstantiationService,34@IConfigurationService private readonly configurationService: IConfigurationService,35) {36super();37}3839getLocalSearchProvider(filter: string): LocalSearchProvider {40return this.instantiationService.createInstance(LocalSearchProvider, filter);41}4243private get remoteSearchAllowed(): boolean {44const workbenchSettings = this.configurationService.getValue<IWorkbenchSettingsConfiguration>().workbench.settings;45return workbenchSettings.enableNaturalLanguageSearch;46}4748getRemoteSearchProvider(filter: string): IRemoteSearchProvider | undefined {49if (!this.remoteSearchAllowed) {50return undefined;51}5253this._remoteSearchProvider ??= this.instantiationService.createInstance(RemoteSearchProvider);54this._remoteSearchProvider.setFilter(filter);55return this._remoteSearchProvider;56}5758getAiSearchProvider(filter: string): IAiSearchProvider | undefined {59if (!this.remoteSearchAllowed) {60return undefined;61}6263this._aiSearchProvider ??= this.instantiationService.createInstance(AiSearchProvider);64this._aiSearchProvider.setFilter(filter);65return this._aiSearchProvider;66}67}6869function cleanFilter(filter: string): string {70// Remove " and : which are likely to be copypasted as part of a setting name.71// Leave other special characters which the user might want to search for.72return filter73.replace(/[":]/g, ' ')74.replace(/ /g, ' ')75.trim();76}7778export class LocalSearchProvider implements ISearchProvider {79constructor(80private _filter: string,81@IConfigurationService private readonly configurationService: IConfigurationService82) {83this._filter = cleanFilter(this._filter);84}8586searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise<ISearchResult | null> {87if (!this._filter) {88return Promise.resolve(null);89}9091const settingMatcher: ISettingMatcher = (setting: ISetting) => {92let { matches, matchType, keyMatchScore } = new SettingMatches(93this._filter,94setting,95true,96this.configurationService97);98if (matchType === SettingMatchType.None || matches.length === 0) {99return null;100}101if (strings.equalsIgnoreCase(this._filter, setting.key)) {102matchType = SettingMatchType.ExactMatch;103}104return {105matches,106matchType,107keyMatchScore,108score: 0 // only used for RemoteSearchProvider matches.109};110};111112const filterMatches = preferencesModel.filterSettings(this._filter, this.getGroupFilter(this._filter), settingMatcher);113114// Check the top key match type.115const topKeyMatchType = Math.max(...filterMatches.map(m => (m.matchType & SettingKeyMatchTypes)));116// Always allow description matches as part of https://github.com/microsoft/vscode/issues/239936.117const alwaysAllowedMatchTypes = SettingMatchType.DescriptionOrValueMatch | SettingMatchType.LanguageTagSettingMatch;118const filteredMatches = filterMatches119.filter(m => (m.matchType & topKeyMatchType) || (m.matchType & alwaysAllowedMatchTypes) || m.matchType === SettingMatchType.ExactMatch)120.map(m => ({ ...m, providerName: STRING_MATCH_SEARCH_PROVIDER_NAME }));121return Promise.resolve({122filterMatches: filteredMatches,123exactMatch: filteredMatches.some(m => m.matchType === SettingMatchType.ExactMatch)124});125}126127private getGroupFilter(filter: string): IGroupFilter {128const regex = strings.createRegExp(filter, false, { global: true });129return (group: ISettingsGroup) => {130return group.id !== 'defaultOverrides' && regex.test(group.title);131};132}133}134135export class SettingMatches {136readonly matches: IRange[];137matchType: SettingMatchType = SettingMatchType.None;138/**139* A match score for key matches to allow comparing key matches against each other.140* Otherwise, all key matches are treated the same, and sorting is done by ToC order.141*/142keyMatchScore: number = 0;143144constructor(145searchString: string,146setting: ISetting,147private searchDescription: boolean,148private readonly configurationService: IConfigurationService149) {150this.matches = distinct(this._findMatchesInSetting(searchString, setting), (match) => `${match.startLineNumber}_${match.startColumn}_${match.endLineNumber}_${match.endColumn}_`);151}152153private _findMatchesInSetting(searchString: string, setting: ISetting): IRange[] {154const result = this._doFindMatchesInSetting(searchString, setting);155return result;156}157158private _keyToLabel(settingId: string): string {159const label = settingId160.replace(/[-._]/g, ' ')161.replace(/([a-z]+)([A-Z])/g, '$1 $2')162.replace(/([A-Za-z]+)(\d+)/g, '$1 $2')163.replace(/(\d+)([A-Za-z]+)/g, '$1 $2')164.toLowerCase();165return label;166}167168private _toAlphaNumeric(s: string): string {169return s.replace(/[^\p{L}\p{N}]+/gu, '');170}171172private _doFindMatchesInSetting(searchString: string, setting: ISetting): IRange[] {173const descriptionMatchingWords: Map<string, IRange[]> = new Map<string, IRange[]>();174const keyMatchingWords: Map<string, IRange[]> = new Map<string, IRange[]>();175const valueMatchingWords: Map<string, IRange[]> = new Map<string, IRange[]>();176177// Key (ID) search178// First, search by the setting's ID and label.179const settingKeyAsWords: string = this._keyToLabel(setting.key);180const queryWords = new Set<string>(searchString.split(' '));181for (const word of queryWords) {182// Check if the key contains the word. Use contiguous search.183const keyMatches = matchesWords(word, settingKeyAsWords, true);184if (keyMatches?.length) {185keyMatchingWords.set(word, keyMatches.map(match => this.toKeyRange(setting, match)));186}187}188if (keyMatchingWords.size === queryWords.size) {189// All words in the query matched with something in the setting key.190// Matches "edit format on paste" to "editor.formatOnPaste".191this.matchType |= SettingMatchType.AllWordsInSettingsLabel;192} else if (keyMatchingWords.size >= 2) {193// Matches "edit paste" to "editor.formatOnPaste".194// The if statement reduces noise by preventing "editor formatonpast" from matching all editor settings.195this.matchType |= SettingMatchType.ContiguousWordsInSettingsLabel;196this.keyMatchScore = keyMatchingWords.size;197}198const searchStringAlphaNumeric = this._toAlphaNumeric(searchString);199const keyAlphaNumeric = this._toAlphaNumeric(setting.key);200const keyIdMatches = matchesContiguousSubString(searchStringAlphaNumeric, keyAlphaNumeric);201if (keyIdMatches?.length) {202// Matches "editorformatonp" to "editor.formatonpaste".203keyMatchingWords.set(setting.key, keyIdMatches.map(match => this.toKeyRange(setting, match)));204this.matchType |= SettingMatchType.ContiguousQueryInSettingId;205}206207// Fall back to non-contiguous key (ID) searches if nothing matched yet.208if (this.matchType === SettingMatchType.None) {209keyMatchingWords.clear();210for (const word of queryWords) {211const keyMatches = matchesWords(word, settingKeyAsWords, false);212if (keyMatches?.length) {213keyMatchingWords.set(word, keyMatches.map(match => this.toKeyRange(setting, match)));214}215}216if (keyMatchingWords.size >= 2 || (keyMatchingWords.size === 1 && queryWords.size === 1)) {217// Matches "edforonpas" to "editor.formatOnPaste".218// The if statement reduces noise by preventing "editor fomonpast" from matching all editor settings.219this.matchType |= SettingMatchType.NonContiguousWordsInSettingsLabel;220this.keyMatchScore = keyMatchingWords.size;221} else {222const keyIdMatches = matchesSubString(searchStringAlphaNumeric, keyAlphaNumeric);223if (keyIdMatches?.length) {224// Matches "edfmonpas" to "editor.formatOnPaste".225keyMatchingWords.set(setting.key, keyIdMatches.map(match => this.toKeyRange(setting, match)));226this.matchType |= SettingMatchType.NonContiguousQueryInSettingId;227}228}229}230231// Check if the match was for a language tag group setting such as [markdown].232// In such a case, move that setting to be last.233if (setting.overrides?.length && (this.matchType !== SettingMatchType.None)) {234this.matchType = SettingMatchType.LanguageTagSettingMatch;235const keyRanges = keyMatchingWords.size ?236Array.from(keyMatchingWords.values()).flat() : [];237return [...keyRanges];238}239240// Description search241// Search the description if we found non-contiguous key matches at best.242const hasContiguousKeyMatchTypes = this.matchType >= SettingMatchType.ContiguousWordsInSettingsLabel;243if (this.searchDescription && !hasContiguousKeyMatchTypes) {244// Search the description lines and any additional keywords.245const searchableLines = setting.keywords?.length246? [...setting.description, setting.keywords.join(' ')]247: setting.description;248for (const word of queryWords) {249for (let lineIndex = 0; lineIndex < searchableLines.length; lineIndex++) {250const descriptionMatches = matchesBaseContiguousSubString(word, searchableLines[lineIndex]);251if (descriptionMatches?.length) {252descriptionMatchingWords.set(word, descriptionMatches.map(match => this.toDescriptionRange(setting, match, lineIndex)));253}254}255}256if (descriptionMatchingWords.size === queryWords.size) {257this.matchType |= SettingMatchType.DescriptionOrValueMatch;258} else {259// Clear out the match for now. We want to require all words to match in the description.260descriptionMatchingWords.clear();261}262}263264// Value search265// Check if the value contains all the words.266// Search the values if we found non-contiguous key matches at best.267if (!hasContiguousKeyMatchTypes) {268if (setting.enum?.length) {269// Search all string values of enums.270for (const option of setting.enum) {271if (typeof option !== 'string') {272continue;273}274valueMatchingWords.clear();275for (const word of queryWords) {276const valueMatches = matchesContiguousSubString(word, option);277if (valueMatches?.length) {278valueMatchingWords.set(word, valueMatches.map(match => this.toValueRange(setting, match)));279}280}281if (valueMatchingWords.size === queryWords.size) {282this.matchType |= SettingMatchType.DescriptionOrValueMatch;283break;284} else {285// Clear out the match for now. We want to require all words to match in the value.286valueMatchingWords.clear();287}288}289} else {290// Search single string value.291const settingValue = this.configurationService.getValue(setting.key);292if (typeof settingValue === 'string') {293for (const word of queryWords) {294const valueMatches = matchesContiguousSubString(word, settingValue);295if (valueMatches?.length) {296valueMatchingWords.set(word, valueMatches.map(match => this.toValueRange(setting, match)));297}298}299if (valueMatchingWords.size === queryWords.size) {300this.matchType |= SettingMatchType.DescriptionOrValueMatch;301} else {302// Clear out the match for now. We want to require all words to match in the value.303valueMatchingWords.clear();304}305}306}307}308309const descriptionRanges = descriptionMatchingWords.size ?310Array.from(descriptionMatchingWords.values()).flat() : [];311const keyRanges = keyMatchingWords.size ?312Array.from(keyMatchingWords.values()).flat() : [];313const valueRanges = valueMatchingWords.size ?314Array.from(valueMatchingWords.values()).flat() : [];315return [...descriptionRanges, ...keyRanges, ...valueRanges];316}317318private toKeyRange(setting: ISetting, match: IMatch): IRange {319return {320startLineNumber: setting.keyRange.startLineNumber,321startColumn: setting.keyRange.startColumn + match.start,322endLineNumber: setting.keyRange.startLineNumber,323endColumn: setting.keyRange.startColumn + match.end324};325}326327private toDescriptionRange(setting: ISetting, match: IMatch, lineIndex: number): IRange {328const descriptionRange = setting.descriptionRanges[lineIndex];329if (!descriptionRange) {330// This case occurs with added settings such as the331// manage extension setting.332return nullRange;333}334return {335startLineNumber: descriptionRange.startLineNumber,336startColumn: descriptionRange.startColumn + match.start,337endLineNumber: descriptionRange.endLineNumber,338endColumn: descriptionRange.startColumn + match.end339};340}341342private toValueRange(setting: ISetting, match: IMatch): IRange {343return {344startLineNumber: setting.valueRange.startLineNumber,345startColumn: setting.valueRange.startColumn + match.start + 1,346endLineNumber: setting.valueRange.startLineNumber,347endColumn: setting.valueRange.startColumn + match.end + 1348};349}350}351352class SettingsRecordProvider {353private _settingsRecord: IStringDictionary<ISetting> = {};354private _currentPreferencesModel: ISettingsEditorModel | undefined;355356constructor() { }357358updateModel(preferencesModel: ISettingsEditorModel) {359if (preferencesModel === this._currentPreferencesModel) {360return;361}362363this._currentPreferencesModel = preferencesModel;364this.refresh();365}366367private refresh() {368this._settingsRecord = {};369370if (!this._currentPreferencesModel) {371return;372}373374for (const group of this._currentPreferencesModel.settingsGroups) {375if (group.id === 'mostCommonlyUsed') {376continue;377}378for (const section of group.sections) {379for (const setting of section.settings) {380this._settingsRecord[setting.key] = setting;381}382}383}384}385386getSettingsRecord(): IStringDictionary<ISetting> {387return this._settingsRecord;388}389}390391class EmbeddingsSearchProvider implements IRemoteSearchProvider {392private static readonly EMBEDDINGS_SETTINGS_SEARCH_MAX_PICKS = 10;393394private readonly _recordProvider: SettingsRecordProvider;395private _filter: string = '';396397constructor(398private readonly _aiSettingsSearchService: IAiSettingsSearchService399) {400this._recordProvider = new SettingsRecordProvider();401}402403setFilter(filter: string) {404this._filter = cleanFilter(filter);405}406407async searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise<ISearchResult | null> {408if (!this._filter || !this._aiSettingsSearchService.isEnabled()) {409return null;410}411412this._recordProvider.updateModel(preferencesModel);413this._aiSettingsSearchService.startSearch(this._filter, token);414415return {416filterMatches: await this.getEmbeddingsItems(token),417exactMatch: false418};419}420421private async getEmbeddingsItems(token: CancellationToken): Promise<ISettingMatch[]> {422const settingsRecord = this._recordProvider.getSettingsRecord();423const filterMatches: ISettingMatch[] = [];424const settings = await this._aiSettingsSearchService.getEmbeddingsResults(this._filter, token);425if (!settings) {426return [];427}428429const providerName = EMBEDDINGS_SEARCH_PROVIDER_NAME;430for (const settingKey of settings) {431if (filterMatches.length === EmbeddingsSearchProvider.EMBEDDINGS_SETTINGS_SEARCH_MAX_PICKS) {432break;433}434filterMatches.push({435setting: settingsRecord[settingKey],436matches: [settingsRecord[settingKey].range],437matchType: SettingMatchType.RemoteMatch,438keyMatchScore: 0,439score: 0, // the results are sorted upstream.440providerName441});442}443444return filterMatches;445}446}447448class TfIdfSearchProvider implements IRemoteSearchProvider {449private static readonly TF_IDF_PRE_NORMALIZE_THRESHOLD = 50;450private static readonly TF_IDF_POST_NORMALIZE_THRESHOLD = 0.7;451private static readonly TF_IDF_MAX_PICKS = 5;452453private _currentPreferencesModel: ISettingsEditorModel | undefined;454private _filter: string = '';455private _documents: TfIdfDocument[] = [];456private _settingsRecord: IStringDictionary<ISetting> = {};457458constructor() {459}460461setFilter(filter: string) {462this._filter = cleanFilter(filter);463}464465keyToLabel(settingId: string): string {466const label = settingId467.replace(/[-._]/g, ' ')468.replace(/([a-z]+)([A-Z])/g, '$1 $2')469.replace(/([A-Za-z]+)(\d+)/g, '$1 $2')470.replace(/(\d+)([A-Za-z]+)/g, '$1 $2')471.toLowerCase();472return label;473}474475settingItemToEmbeddingString(item: ISetting): string {476let result = `Setting Id: ${item.key}\n`;477result += `Label: ${this.keyToLabel(item.key)}\n`;478result += `Description: ${item.description}\n`;479return result;480}481482async searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise<ISearchResult | null> {483if (!this._filter) {484return null;485}486487if (this._currentPreferencesModel !== preferencesModel) {488// Refresh the documents and settings record489this._currentPreferencesModel = preferencesModel;490this._documents = [];491this._settingsRecord = {};492for (const group of preferencesModel.settingsGroups) {493if (group.id === 'mostCommonlyUsed') {494continue;495}496for (const section of group.sections) {497for (const setting of section.settings) {498this._documents.push({499key: setting.key,500textChunks: [this.settingItemToEmbeddingString(setting)]501});502this._settingsRecord[setting.key] = setting;503}504}505}506}507508return {509filterMatches: await this.getTfIdfItems(token),510exactMatch: false511};512}513514private async getTfIdfItems(token: CancellationToken): Promise<ISettingMatch[]> {515const filterMatches: ISettingMatch[] = [];516const tfIdfCalculator = new TfIdfCalculator();517tfIdfCalculator.updateDocuments(this._documents);518const tfIdfRankings = tfIdfCalculator.calculateScores(this._filter, token);519tfIdfRankings.sort((a, b) => b.score - a.score);520const maxScore = tfIdfRankings[0].score;521522if (maxScore < TfIdfSearchProvider.TF_IDF_PRE_NORMALIZE_THRESHOLD) {523// Reject all the matches.524return [];525}526527for (const info of tfIdfRankings) {528if (info.score / maxScore < TfIdfSearchProvider.TF_IDF_POST_NORMALIZE_THRESHOLD || filterMatches.length === TfIdfSearchProvider.TF_IDF_MAX_PICKS) {529break;530}531const pick = info.key;532filterMatches.push({533setting: this._settingsRecord[pick],534matches: [this._settingsRecord[pick].range],535matchType: SettingMatchType.RemoteMatch,536keyMatchScore: 0,537score: info.score,538providerName: TF_IDF_SEARCH_PROVIDER_NAME539});540}541542return filterMatches;543}544}545546class RemoteSearchProvider implements IRemoteSearchProvider {547private _tfIdfSearchProvider: TfIdfSearchProvider;548private _filter: string = '';549550constructor() {551this._tfIdfSearchProvider = new TfIdfSearchProvider();552}553554setFilter(filter: string): void {555this._filter = filter;556this._tfIdfSearchProvider.setFilter(filter);557}558559async searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise<ISearchResult | null> {560if (!this._filter) {561return null;562}563564const results = await this._tfIdfSearchProvider.searchModel(preferencesModel, token);565return results;566}567}568569class AiSearchProvider implements IAiSearchProvider {570private readonly _embeddingsSearchProvider: EmbeddingsSearchProvider;571private readonly _recordProvider: SettingsRecordProvider;572private _filter: string = '';573574constructor(575@IAiSettingsSearchService private readonly aiSettingsSearchService: IAiSettingsSearchService576) {577this._embeddingsSearchProvider = new EmbeddingsSearchProvider(this.aiSettingsSearchService);578this._recordProvider = new SettingsRecordProvider();579}580581setFilter(filter: string): void {582this._filter = filter;583this._embeddingsSearchProvider.setFilter(filter);584}585586async searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise<ISearchResult | null> {587if (!this._filter || !this.aiSettingsSearchService.isEnabled()) {588return null;589}590591this._recordProvider.updateModel(preferencesModel);592const results = await this._embeddingsSearchProvider.searchModel(preferencesModel, token);593return results;594}595596async getLLMRankedResults(token: CancellationToken): Promise<ISearchResult | null> {597if (!this._filter || !this.aiSettingsSearchService.isEnabled()) {598return null;599}600601const items = await this.getLLMRankedItems(token);602return {603filterMatches: items,604exactMatch: false605};606}607608private async getLLMRankedItems(token: CancellationToken): Promise<ISettingMatch[]> {609const settingsRecord = this._recordProvider.getSettingsRecord();610const filterMatches: ISettingMatch[] = [];611const settings = await this.aiSettingsSearchService.getLLMRankedResults(this._filter, token);612if (!settings) {613return [];614}615616for (const settingKey of settings) {617if (!settingsRecord[settingKey]) {618// Non-existent setting.619continue;620}621filterMatches.push({622setting: settingsRecord[settingKey],623matches: [settingsRecord[settingKey].range],624matchType: SettingMatchType.RemoteMatch,625keyMatchScore: 0,626score: 0, // the results are sorted upstream.627providerName: LLM_RANKED_SEARCH_PROVIDER_NAME628});629}630631return filterMatches;632}633}634635registerSingleton(IPreferencesSearchService, PreferencesSearchService, InstantiationType.Delayed);636637638