diff --git a/package-lock.json b/package-lock.json index 8fb100e7c..6f0c474f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@gravity-ui/paranoid": "^1.4.1", "@gravity-ui/react-data-table": "^2.0.1", "@gravity-ui/uikit": "^6.10.2", - "@gravity-ui/websql-autocomplete": "^8.0.2", + "@gravity-ui/websql-autocomplete": "^8.1.0", "@reduxjs/toolkit": "^2.2.3", "axios": "^1.6.8", "colord": "^2.9.3", @@ -3969,12 +3969,12 @@ } }, "node_modules/@gravity-ui/websql-autocomplete": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@gravity-ui/websql-autocomplete/-/websql-autocomplete-8.0.2.tgz", - "integrity": "sha512-Oj0xQU5rSucPxtru0WwUXtnBHq3E6fQJ+Ge11J5qWoFWfALKfJU8xhvfrTWCxwZIC79iqSyhBOHbPvN3kpaX1w==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@gravity-ui/websql-autocomplete/-/websql-autocomplete-8.1.0.tgz", + "integrity": "sha512-KHXOrikdszTfOuoEvtBjrsdjrcYAxgi/rfmCkDV8/dNrbvbtUaxvIlFJHka7cRwgZabh0+j7ImjYWS9Pb3fvFA==", "dependencies": { - "antlr4-c3": "~3.3.5", - "antlr4ng": "^2.0.10" + "antlr4-c3": "^3.4.1", + "antlr4ng": "^3.0.4" }, "engines": { "node": ">=16.0" @@ -6903,25 +6903,25 @@ } }, "node_modules/antlr4-c3": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/antlr4-c3/-/antlr4-c3-3.3.7.tgz", - "integrity": "sha512-F3ndE38wwA6z6AjUbL3heSdEGl4TxulGDPf9xB0/IY4dbRHWBh6XNaqFwur8vHKQk9FS5yNABHeg2wqlqIYO0w==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/antlr4-c3/-/antlr4-c3-3.4.1.tgz", + "integrity": "sha512-YLO/ncwUp8w2GNK/lnsYXtMkd8izHCWjxtk7EaTGIZq07THfvI5aHDuhls/RctX3EYDlM9zeTKdqn54eLYNglQ==", "dependencies": { - "antlr4ng": "2.0.11" + "antlr4ng": "^3.0.1" } }, "node_modules/antlr4ng": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/antlr4ng/-/antlr4ng-2.0.11.tgz", - "integrity": "sha512-9jM91VVtHSqHkAHQsXHaoaiewFETMvUTI1/tXvwTiFw4f7zke3IGlwEyoKN9NS0FqIwDKFvUNW2e1cKPniTkVQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/antlr4ng/-/antlr4ng-3.0.4.tgz", + "integrity": "sha512-u1Ww6wVv9hq70E9AaYe5qW3ba8hvnjJdO3ZsKnb3iJWFV/medLEEhbyWwXCvvD2ef0ptdaiIUgmaazS/WE6uyQ==", "peerDependencies": { - "antlr4ng-cli": "1.0.7" + "antlr4ng-cli": "^2.0.0" } }, "node_modules/antlr4ng-cli": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/antlr4ng-cli/-/antlr4ng-cli-1.0.7.tgz", - "integrity": "sha512-qN2FsDBmLvsQcA5CWTrPz8I8gNXeS1fgXBBhI78VyxBSBV/EJgqy8ks6IDTC9jyugpl40csCQ4sL5K4i2YZ/2w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/antlr4ng-cli/-/antlr4ng-cli-2.0.0.tgz", + "integrity": "sha512-oAt5OSSYhRQn1PgahtpAP4Vp3BApCoCqlzX7Q8ZUWWls4hX59ryYuu0t7Hwrnfk796OxP/vgIJaqxdltd/oEvQ==", "peer": true, "bin": { "antlr4ng": "index.js" diff --git a/package.json b/package.json index 768d68f4d..100b33f72 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@gravity-ui/paranoid": "^1.4.1", "@gravity-ui/react-data-table": "^2.0.1", "@gravity-ui/uikit": "^6.10.2", - "@gravity-ui/websql-autocomplete": "^8.0.2", + "@gravity-ui/websql-autocomplete": "^8.1.0", "@reduxjs/toolkit": "^2.2.3", "axios": "^1.6.8", "colord": "^2.9.3", diff --git a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx index f7fb431eb..ec2ff76da 100644 --- a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx +++ b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx @@ -30,6 +30,7 @@ import { SAVED_QUERIES_KEY, } from '../../../../utils/constants'; import {useQueryModes, useSetting} from '../../../../utils/hooks'; +import {LANGUAGE_YQL_ID} from '../../../../utils/monaco/yql/constants'; import {QUERY_ACTIONS} from '../../../../utils/query'; import {parseJson} from '../../../../utils/utils'; import type {InitialPaneState} from '../../utils/paneVisibilityToggleHelpers'; @@ -412,7 +413,7 @@ function QueryEditor(props: QueryEditorProps) {
(() => { const useAutocomplete = Boolean(enableAutocomplete); return { quickSuggestions: useAutocomplete, suggestOnTriggerCharacters: useAutocomplete, + acceptSuggestionOnEnter: autocompleteOnEnter ? 'on' : 'off', ...EDITOR_OPTIONS, }; - }, [enableAutocomplete]); + }, [enableAutocomplete, autocompleteOnEnter]); return options; } diff --git a/src/containers/Tenant/Tenant.tsx b/src/containers/Tenant/Tenant.tsx index e499ba508..ca65f8a1a 100644 --- a/src/containers/Tenant/Tenant.tsx +++ b/src/containers/Tenant/Tenant.tsx @@ -80,7 +80,7 @@ function Tenant(props: TenantProps) { if (tenantName && typeof tenantName === 'string' && previousTenant.current !== tenantName) { const register = async () => { const {registerYQLCompletionItemProvider} = await import( - '../../utils/monaco/yqlSuggestions/registerCompletionItemProvider' + '../../utils/monaco/yql/yql.completionItemProvider' ); registerYQLCompletionItemProvider(tenantName); }; diff --git a/src/containers/UserSettings/i18n/en.json b/src/containers/UserSettings/i18n/en.json index cfcb1f4c5..da4c677c0 100644 --- a/src/containers/UserSettings/i18n/en.json +++ b/src/containers/UserSettings/i18n/en.json @@ -11,6 +11,9 @@ "settings.editor.autocomplete.title": "Enable autocomplete", "settings.editor.autocomplete.description": "You’re always able to get suggestions by pressing Ctrl+Space.", + "settings.editor.autocomplete-on-enter.title": "Accept suggestion on Enter", + "settings.editor.autocomplete-on-enter.description": "Controls whether suggestions should be accepted on Enter, in addition to Tab. Helps to avoid ambiguity between inserting new lines or accepting suggestions.", + "settings.theme.title": "Interface theme", "settings.theme.option-dark": "Dark", "settings.theme.option-light": "Light", diff --git a/src/containers/UserSettings/settings.tsx b/src/containers/UserSettings/settings.tsx index 97c4f25ec..3d0b07967 100644 --- a/src/containers/UserSettings/settings.tsx +++ b/src/containers/UserSettings/settings.tsx @@ -2,6 +2,7 @@ import {Flask, PencilToSquare, StarFill} from '@gravity-ui/icons'; import type {IconProps} from '@gravity-ui/uikit'; import { + AUTOCOMPLETE_ON_ENTER, BINARY_DATA_IN_PLAIN_TEXT_DISPLAY, ENABLE_AUTOCOMPLETE, INVERTED_DISKS_KEY, @@ -112,6 +113,12 @@ export const enableAutocompleteSetting: SettingProps = { description: i18n('settings.editor.autocomplete.description'), }; +export const autocompleteOnEnterSetting: SettingProps = { + settingKey: AUTOCOMPLETE_ON_ENTER, + title: i18n('settings.editor.autocomplete-on-enter.title'), + description: i18n('settings.editor.autocomplete-on-enter.description'), +}; + export const appearanceSection: SettingsSection = { id: 'appearanceSection', title: i18n('section.appearance'), @@ -125,7 +132,7 @@ export const experimentsSection: SettingsSection = { export const devSettingsSection: SettingsSection = { id: 'devSettingsSection', title: i18n('section.dev-setting'), - settings: [enableAutocompleteSetting], + settings: [enableAutocompleteSetting, autocompleteOnEnterSetting], }; export const generalPage: SettingsPage = { diff --git a/src/services/api.ts b/src/services/api.ts index 5d792120b..a96b1875e 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -5,6 +5,7 @@ import {backend as BACKEND, metaBackend as META_BACKEND} from '../store'; import type {ComputeApiRequestParams, NodesApiRequestParams} from '../store/reducers/nodes/types'; import type {StorageApiRequestParams} from '../store/reducers/storage/types'; import type {TMetaInfo} from '../types/api/acl'; +import type {TQueryAutocomplete} from '../types/api/autocomplete'; import type {TClusterInfo} from '../types/api/cluster'; import type {TComputeInfo} from '../types/api/compute'; import type {DescribeConsumerResult} from '../types/api/consumer'; @@ -587,6 +588,15 @@ export class YdbEmbeddedAPI extends AxiosWrapper { whoami() { return this.get(this.getPath('/viewer/json/whoami'), {}); } + autocomplete(params: {database: string; prefix?: string; limit?: number; table?: string[]}) { + const {table, ...rest} = params; + const tablesParam = table?.join(','); + return this.get( + this.getPath('/viewer/json/autocomplete'), + {...rest, table: tablesParam}, + {concurrentId: 'sql-autocomplete'}, + ); + } // used if not single cluster mode getClustersList(_?: never, {signal}: {signal?: AbortSignal} = {}) { diff --git a/src/services/settings.ts b/src/services/settings.ts index f6b278c99..f90438653 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -1,6 +1,7 @@ import {TENANT_PAGES_IDS} from '../store/reducers/tenant/constants'; import { ASIDE_HEADER_COMPACT_KEY, + AUTOCOMPLETE_ON_ENTER, BINARY_DATA_IN_PLAIN_TEXT_DISPLAY, ENABLE_AUTOCOMPLETE, INVERTED_DISKS_KEY, @@ -38,6 +39,7 @@ export const DEFAULT_USER_SETTINGS: SettingsObject = { [USE_BACKEND_PARAMS_FOR_TABLES_KEY]: false, [USE_CLUSTER_BALANCER_AS_BACKEND_KEY]: true, [ENABLE_AUTOCOMPLETE]: false, + [AUTOCOMPLETE_ON_ENTER]: true, }; class SettingsManager { diff --git a/src/types/api/autocomplete.ts b/src/types/api/autocomplete.ts new file mode 100644 index 000000000..f28ccdc60 --- /dev/null +++ b/src/types/api/autocomplete.ts @@ -0,0 +1,46 @@ +/** + * endpoint: /viewer/json/autocomplete + * + * source: https://github.com/ydb-platform/ydb/blob/main/ydb/core/viewer/protos/viewer.proto + */ +export interface TQueryAutocomplete { + Success: boolean; + Error?: string[]; + Result: TAutocompleteResult; +} + +interface TAutocompleteResult { + Entities: TAutocompleteEntity[]; + Total?: number; +} + +export interface TAutocompleteEntity { + Name: string; + Type: AutocompleteEntityType; + Parent: string; +} + +export type AutocompleteEntityType = + | 'unknown' + | 'dir' + | 'table' + | 'pers_queue_group' + | 'sub_domain' + | 'rtmr_volume' + | 'block_store_volume' + | 'kesus' + | 'solomon_volume' + | 'table_index' + | 'ext_sub_domain' + | 'file_store' + | 'column_store' + | 'column_table' + | 'cdc_stream' + | 'sequence' + | 'replication' + | 'blob_depot' + | 'external_table' + | 'external_data_source' + | 'view' + | 'column' + | 'index'; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 68b652bfb..7981b8061 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -134,3 +134,5 @@ export const QUERY_USE_MULTI_SCHEMA_KEY = 'queryUseMultiSchema'; export const USE_CLUSTER_BALANCER_AS_BACKEND_KEY = 'useClusterBalancerAsBacked'; export const ENABLE_AUTOCOMPLETE = 'enableAutocomplete'; + +export const AUTOCOMPLETE_ON_ENTER = 'autocompleteOnEnter'; diff --git a/src/utils/monaco/index.ts b/src/utils/monaco/index.ts index 59be381db..540aaab5c 100644 --- a/src/utils/monaco/index.ts +++ b/src/utils/monaco/index.ts @@ -1,5 +1,7 @@ import {registerSExpressionLanguage} from './s-expression/registerLanguage'; +import {registerYqlLanguage} from './yql/registerLanguage'; export function registerLanguages() { registerSExpressionLanguage(); + registerYqlLanguage(); } diff --git a/src/utils/monaco/yqlSuggestions/constants.ts b/src/utils/monaco/yql/constants.ts similarity index 99% rename from src/utils/monaco/yqlSuggestions/constants.ts rename to src/utils/monaco/yql/constants.ts index 0a6abf2e7..8ccfd075e 100644 --- a/src/utils/monaco/yqlSuggestions/constants.ts +++ b/src/utils/monaco/yql/constants.ts @@ -1,3 +1,5 @@ +export const LANGUAGE_YQL_ID = 'yql'; + export const SimpleTypes = [ 'String', 'Bool', diff --git a/src/utils/monaco/yqlSuggestions/generateSuggestions.ts b/src/utils/monaco/yql/generateSuggestions.ts similarity index 59% rename from src/utils/monaco/yqlSuggestions/generateSuggestions.ts rename to src/utils/monaco/yql/generateSuggestions.ts index d5b746d13..b5eb008f9 100644 --- a/src/utils/monaco/yqlSuggestions/generateSuggestions.ts +++ b/src/utils/monaco/yql/generateSuggestions.ts @@ -1,9 +1,12 @@ import type { ColumnAliasSuggestion, KeywordSuggestion, + YQLEntity, YqlAutocompleteResult, } from '@gravity-ui/websql-autocomplete'; -import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; + +import type {AutocompleteEntityType, TAutocompleteEntity} from '../../../types/api/autocomplete'; import { AggregateFunctions, @@ -50,6 +53,33 @@ const CompletionItemKind: { const re = /[\s'"-/@]/; +const suggestionEntityToAutocomplete: Partial> = { + externalDataSource: ['external_data_source'], + replication: ['replication'], + table: ['table'], + tableStore: ['column_store'], + topic: ['pers_queue_group'], + view: ['view'], + //TODO: add after websql-autocomplete support indexex + // index: ['table_index', 'index'], +}; + +const commonSuggestionEntities: AutocompleteEntityType[] = ['dir', 'unknown']; + +function filterAutocompleteEntities( + autocompleteEntities: TAutocompleteEntity[], + suggestions: YQLEntity[], +) { + const suggestionsSet = suggestions.reduce((acc, el) => { + const autocompleteEntity = suggestionEntityToAutocomplete[el]; + if (autocompleteEntity) { + autocompleteEntity.forEach((el) => acc.add(el)); + } + return acc; + }, new Set(commonSuggestionEntities)); + return autocompleteEntities.filter(({Type}) => suggestionsSet.has(Type)); +} + function wrapStringToBackticks(value: string) { let result = value; if (value.match(re)) { @@ -59,11 +89,31 @@ function wrapStringToBackticks(value: string) { } function removeBackticks(value: string) { - let normalizedValue = value; + let sliceStart = 0; + let sliceEnd = value.length; if (value.startsWith('`')) { - normalizedValue = value.slice(1, -1); + sliceStart = 1; + } + if (value.endsWith('`')) { + sliceEnd = -1; + } + return value.slice(sliceStart, sliceEnd); +} + +function removeStartSlash(value: string) { + if (value.startsWith('/')) { + return value.slice(1); } - return normalizedValue; + return value; +} + +function normalizeEntityPrefix(value = '', database: string) { + let cleanedValue = removeStartSlash(removeBackticks(value)); + const cleanedDatabase = removeStartSlash(database); + if (cleanedValue.startsWith(cleanedDatabase)) { + cleanedValue = cleanedValue.slice(cleanedDatabase.length); + } + return removeStartSlash(cleanedValue); } type SuggestionType = keyof Omit; @@ -83,53 +133,6 @@ const SuggestionsWeight: Record = { suggestSimpleTypes: 11, }; -const KEEP_CACHE_MILLIS = 5 * 60 * 1000; - -function getColumnsWithCache() { - const cache = new Map(); - return async (path: string) => { - const normalizedPath = removeBackticks(path); - const existed = cache.get(path); - if (existed) { - return existed; - } - const columns = []; - const data = await window.api.getDescribe({path: normalizedPath}); - if (data?.Status === 'StatusSuccess') { - const desc = data.PathDescription; - if (desc?.Table?.Columns) { - for (const c of desc.Table.Columns) { - if (c.Name) { - columns.push(c.Name); - } - } - } - if (desc?.ColumnTableDescription?.Schema?.Columns) { - for (const c of desc.ColumnTableDescription.Schema.Columns) { - if (c.Name) { - columns.push(c.Name); - } - } - } - if (desc?.ExternalTableDescription?.Columns) { - for (const c of desc.ExternalTableDescription.Columns) { - if (c.Name) { - columns.push(c.Name); - } - } - } - } - - cache.set(path, columns); - setTimeout(() => { - cache.delete(path); - }, KEEP_CACHE_MILLIS); - return columns; - }; -} - -const getColumns = getColumnsWithCache(); - function getSuggestionIndex(suggestionType: SuggestionType) { return SuggestionsWeight[suggestionType]; } @@ -166,21 +169,68 @@ export async function generateColumnsSuggestion( } const suggestions: monaco.languages.CompletionItem[] = []; const multi = suggestColumns.tables.length > 1; - for (const entity of suggestColumns.tables ?? []) { - let normalizedEntityName = removeBackticks(entity.name); - // if it's relative entity path - if (!normalizedEntityName.startsWith('/')) { - normalizedEntityName = `${database}/${normalizedEntityName}`; + + const normalizedTableNames = + suggestColumns.tables?.map((entity) => { + let normalizedEntityName = removeBackticks(entity.name); + if (!normalizedEntityName.endsWith('/')) { + normalizedEntityName = `${normalizedEntityName}/`; + } + return normalizeEntityPrefix(normalizedEntityName, database); + }) ?? []; + + // remove duplicates if any + const filteredTableNames = Array.from(new Set(normalizedTableNames)); + + const autocompleteResponse = await window.api.autocomplete({ + database, + table: filteredTableNames, + limit: 1000, + }); + if (!autocompleteResponse.Success) { + return []; + } + + const tableNameToAliasMap = suggestColumns.tables?.reduce( + (acc, entity) => { + const normalizedEntityName = normalizeEntityPrefix( + removeBackticks(entity.name), + database, + ); + const aliases = acc[normalizedEntityName] ?? []; + if (entity.alias) { + aliases.push(entity.alias); + } + acc[normalizedEntityName] = aliases; + return acc; + }, + {} as Record, + ); + + autocompleteResponse.Result.Entities.forEach((col) => { + if (col.Type !== 'column') { + return; } - const fields = await getColumns(normalizedEntityName); - fields.forEach((columnName: string) => { - const normalizedName = wrapStringToBackticks(columnName); + const normalizedName = wrapStringToBackticks(col.Name); + + const normalizedParentName = normalizeEntityPrefix(col.Parent, database); + const aliases = tableNameToAliasMap[normalizedParentName]; + if (aliases?.length) { + aliases.forEach((a) => { + const columnNameSuggestion = `${a}.${normalizedName}`; + suggestions.push({ + label: columnNameSuggestion, + insertText: columnNameSuggestion, + kind: CompletionItemKind.Field, + detail: 'Column', + range: rangeToInsertSuggestion, + sortText: suggestionIndexToWeight(getSuggestionIndex('suggestColumns')), + }); + }); + } else { let columnNameSuggestion = normalizedName; - if (entity.alias) { - columnNameSuggestion = `${entity.alias}.${normalizedName}`; - } else if (multi) { - // no need to wrap entity.name to backticks, because it's already with them if needed - columnNameSuggestion = `${entity.name}.${normalizedName}`; + if (multi) { + columnNameSuggestion = `${wrapStringToBackticks(normalizedParentName)}.${normalizedName}`; } suggestions.push({ label: columnNameSuggestion, @@ -190,8 +240,8 @@ export async function generateColumnsSuggestion( range: rangeToInsertSuggestion, sortText: suggestionIndexToWeight(getSuggestionIndex('suggestColumns')), }); - }); - } + } + }); return suggestions; } @@ -229,8 +279,40 @@ export function generateKeywordsSuggestion( } export async function generateEntitiesSuggestion( - _rangeToInsertSuggestion: monaco.IRange, + rangeToInsertSuggestion: monaco.IRange, + suggestEntities: YQLEntity[], + database: string, + prefix?: string, ): Promise { + const normalizedPrefix = normalizeEntityPrefix(prefix, database); + const data = await window.api.autocomplete({database, prefix: normalizedPrefix, limit: 1000}); + const withBackticks = prefix?.startsWith('`'); + if (data.Success) { + const filteredEntities = filterAutocompleteEntities(data.Result.Entities, suggestEntities); + return filteredEntities.reduce((acc, {Name, Type}) => { + const isDir = Type === 'dir'; + const label = isDir ? `${Name}/` : Name; + let labelAsSnippet; + if (isDir && !withBackticks) { + labelAsSnippet = `\`${label}$0\``; + } + acc.push({ + label, + insertText: labelAsSnippet ?? label, + kind: isDir ? CompletionItemKind.Folder : CompletionItemKind.Text, + insertTextRules: labelAsSnippet + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : monaco.languages.CompletionItemInsertTextRule.None, + detail: Type, + range: rangeToInsertSuggestion, + command: label.endsWith('/') + ? {id: 'editor.action.triggerSuggest', title: ''} + : undefined, + sortText: suggestionIndexToWeight(getSuggestionIndex('suggestEntity')), + }); + return acc; + }, [] as monaco.languages.CompletionItem[]); + } return []; } export async function generateSimpleFunctionsSuggestion( diff --git a/src/utils/monaco/yql/registerLanguage.ts b/src/utils/monaco/yql/registerLanguage.ts new file mode 100644 index 000000000..ed2ef8fcd --- /dev/null +++ b/src/utils/monaco/yql/registerLanguage.ts @@ -0,0 +1,10 @@ +import * as monaco from 'monaco-editor'; + +import {LANGUAGE_YQL_ID} from './constants'; +import {conf, getLanguage} from './yql'; + +export function registerYqlLanguage() { + monaco.languages.register({id: LANGUAGE_YQL_ID}); + monaco.languages.setMonarchTokensProvider(LANGUAGE_YQL_ID, getLanguage()); + monaco.languages.setLanguageConfiguration(LANGUAGE_YQL_ID, conf); +} diff --git a/src/utils/monaco/yqlSuggestions/registerCompletionItemProvider.ts b/src/utils/monaco/yql/yql.completionItemProvider.ts similarity index 79% rename from src/utils/monaco/yqlSuggestions/registerCompletionItemProvider.ts rename to src/utils/monaco/yql/yql.completionItemProvider.ts index 24bb74b6e..a65446341 100644 --- a/src/utils/monaco/yqlSuggestions/registerCompletionItemProvider.ts +++ b/src/utils/monaco/yql/yql.completionItemProvider.ts @@ -1,5 +1,6 @@ import * as monaco from 'monaco-editor'; +import {LANGUAGE_YQL_ID} from './constants'; import {createProvideSuggestionsFunction} from './yqlSuggestions'; let completionProvider: monaco.IDisposable | undefined; @@ -12,8 +13,8 @@ function disableCodeSuggestions(): void { export function registerYQLCompletionItemProvider(database: string) { disableCodeSuggestions(); - completionProvider = monaco.languages.registerCompletionItemProvider('sql', { - triggerCharacters: [' ', '\n', '', ',', '.', '`', '('], + completionProvider = monaco.languages.registerCompletionItemProvider(LANGUAGE_YQL_ID, { + triggerCharacters: [' ', '', ',', '.', '`', '(', '/'], provideCompletionItems: createProvideSuggestionsFunction(database), }); } diff --git a/src/utils/monaco/yql/yql.keywords.ts b/src/utils/monaco/yql/yql.keywords.ts new file mode 100644 index 000000000..4596981c0 --- /dev/null +++ b/src/utils/monaco/yql/yql.keywords.ts @@ -0,0 +1,13 @@ +export const keywords = + '$row|$rows|action|all|and|any|as|asc|assume|begin|bernoulli|between|by|case|columns|commit|compact|create|cross|cube|declare|define|delete|desc|dict|discard|distinct|do|drop|else|empty_action|end|erase|evaluate|exclusion|exists|export|flatten|for|from|full|group|grouping|having|if|ignore|ilike|import|in|inner|insert|into|is|join|left|like|limit|list|match|not|null|nulls|offset|on|only|optional|or|order|over|partition|pragma|presort|process|reduce|regexp|repeatable|replace|respect|result|return|right|rlike|rollup|sample|schema|select|semi|set|sets|stream|subquery|table|tablesample|then|truncate|union|update|upsert|use|using|values|view|when|where|window|with|without|xor'.split( + '|', + ); +export const typeKeywords = + 'bool|date|datetime|decimal|double|float|int16|int32|int64|int8|interval|json|string|timestamp|tzdate|tzdatetime|tztimestamp|uint16|uint32|uint64|uint8|utf8|uuid|yson'.split( + '|', + ); + +export const builtinFunctions = + 'abs|aggregate_by|aggregate_list|aggregate_list_distinct|agg_list|agg_list_distinct|as_table|avg|avg_if|adaptivedistancehistogram|adaptivewardhistogram|adaptiveweighthistogram|addmember|addtimezone|aggregateflatten|aggregatetransforminput|aggregatetransformoutput|aggregationfactory|asatom|asdict|asdictstrict|asenum|aslist|asliststrict|asset|assetstrict|asstruct|astagged|astuple|asvariant|atomcode|bitcast|bit_and|bit_or|bit_xor|bool_and|bool_or|bool_xor|bottom|bottom_by|blockwardhistogram|blockweighthistogram|cast|coalesce|concat|concat_strict|correlation|count|count_if|covariance|covariance_population|covariance_sample|callableargument|callableargumenttype|callableresulttype|callabletype|callabletypecomponents|callabletypehandle|choosemembers|combinemembers|countdistinctestimate|currentauthenticateduser|currentoperationid|currentoperationsharedid|currenttzdate|currenttzdatetime|currenttztimestamp|currentutcdate|currentutcdatetime|currentutctimestamp|dense_rank|datatype|datatypecomponents|datatypehandle|dictaggregate|dictcontains|dictcreate|dicthasitems|dictitems|dictkeytype|dictkeys|dictlength|dictlookup|dictpayloadtype|dictpayloads|dicttype|dicttypecomponents|dicttypehandle|each|each_strict|emptydicttype|emptydicttypehandle|emptylisttype|emptylisttypehandle|endswith|ensure|ensureconvertibleto|ensuretype|enum|evaluateatom|evaluatecode|evaluateexpr|evaluatetype|expandstruct|filter|filter_strict|find|first_value|folder|filecontent|filepath|flattenmembers|forceremovemember|forceremovemembers|forcerenamemembers|forcespreadmembers|formatcode|formattype|frombytes|funccode|greatest|grouping|gathermembers|generictype|histogram|hll|hyperloglog|if|if_strict|instanceof|json_exists|json_query|json_value|jointablerow|just|lag|last_value|lead|least|len|length|like|likely|like_strict|lambdaargumentscount|lambdacode|linearhistogram|listaggregate|listall|listany|listavg|listcode|listcollect|listconcat|listcreate|listdistinct|listenumerate|listextend|listextendstrict|listextract|listfilter|listflatmap|listflatten|listfromrange|listhas|listhasitems|listhead|listindexof|listitemtype|listlast|listlength|listmap|listmax|listmin|listnotnull|listreplicate|listreverse|listskip|listskipwhile|listskipwhileinclusive|listsort|listsortasc|listsortdesc|listsum|listtake|listtakewhile|listtakewhileinclusive|listtype|listtypehandle|listunionall|listuniq|listzip|listzipall|loghistogram|logarithmichistogram|max|max_by|max_of|median|min|min_by|min_of|mode|multi_aggregate_by|nanvl|nvl|nothing|nulltype|nulltypehandle|optionalitemtype|optionaltype|optionaltypehandle|percentile|parsefile|parsetype|parsetypehandle|pickle|quotecode|range|range_strict|rank|regexp|regexp_strict|rfind|row_number|random|randomnumber|randomuuid|removemember|removemembers|removetimezone|renamemembers|replacemember|reprcode|resourcetype|resourcetypehandle|resourcetypetag|some|stddev|stddev_population|stddev_sample|substring|sum|sum_if|sessionstart|sessionwindow|setcreate|setdifference|setincludes|setintersection|setisdisjoint|setsymmetricdifference|setunion|spreadmembers|stablepickle|startswith|staticmap|streamitemtype|streamtype|streamtypehandle|structmembertype|structmembers|structtypecomponents|structtypehandle|subqueryextend|subqueryextendfor|subquerymerge|subquerymergefor|subqueryunionall|subqueryunionallfor|subqueryunionmerge|subqueryunionmergefor|top|topfreq|top_by|tablename|tablepath|tablerecordindex|tablerow|taggedtype|taggedtypecomponents|taggedtypehandle|tobytes|todict|tomultidict|toset|tosorteddict|tosortedmultidict|trymember|tupleelementtype|tupletype|tupletypecomponents|tupletypehandle|typehandle|typekind|typeof|udaf|unittype|unpickle|untag|unwrap|variance|variance_population|variance_sample|variant|varianttype|varianttypehandle|variantunderlyingtype|voidtype|voidtypehandle|way|worldcode'.split( + '|', + ); diff --git a/src/utils/monaco/yql/yql.ts b/src/utils/monaco/yql/yql.ts new file mode 100644 index 000000000..1f82997f9 --- /dev/null +++ b/src/utils/monaco/yql/yql.ts @@ -0,0 +1,197 @@ +import type * as monaco from 'monaco-editor'; + +import {builtinFunctions, keywords, typeKeywords} from './yql.keywords'; + +export const conf: monaco.languages.LanguageConfiguration = { + comments: { + lineComment: '--', + blockComment: ['/*', '*/'], + }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ], + autoClosingPairs: [ + {open: '{', close: '}'}, + {open: '[', close: ']'}, + {open: '(', close: ')'}, + {open: '"', close: '"'}, + {open: "'", close: "'"}, + {open: '`', close: '`'}, + ], + surroundingPairs: [ + {open: '{', close: '}'}, + {open: '[', close: ']'}, + {open: '(', close: ')'}, + {open: '"', close: '"'}, + {open: "'", close: "'"}, + {open: '`', close: '`'}, + ], + wordPattern: /(-?\d*\.\d\w*)|([^`~!@#%^&*()\-=+[{\]}\\|;:'",./?\s]+)/g, +}; + +interface LanguageOptions { + ansi?: boolean; +} +export function getLanguage({ + ansi = false, +}: LanguageOptions = {}): monaco.languages.IMonarchLanguage & Record { + return { + defaultToken: 'text', + ignoreCase: true, + + brackets: [ + {open: '[', close: ']', token: 'delimiter.square'}, + {open: '(', close: ')', token: 'delimiter.parenthesis'}, + {open: '{', close: '}', token: 'delimiter.curly'}, + ], + + keywords, + + typeKeywords, + + constants: ['true', 'false'], + + builtinFunctions, + + operators: [ + '+', + '-', + '/', + '//', + '%', + '<@>', + '@>', + '<@', + '&', + '^', + '~', + '<', + '>', + '<=', + '>=', + '=>', + '==', + '!=', + '<>', + '=', + ], + + symbols: /[=> { - const cursorPosition: CursorPosition = { - line: monacoCursorPosition.lineNumber, - column: monacoCursorPosition.column, - }; const rangeToInsertSuggestion = getRangeToInsertSuggestion(model, monacoCursorPosition); const suggestions = await getSuggestions( model, - cursorPosition, + monacoCursorPosition, rangeToInsertSuggestion, database, ); @@ -39,14 +35,32 @@ export function createProvideSuggestionsFunction(database: string) { }; } +function getEntityNameAtCursor(model: Monaco.editor.ITextModel, cursorPosition: Monaco.Position) { + const prevWord = model.findPreviousMatch( + '\\s(`?[^\\s]*)', + cursorPosition, + true, + false, + null, + true, + ); + const nextWord = model.findNextMatch('([^\\s]*)`?', cursorPosition, true, false, null, true); + + return `${prevWord?.matches?.[1] ?? ''}${nextWord?.matches?.[1] ?? ''}`; +} + async function getSuggestions( model: Monaco.editor.ITextModel, - cursorPosition: CursorPosition, + cursorPosition: Monaco.Position, rangeToInsertSuggestion: Monaco.IRange, database: string, ): Promise { const {parseYqlQuery} = await import('@gravity-ui/websql-autocomplete'); - const parseResult = parseYqlQuery(model.getValue(), cursorPosition); + const cursorForParsing: CursorPosition = { + line: cursorPosition.lineNumber, + column: cursorPosition.column, + }; + const parseResult = parseYqlQuery(model.getValue(), cursorForParsing); let entitiesSuggestions: Monaco.languages.CompletionItem[] = []; let functionsSuggestions: Monaco.languages.CompletionItem[] = []; let aggregateFunctionsSuggestions: Monaco.languages.CompletionItem[] = []; @@ -57,7 +71,14 @@ async function getSuggestions( let pragmasSuggestions: Monaco.languages.CompletionItem[] = []; if (parseResult.suggestEntity) { - entitiesSuggestions = await generateEntitiesSuggestion(rangeToInsertSuggestion); + const entityNamePrefix = getEntityNameAtCursor(model, cursorPosition); + + entitiesSuggestions = await generateEntitiesSuggestion( + rangeToInsertSuggestion, + parseResult.suggestEntity, + database, + entityNamePrefix, + ); } if (parseResult.suggestFunctions) { functionsSuggestions = await generateSimpleFunctionsSuggestion(rangeToInsertSuggestion);