Path: blob/main/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts
3296 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, 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 { IExtensionManagementService, ILocalExtension } from '../../../../platform/extensionManagement/common/extensionManagement.js';15import { ExtensionType } from '../../../../platform/extensions/common/extensions.js';16import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';17import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';18import { IAiSettingsSearchService } from '../../../services/aiSettingsSearch/common/aiSettingsSearch.js';19import { IWorkbenchExtensionEnablementService } from '../../../services/extensionManagement/common/extensionManagement.js';20import { IGroupFilter, ISearchResult, ISetting, ISettingMatch, ISettingMatcher, ISettingsEditorModel, ISettingsGroup, SettingKeyMatchTypes, SettingMatchType } from '../../../services/preferences/common/preferences.js';21import { nullRange } from '../../../services/preferences/common/preferencesModels.js';22import { EMBEDDINGS_ONLY_SEARCH_PROVIDER_NAME, 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';2324export interface IEndpointDetails {25urlBase?: string;26key?: string;27}2829export class PreferencesSearchService extends Disposable implements IPreferencesSearchService {30declare readonly _serviceBrand: undefined;3132// @ts-expect-error disable remote search for now, ref https://github.com/microsoft/vscode/issues/17241133private _installedExtensions: Promise<ILocalExtension[]>;34private _remoteSearchProvider: IRemoteSearchProvider | undefined;35private _aiSearchProvider: IAiSearchProvider | undefined;3637constructor(38@IInstantiationService private readonly instantiationService: IInstantiationService,39@IConfigurationService private readonly configurationService: IConfigurationService,40@IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService,41@IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService42) {43super();4445// This request goes to the shared process but results won't change during a window's lifetime, so cache the results.46this._installedExtensions = this.extensionManagementService.getInstalled(ExtensionType.User).then(exts => {47// Filter to enabled extensions that have settings48return exts49.filter(ext => this.extensionEnablementService.isEnabled(ext))50.filter(ext => ext.manifest && ext.manifest.contributes && ext.manifest.contributes.configuration)51.filter(ext => !!ext.identifier.uuid);52});53}5455getLocalSearchProvider(filter: string): LocalSearchProvider {56return this.instantiationService.createInstance(LocalSearchProvider, filter);57}5859private get remoteSearchAllowed(): boolean {60const workbenchSettings = this.configurationService.getValue<IWorkbenchSettingsConfiguration>().workbench.settings;61return workbenchSettings.enableNaturalLanguageSearch;62}6364getRemoteSearchProvider(filter: string): IRemoteSearchProvider | undefined {65if (!this.remoteSearchAllowed) {66return undefined;67}6869this._remoteSearchProvider ??= this.instantiationService.createInstance(RemoteSearchProvider);70this._remoteSearchProvider.setFilter(filter);71return this._remoteSearchProvider;72}7374getAiSearchProvider(filter: string): IAiSearchProvider | undefined {75if (!this.remoteSearchAllowed) {76return undefined;77}7879this._aiSearchProvider ??= this.instantiationService.createInstance(AiSearchProvider);80this._aiSearchProvider.setFilter(filter);81return this._aiSearchProvider;82}83}8485function cleanFilter(filter: string): string {86// Remove " and : which are likely to be copypasted as part of a setting name.87// Leave other special characters which the user might want to search for.88return filter89.replace(/[":]/g, ' ')90.replace(/ /g, ' ')91.trim();92}9394export class LocalSearchProvider implements ISearchProvider {95constructor(96private _filter: string,97@IConfigurationService private readonly configurationService: IConfigurationService98) {99this._filter = cleanFilter(this._filter);100}101102searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise<ISearchResult | null> {103if (!this._filter) {104return Promise.resolve(null);105}106107const settingMatcher: ISettingMatcher = (setting: ISetting) => {108let { matches, matchType, keyMatchScore } = new SettingMatches(109this._filter,110setting,111true,112this.configurationService113);114if (matchType === SettingMatchType.None || matches.length === 0) {115return null;116}117if (strings.equalsIgnoreCase(this._filter, setting.key)) {118matchType = SettingMatchType.ExactMatch;119}120return {121matches,122matchType,123keyMatchScore,124score: 0 // only used for RemoteSearchProvider matches.125};126};127128const filterMatches = preferencesModel.filterSettings(this._filter, this.getGroupFilter(this._filter), settingMatcher);129130// Check the top key match type.131const topKeyMatchType = Math.max(...filterMatches.map(m => (m.matchType & SettingKeyMatchTypes)));132// Always allow description matches as part of https://github.com/microsoft/vscode/issues/239936.133const alwaysAllowedMatchTypes = SettingMatchType.DescriptionOrValueMatch | SettingMatchType.LanguageTagSettingMatch;134const filteredMatches = filterMatches135.filter(m => (m.matchType & topKeyMatchType) || (m.matchType & alwaysAllowedMatchTypes) || m.matchType === SettingMatchType.ExactMatch)136.map(m => ({ ...m, providerName: STRING_MATCH_SEARCH_PROVIDER_NAME }));137return Promise.resolve({138filterMatches: filteredMatches,139exactMatch: filteredMatches.some(m => m.matchType === SettingMatchType.ExactMatch)140});141}142143private getGroupFilter(filter: string): IGroupFilter {144const regex = strings.createRegExp(filter, false, { global: true });145return (group: ISettingsGroup) => {146return group.id !== 'defaultOverrides' && regex.test(group.title);147};148}149}150151export class SettingMatches {152readonly matches: IRange[];153matchType: SettingMatchType = SettingMatchType.None;154/**155* A match score for key matches to allow comparing key matches against each other.156* Otherwise, all key matches are treated the same, and sorting is done by ToC order.157*/158keyMatchScore: number = 0;159160constructor(161searchString: string,162setting: ISetting,163private searchDescription: boolean,164private readonly configurationService: IConfigurationService165) {166this.matches = distinct(this._findMatchesInSetting(searchString, setting), (match) => `${match.startLineNumber}_${match.startColumn}_${match.endLineNumber}_${match.endColumn}_`);167}168169private _findMatchesInSetting(searchString: string, setting: ISetting): IRange[] {170const result = this._doFindMatchesInSetting(searchString, setting);171return result;172}173174private _keyToLabel(settingId: string): string {175const label = settingId176.replace(/[-._]/g, ' ')177.replace(/([a-z]+)([A-Z])/g, '$1 $2')178.replace(/([A-Za-z]+)(\d+)/g, '$1 $2')179.replace(/(\d+)([A-Za-z]+)/g, '$1 $2')180.toLowerCase();181return label;182}183184private _toAlphaNumeric(s: string): string {185return s.replace(/[^\p{L}\p{N}]+/gu, '');186}187188private _doFindMatchesInSetting(searchString: string, setting: ISetting): IRange[] {189const descriptionMatchingWords: Map<string, IRange[]> = new Map<string, IRange[]>();190const keyMatchingWords: Map<string, IRange[]> = new Map<string, IRange[]>();191const valueMatchingWords: Map<string, IRange[]> = new Map<string, IRange[]>();192193// Key (ID) search194// First, search by the setting's ID and label.195const settingKeyAsWords: string = this._keyToLabel(setting.key);196const queryWords = new Set<string>(searchString.split(' '));197for (const word of queryWords) {198// Check if the key contains the word. Use contiguous search.199const keyMatches = matchesWords(word, settingKeyAsWords, true);200if (keyMatches?.length) {201keyMatchingWords.set(word, keyMatches.map(match => this.toKeyRange(setting, match)));202}203}204if (keyMatchingWords.size === queryWords.size) {205// All words in the query matched with something in the setting key.206// Matches "edit format on paste" to "editor.formatOnPaste".207this.matchType |= SettingMatchType.AllWordsInSettingsLabel;208} else if (keyMatchingWords.size >= 2) {209// Matches "edit paste" to "editor.formatOnPaste".210// The if statement reduces noise by preventing "editor formatonpast" from matching all editor settings.211this.matchType |= SettingMatchType.ContiguousWordsInSettingsLabel;212this.keyMatchScore = keyMatchingWords.size;213}214const searchStringAlphaNumeric = this._toAlphaNumeric(searchString);215const keyAlphaNumeric = this._toAlphaNumeric(setting.key);216const keyIdMatches = matchesContiguousSubString(searchStringAlphaNumeric, keyAlphaNumeric);217if (keyIdMatches?.length) {218// Matches "editorformatonp" to "editor.formatonpaste".219keyMatchingWords.set(setting.key, keyIdMatches.map(match => this.toKeyRange(setting, match)));220this.matchType |= SettingMatchType.ContiguousQueryInSettingId;221}222223// Fall back to non-contiguous key (ID) searches if nothing matched yet.224if (this.matchType === SettingMatchType.None) {225keyMatchingWords.clear();226for (const word of queryWords) {227const keyMatches = matchesWords(word, settingKeyAsWords, false);228if (keyMatches?.length) {229keyMatchingWords.set(word, keyMatches.map(match => this.toKeyRange(setting, match)));230}231}232if (keyMatchingWords.size >= 2 || (keyMatchingWords.size === 1 && queryWords.size === 1)) {233// Matches "edforonpas" to "editor.formatOnPaste".234// The if statement reduces noise by preventing "editor fomonpast" from matching all editor settings.235this.matchType |= SettingMatchType.NonContiguousWordsInSettingsLabel;236this.keyMatchScore = keyMatchingWords.size;237} else {238const keyIdMatches = matchesSubString(searchStringAlphaNumeric, keyAlphaNumeric);239if (keyIdMatches?.length) {240// Matches "edfmonpas" to "editor.formatOnPaste".241keyMatchingWords.set(setting.key, keyIdMatches.map(match => this.toKeyRange(setting, match)));242this.matchType |= SettingMatchType.NonContiguousQueryInSettingId;243}244}245}246247// Check if the match was for a language tag group setting such as [markdown].248// In such a case, move that setting to be last.249if (setting.overrides?.length && (this.matchType !== SettingMatchType.None)) {250this.matchType = SettingMatchType.LanguageTagSettingMatch;251const keyRanges = keyMatchingWords.size ?252Array.from(keyMatchingWords.values()).flat() : [];253return [...keyRanges];254}255256// Description search257// Search the description if we found non-contiguous key matches at best.258const hasContiguousKeyMatchTypes = this.matchType >= SettingMatchType.ContiguousWordsInSettingsLabel;259if (this.searchDescription && !hasContiguousKeyMatchTypes) {260for (const word of queryWords) {261// Search the description lines.262for (let lineIndex = 0; lineIndex < setting.description.length; lineIndex++) {263const descriptionMatches = matchesContiguousSubString(word, setting.description[lineIndex]);264if (descriptionMatches?.length) {265descriptionMatchingWords.set(word, descriptionMatches.map(match => this.toDescriptionRange(setting, match, lineIndex)));266}267}268}269if (descriptionMatchingWords.size === queryWords.size) {270this.matchType |= SettingMatchType.DescriptionOrValueMatch;271} else {272// Clear out the match for now. We want to require all words to match in the description.273descriptionMatchingWords.clear();274}275}276277// Value search278// Check if the value contains all the words.279// Search the values if we found non-contiguous key matches at best.280if (!hasContiguousKeyMatchTypes) {281if (setting.enum?.length) {282// Search all string values of enums.283for (const option of setting.enum) {284if (typeof option !== 'string') {285continue;286}287valueMatchingWords.clear();288for (const word of queryWords) {289const valueMatches = matchesContiguousSubString(word, option);290if (valueMatches?.length) {291valueMatchingWords.set(word, valueMatches.map(match => this.toValueRange(setting, match)));292}293}294if (valueMatchingWords.size === queryWords.size) {295this.matchType |= SettingMatchType.DescriptionOrValueMatch;296break;297} else {298// Clear out the match for now. We want to require all words to match in the value.299valueMatchingWords.clear();300}301}302} else {303// Search single string value.304const settingValue = this.configurationService.getValue(setting.key);305if (typeof settingValue === 'string') {306for (const word of queryWords) {307const valueMatches = matchesContiguousSubString(word, settingValue);308if (valueMatches?.length) {309valueMatchingWords.set(word, valueMatches.map(match => this.toValueRange(setting, match)));310}311}312if (valueMatchingWords.size === queryWords.size) {313this.matchType |= SettingMatchType.DescriptionOrValueMatch;314} else {315// Clear out the match for now. We want to require all words to match in the value.316valueMatchingWords.clear();317}318}319}320}321322const descriptionRanges = descriptionMatchingWords.size ?323Array.from(descriptionMatchingWords.values()).flat() : [];324const keyRanges = keyMatchingWords.size ?325Array.from(keyMatchingWords.values()).flat() : [];326const valueRanges = valueMatchingWords.size ?327Array.from(valueMatchingWords.values()).flat() : [];328return [...descriptionRanges, ...keyRanges, ...valueRanges];329}330331private toKeyRange(setting: ISetting, match: IMatch): IRange {332return {333startLineNumber: setting.keyRange.startLineNumber,334startColumn: setting.keyRange.startColumn + match.start,335endLineNumber: setting.keyRange.startLineNumber,336endColumn: setting.keyRange.startColumn + match.end337};338}339340private toDescriptionRange(setting: ISetting, match: IMatch, lineIndex: number): IRange {341const descriptionRange = setting.descriptionRanges[lineIndex];342if (!descriptionRange) {343// This case occurs with added settings such as the344// manage extension setting.345return nullRange;346}347return {348startLineNumber: descriptionRange.startLineNumber,349startColumn: descriptionRange.startColumn + match.start,350endLineNumber: descriptionRange.endLineNumber,351endColumn: descriptionRange.startColumn + match.end352};353}354355private toValueRange(setting: ISetting, match: IMatch): IRange {356return {357startLineNumber: setting.valueRange.startLineNumber,358startColumn: setting.valueRange.startColumn + match.start + 1,359endLineNumber: setting.valueRange.startLineNumber,360endColumn: setting.valueRange.startColumn + match.end + 1361};362}363}364365class SettingsRecordProvider {366private _settingsRecord: IStringDictionary<ISetting> = {};367private _currentPreferencesModel: ISettingsEditorModel | undefined;368369constructor() { }370371updateModel(preferencesModel: ISettingsEditorModel) {372if (preferencesModel === this._currentPreferencesModel) {373return;374}375376this._currentPreferencesModel = preferencesModel;377this.refresh();378}379380private refresh() {381this._settingsRecord = {};382383if (!this._currentPreferencesModel) {384return;385}386387for (const group of this._currentPreferencesModel.settingsGroups) {388if (group.id === 'mostCommonlyUsed') {389continue;390}391for (const section of group.sections) {392for (const setting of section.settings) {393this._settingsRecord[setting.key] = setting;394}395}396}397}398399getSettingsRecord(): IStringDictionary<ISetting> {400return this._settingsRecord;401}402}403404class EmbeddingsSearchProvider implements IRemoteSearchProvider {405private static readonly EMBEDDINGS_SETTINGS_SEARCH_MAX_PICKS = 10;406407private readonly _recordProvider: SettingsRecordProvider;408private _filter: string = '';409410constructor(411private readonly _aiSettingsSearchService: IAiSettingsSearchService,412private readonly _excludeSelectionStep: boolean413) {414this._recordProvider = new SettingsRecordProvider();415}416417setFilter(filter: string) {418this._filter = cleanFilter(filter);419}420421async searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise<ISearchResult | null> {422if (!this._filter || !this._aiSettingsSearchService.isEnabled()) {423return null;424}425426this._recordProvider.updateModel(preferencesModel);427this._aiSettingsSearchService.startSearch(this._filter, this._excludeSelectionStep, token);428429return {430filterMatches: await this.getEmbeddingsItems(token),431exactMatch: false432};433}434435private async getEmbeddingsItems(token: CancellationToken): Promise<ISettingMatch[]> {436const settingsRecord = this._recordProvider.getSettingsRecord();437const filterMatches: ISettingMatch[] = [];438const settings = await this._aiSettingsSearchService.getEmbeddingsResults(this._filter, token);439if (!settings) {440return [];441}442443const providerName = this._excludeSelectionStep ? EMBEDDINGS_ONLY_SEARCH_PROVIDER_NAME : EMBEDDINGS_SEARCH_PROVIDER_NAME;444for (const settingKey of settings) {445if (filterMatches.length === EmbeddingsSearchProvider.EMBEDDINGS_SETTINGS_SEARCH_MAX_PICKS) {446break;447}448filterMatches.push({449setting: settingsRecord[settingKey],450matches: [settingsRecord[settingKey].range],451matchType: SettingMatchType.RemoteMatch,452keyMatchScore: 0,453score: 0, // the results are sorted upstream.454providerName455});456}457458return filterMatches;459}460}461462class TfIdfSearchProvider implements IRemoteSearchProvider {463private static readonly TF_IDF_PRE_NORMALIZE_THRESHOLD = 50;464private static readonly TF_IDF_POST_NORMALIZE_THRESHOLD = 0.7;465private static readonly TF_IDF_MAX_PICKS = 5;466467private _currentPreferencesModel: ISettingsEditorModel | undefined;468private _filter: string = '';469private _documents: TfIdfDocument[] = [];470private _settingsRecord: IStringDictionary<ISetting> = {};471472constructor() {473}474475setFilter(filter: string) {476this._filter = cleanFilter(filter);477}478479keyToLabel(settingId: string): string {480const label = settingId481.replace(/[-._]/g, ' ')482.replace(/([a-z]+)([A-Z])/g, '$1 $2')483.replace(/([A-Za-z]+)(\d+)/g, '$1 $2')484.replace(/(\d+)([A-Za-z]+)/g, '$1 $2')485.toLowerCase();486return label;487}488489settingItemToEmbeddingString(item: ISetting): string {490let result = `Setting Id: ${item.key}\n`;491result += `Label: ${this.keyToLabel(item.key)}\n`;492result += `Description: ${item.description}\n`;493return result;494}495496async searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise<ISearchResult | null> {497if (!this._filter) {498return null;499}500501if (this._currentPreferencesModel !== preferencesModel) {502// Refresh the documents and settings record503this._currentPreferencesModel = preferencesModel;504this._documents = [];505this._settingsRecord = {};506for (const group of preferencesModel.settingsGroups) {507if (group.id === 'mostCommonlyUsed') {508continue;509}510for (const section of group.sections) {511for (const setting of section.settings) {512this._documents.push({513key: setting.key,514textChunks: [this.settingItemToEmbeddingString(setting)]515});516this._settingsRecord[setting.key] = setting;517}518}519}520}521522return {523filterMatches: await this.getTfIdfItems(token),524exactMatch: false525};526}527528private async getTfIdfItems(token: CancellationToken): Promise<ISettingMatch[]> {529const filterMatches: ISettingMatch[] = [];530const tfIdfCalculator = new TfIdfCalculator();531tfIdfCalculator.updateDocuments(this._documents);532const tfIdfRankings = tfIdfCalculator.calculateScores(this._filter, token);533tfIdfRankings.sort((a, b) => b.score - a.score);534const maxScore = tfIdfRankings[0].score;535536if (maxScore < TfIdfSearchProvider.TF_IDF_PRE_NORMALIZE_THRESHOLD) {537// Reject all the matches.538return [];539}540541for (const info of tfIdfRankings) {542if (info.score / maxScore < TfIdfSearchProvider.TF_IDF_POST_NORMALIZE_THRESHOLD || filterMatches.length === TfIdfSearchProvider.TF_IDF_MAX_PICKS) {543break;544}545const pick = info.key;546filterMatches.push({547setting: this._settingsRecord[pick],548matches: [this._settingsRecord[pick].range],549matchType: SettingMatchType.RemoteMatch,550keyMatchScore: 0,551score: info.score,552providerName: TF_IDF_SEARCH_PROVIDER_NAME553});554}555556return filterMatches;557}558}559560class RemoteSearchProvider implements IRemoteSearchProvider {561private _tfIdfSearchProvider: TfIdfSearchProvider;562private _filter: string = '';563564constructor() {565this._tfIdfSearchProvider = new TfIdfSearchProvider();566}567568setFilter(filter: string): void {569this._filter = filter;570this._tfIdfSearchProvider.setFilter(filter);571}572573async searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise<ISearchResult | null> {574if (!this._filter) {575return null;576}577578const results = await this._tfIdfSearchProvider.searchModel(preferencesModel, token);579return results;580}581}582583class AiSearchProvider implements IAiSearchProvider {584private readonly _embeddingsSearchProvider: EmbeddingsSearchProvider;585private readonly _recordProvider: SettingsRecordProvider;586private _filter: string = '';587588constructor(589@IAiSettingsSearchService private readonly aiSettingsSearchService: IAiSettingsSearchService590) {591this._embeddingsSearchProvider = new EmbeddingsSearchProvider(this.aiSettingsSearchService, false);592this._recordProvider = new SettingsRecordProvider();593}594595setFilter(filter: string): void {596this._filter = filter;597this._embeddingsSearchProvider.setFilter(filter);598}599600async searchModel(preferencesModel: ISettingsEditorModel, token: CancellationToken): Promise<ISearchResult | null> {601if (!this._filter || !this.aiSettingsSearchService.isEnabled()) {602return null;603}604605this._recordProvider.updateModel(preferencesModel);606const results = await this._embeddingsSearchProvider.searchModel(preferencesModel, token);607return results;608}609610async getLLMRankedResults(token: CancellationToken): Promise<ISearchResult | null> {611if (!this._filter || !this.aiSettingsSearchService.isEnabled()) {612return null;613}614615const items = await this.getLLMRankedItems(token);616return {617filterMatches: items,618exactMatch: false619};620}621622private async getLLMRankedItems(token: CancellationToken): Promise<ISettingMatch[]> {623const settingsRecord = this._recordProvider.getSettingsRecord();624const filterMatches: ISettingMatch[] = [];625const settings = await this.aiSettingsSearchService.getLLMRankedResults(this._filter, token);626if (!settings) {627return [];628}629630for (const settingKey of settings) {631if (!settingsRecord[settingKey]) {632// Non-existent setting.633continue;634}635filterMatches.push({636setting: settingsRecord[settingKey],637matches: [settingsRecord[settingKey].range],638matchType: SettingMatchType.RemoteMatch,639keyMatchScore: 0,640score: 0, // the results are sorted upstream.641providerName: LLM_RANKED_SEARCH_PROVIDER_NAME642});643}644645return filterMatches;646}647}648649registerSingleton(IPreferencesSearchService, PreferencesSearchService, InstantiationType.Delayed);650651652