UNPKG

@prisma/language-server

Version:
764 lines (686 loc) • 24 kB
import { CompletionParams, CompletionItem, CompletionList, CompletionTriggerKind, Position, CompletionItemKind, InsertTextFormat, } from 'vscode-languageserver' import type { TextDocument } from 'vscode-languageserver-textdocument' import textDocumentCompletion from '../prisma-schema-wasm/textDocumentCompletion' import { getCurrentLine, getSymbolBeforePosition, getBlockAtPosition, isFirstInsideBlock, positionIsAfterFieldAndType, isInsideAttribute, getFirstDatasourceProvider, Block, getAllPreviewFeaturesFromGenerators, getCompositeTypeFieldsRecursively, getDatamodelBlock, getFieldTypesFromCurrentBlock, getFieldsFromCurrentBlock, getValuesInsideSquareBrackets, isInsideFieldArgument, isInsideGivenProperty, getFieldType, } from '../ast' import { getSuggestionForBlockAttribute, getSuggestionForFieldAttribute } from './attributes' import { getSuggestionForBlockTypes } from './blocks' import { isInsideQuotationMark, suggestEqualSymbol, toCompletionItems } from './internals' import { getSuggestionForNativeTypes, getSuggestionsForFieldTypes } from './types' import { dataSourceSuggestions } from './datasource' import { generatorSuggestions, getSuggestionForGeneratorField } from './generator' import { relationArguments, opsIndexFulltextCompletion, filterSortLengthBasedOnInput, sortLengthProperties, getCompletionsForBlockAttributeArgs, getCompletionsForFieldAttributeArgs, booleanDefaultCompletions, cacheSequenceDefaultCompletion, incrementSequenceDefaultCompletion, maxValueSequenceDefaultCompletion, minValueSequenceDefaultCompletion, startSequenceDefaultCompletion, virtualSequenceDefaultCompletion, } from './arguments' import { autoDefaultCompletion, dbgeneratedDefaultCompletion, sequenceDefaultCompletion, autoincrementDefaultCompletion, nowDefaultCompletion, uuidDefaultCompletion, cuidDefaultCompletion, ulidDefaultCompletion, nanoidDefaultCompletion, } from './functions' import { BlockType } from '../types' import { PrismaSchema } from '../Schema' function getDefaultValueSuggestions({ currentLine, schema, wordsBeforePosition, }: { currentLine: string schema: PrismaSchema wordsBeforePosition: string[] }): CompletionItem[] { const suggestions: CompletionItem[] = [] const datasourceProvider = getFirstDatasourceProvider(schema) // Completions for sequence(|) if (datasourceProvider === 'cockroachdb') { if (wordsBeforePosition.some((a) => a.includes('sequence('))) { const sequenceProperties = ['virtual', 'minValue', 'maxValue', 'cache', 'increment', 'start'] // No suggestions if virtual is present if (currentLine.includes('virtual')) { return suggestions } if (!sequenceProperties.some((it) => currentLine.includes(it))) { virtualSequenceDefaultCompletion(suggestions) } if (!currentLine.includes('minValue')) { minValueSequenceDefaultCompletion(suggestions) } if (!currentLine.includes('maxValue')) { maxValueSequenceDefaultCompletion(suggestions) } if (!currentLine.includes('cache')) { cacheSequenceDefaultCompletion(suggestions) } if (!currentLine.includes('increment')) { incrementSequenceDefaultCompletion(suggestions) } if (!currentLine.includes('start')) { startSequenceDefaultCompletion(suggestions) } return suggestions } } if (datasourceProvider === 'mongodb') { autoDefaultCompletion(suggestions) } else { dbgeneratedDefaultCompletion(suggestions) } const fieldType = getFieldType(currentLine) if (!fieldType) { return [] } const isScalarList = fieldType.endsWith('[]') if (isScalarList) { const isDefaultEmpty = wordsBeforePosition.at(-1)?.trim().endsWith('(') const listItemFieldType = fieldType.slice(0, -2) const listItemSuggestions = getDefaultValueSuggestions({ currentLine: currentLine.replace(fieldType, listItemFieldType), schema, wordsBeforePosition, }) // functions are currently not supported for list defaults .filter((item) => item.kind !== CompletionItemKind.Function) if (isDefaultEmpty) { suggestions.unshift({ command: listItemSuggestions.length ? { command: 'editor.action.triggerSuggest', title: 'triggerSuggest', } : undefined, documentation: 'Set a default value on the list field', insertText: '[$0]', insertTextFormat: InsertTextFormat.Snippet, kind: CompletionItemKind.Value, label: '[]', }) return suggestions } return listItemSuggestions } switch (fieldType) { case 'BigInt': case 'Int': if (datasourceProvider === 'cockroachdb') { sequenceDefaultCompletion(suggestions) if (fieldType === 'Int') { // @default(autoincrement()) is only supported on BigInt fields for cockroachdb. break } } autoincrementDefaultCompletion(suggestions) break case 'DateTime': nowDefaultCompletion(suggestions) break case 'String': uuidDefaultCompletion(suggestions) cuidDefaultCompletion(suggestions) ulidDefaultCompletion(suggestions) nanoidDefaultCompletion(suggestions) break case 'Boolean': booleanDefaultCompletions(suggestions) break } const dataBlock = getDatamodelBlock(fieldType, schema) if (dataBlock && dataBlock.type === 'enum') { // get fields from enum block for suggestions const values: string[] = getFieldsFromCurrentBlock(schema, dataBlock) values.forEach((v) => suggestions.push({ label: v, kind: CompletionItemKind.Value })) } return suggestions } function getSuggestionsForAttribute({ attribute, wordsBeforePosition, untrimmedCurrentLine, schema, block, position, }: { attribute?: '@relation' wordsBeforePosition: string[] untrimmedCurrentLine: string schema: PrismaSchema block: Block position: Position }): CompletionList | undefined { const firstWordBeforePosition = wordsBeforePosition[wordsBeforePosition.length - 1] const secondWordBeforePosition = wordsBeforePosition[wordsBeforePosition.length - 2] const wordBeforePosition = firstWordBeforePosition === '' ? secondWordBeforePosition : firstWordBeforePosition let suggestions: CompletionItem[] = [] // We can filter on the datasource const datasourceProvider = getFirstDatasourceProvider(schema) // We can filter on the previewFeatures enabled const previewFeatures = getAllPreviewFeaturesFromGenerators(schema) if (attribute === '@relation') { if (datasourceProvider === 'mongodb') { suggestions = relationArguments.filter( (arg) => arg.label !== 'map' && arg.label !== 'onDelete' && arg.label !== 'onUpdate', ) } else { suggestions = relationArguments } // If we are right after @relation( if (wordBeforePosition.includes('@relation')) { return { items: suggestions, isIncomplete: false, } } // TODO check fields with [] shortcut if (isInsideGivenProperty(untrimmedCurrentLine, wordsBeforePosition, 'fields', position)) { return { items: toCompletionItems(getFieldsFromCurrentBlock(schema, block, position), CompletionItemKind.Field), isIncomplete: false, } } if (isInsideGivenProperty(untrimmedCurrentLine, wordsBeforePosition, 'references', position)) { // Get the name by potentially removing ? and [] from Foo? or Foo[] const referencedModelName = wordsBeforePosition[1].replace('?', '').replace('[]', '') const referencedBlock = getDatamodelBlock(referencedModelName, schema) // referenced model does not exist // TODO type? if (!referencedBlock || referencedBlock.type !== 'model') { return } return { items: toCompletionItems(getFieldsFromCurrentBlock(schema, referencedBlock), CompletionItemKind.Field), isIncomplete: false, } } } else { // @id, @unique // @@id, @@unique, @@index, @@fulltext // The length argument is available on MySQL only on the // @id, @@id, @unique, @@unique and @@index fields. // The sort argument is available for all databases on the // @unique, @@unique and @@index fields. // Additionally, SQL Server also allows it on @id and @@id. let attribute: '@@unique' | '@unique' | '@@id' | '@id' | '@@index' | '@@fulltext' | undefined = undefined if (wordsBeforePosition.some((a) => a.includes('@@id'))) { attribute = '@@id' } else if (wordsBeforePosition.some((a) => a.includes('@id'))) { attribute = '@id' } else if (wordsBeforePosition.some((a) => a.includes('@@unique'))) { attribute = '@@unique' } else if (wordsBeforePosition.some((a) => a.includes('@unique'))) { attribute = '@unique' } else if (wordsBeforePosition.some((a) => a.includes('@@index'))) { attribute = '@@index' } else if (wordsBeforePosition.some((a) => a.includes('@@fulltext'))) { attribute = '@@fulltext' } /** * inside [] * suggest composite types for MongoDB * suggest fields and extendedIndexes arguments (sort / length) * * Examples * field attribute: slug String @unique(sort: Desc, length: 42) @db.VarChar(3000) * block attribute: @@id([title(length: 100, sort: Desc), abstract(length: 10)]) */ if (attribute && attribute !== '@@fulltext' && isInsideAttribute(untrimmedCurrentLine, position, '[]')) { if (isInsideFieldArgument(untrimmedCurrentLine, position)) { // extendedIndexes const items: CompletionItem[] = [] // https://www.notion.so/prismaio/Proposal-More-PostgreSQL-index-types-GiST-GIN-SP-GiST-and-BRIN-e27ef762ee4846a9a282eec1a5129270 if (datasourceProvider === 'postgresql' && attribute === '@@index') { opsIndexFulltextCompletion(items) } items.push( ...filterSortLengthBasedOnInput( attribute, previewFeatures, datasourceProvider, wordBeforePosition, sortLengthProperties, ), ) return { items, isIncomplete: false, } } const fieldsFromLine = getValuesInsideSquareBrackets(untrimmedCurrentLine) /* * MongoDB composite type fields, see https://www.prisma.io/docs/concepts/components/prisma-schema/data-model#composite-type-unique-constraints * Examples * @@unique([address.|]) or @@unique(fields: [address.|]) * @@index([address.|]) or @@index(fields: [address.|]) */ if (datasourceProvider === 'mongodb' && fieldsFromLine && firstWordBeforePosition.endsWith('.')) { const getFieldName = (text: string): string => { const [_, __, value] = new RegExp(/(.*\[)?(.+)/).exec(text) || [] let name = value // Example for `@@index([email,address.|])` when there is no space between fields if (name?.includes(',')) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion name = name.split(',').pop()! } // Remove . to only get the name if (name?.endsWith('.')) { name = name.slice(0, -1) } return name } const currentFieldName = getFieldName(firstWordBeforePosition) if (!currentFieldName) { return { isIncomplete: false, items: [], } } const currentCompositeAsArray = currentFieldName.split('.') const fieldTypesFromCurrentBlock = getFieldTypesFromCurrentBlock(schema, block) const fields = getCompositeTypeFieldsRecursively(schema, currentCompositeAsArray, fieldTypesFromCurrentBlock) return { items: toCompletionItems(fields, CompletionItemKind.Field), isIncomplete: false, } } let fieldsFromCurrentBlock = getFieldsFromCurrentBlock(schema, block, position) if (fieldsFromLine.length > 0) { // If we are in a composite type, exit here, to not pollute results with first level fields if (firstWordBeforePosition.includes('.')) { return { isIncomplete: false, items: [], } } // Remove items already used fieldsFromCurrentBlock = fieldsFromCurrentBlock.filter((s) => !fieldsFromLine.includes(s)) // Return fields // `onCompletionResolve` will take care of filtering the partial matches if ( firstWordBeforePosition !== '' && !firstWordBeforePosition.endsWith(',') && !firstWordBeforePosition.endsWith(', ') ) { return { items: toCompletionItems(fieldsFromCurrentBlock, CompletionItemKind.Field), isIncomplete: false, } } } return { items: toCompletionItems(fieldsFromCurrentBlock, CompletionItemKind.Field), isIncomplete: false, } } // "@@" block attributes let blockAtrributeArguments: CompletionItem[] = [] if (attribute === '@@unique') { blockAtrributeArguments = getCompletionsForBlockAttributeArgs({ blockAttributeWithParams: '@@unique', wordBeforePosition, datasourceProvider, previewFeatures, }) } else if (attribute === '@@id') { blockAtrributeArguments = getCompletionsForBlockAttributeArgs({ blockAttributeWithParams: '@@id', wordBeforePosition, datasourceProvider, previewFeatures, }) } else if (attribute === '@@index') { blockAtrributeArguments = getCompletionsForBlockAttributeArgs({ blockAttributeWithParams: '@@index', wordBeforePosition, datasourceProvider, previewFeatures, }) } else if (attribute === '@@fulltext') { blockAtrributeArguments = getCompletionsForBlockAttributeArgs({ blockAttributeWithParams: '@@fulltext', wordBeforePosition, datasourceProvider, previewFeatures, }) } if (blockAtrributeArguments.length) { suggestions = blockAtrributeArguments } else { // "@" field attributes let fieldAtrributeArguments: CompletionItem[] = [] if (attribute === '@unique') { fieldAtrributeArguments = getCompletionsForFieldAttributeArgs( '@unique', previewFeatures, datasourceProvider, wordBeforePosition, ) } else if (attribute === '@id') { fieldAtrributeArguments = getCompletionsForFieldAttributeArgs( '@id', previewFeatures, datasourceProvider, wordBeforePosition, ) } suggestions = fieldAtrributeArguments } } // Check which attributes are already present // so we can filter them out from the suggestions const attributesFound: Set<string> = new Set() for (const word of wordsBeforePosition) { if (word.includes('references')) { attributesFound.add('references') } if (word.includes('fields')) { attributesFound.add('fields') } if (word.includes('onUpdate')) { attributesFound.add('onUpdate') } if (word.includes('onDelete')) { attributesFound.add('onDelete') } if (word.includes('map')) { attributesFound.add('map') } if (word.includes('name') || /".*"/.exec(word)) { attributesFound.add('name') attributesFound.add('""') } if (word.includes('type')) { attributesFound.add('type') } } // now filter them out of the suggestions as they are already present const filteredSuggestions: CompletionItem[] = suggestions.reduce( (accumulator: CompletionItem[] & unknown[], sugg) => { let suggestionMatch = false for (const attribute of attributesFound) { if (sugg.label.includes(attribute)) { suggestionMatch = true } } if (!suggestionMatch) { accumulator.push(sugg) } return accumulator }, [], ) // nothing to present any more, return if (filteredSuggestions.length === 0) { return } return { items: filteredSuggestions, isIncomplete: false, } } function getSuggestionsForInsideRoundBrackets( untrimmedCurrentLine: string, schema: PrismaSchema, position: Position, block: Block, ): CompletionList | undefined { const wordsBeforePosition = untrimmedCurrentLine.slice(0, position.character).trimStart().split(/\s+/) if (wordsBeforePosition.some((a) => a.includes('@default'))) { return { items: getDefaultValueSuggestions({ currentLine: block.definingDocument.getLineContent(position.line), schema, wordsBeforePosition, }), isIncomplete: false, } } if (wordsBeforePosition.some((a) => a.includes('@relation'))) { return getSuggestionsForAttribute({ attribute: '@relation', wordsBeforePosition, untrimmedCurrentLine, schema, block, position, }) } if ( // matches // @id, @unique // @@id, @@unique, @@index, @@fulltext wordsBeforePosition.some( (a) => a.includes('@unique') || a.includes('@id') || a.includes('@@index') || a.includes('@@fulltext'), ) ) { return getSuggestionsForAttribute({ wordsBeforePosition, untrimmedCurrentLine, schema, block, position, }) } return { items: toCompletionItems([], CompletionItemKind.Field), isIncomplete: false, } } // Suggest fields for a BlockType function getSuggestionForSupportedFields( blockType: BlockType, currentLine: string, currentLineUntrimmed: string, position: Position, schema: PrismaSchema, onError?: (errorMessage: string) => void, ): CompletionList | undefined { const isInsideQuotation: boolean = isInsideQuotationMark(currentLineUntrimmed, position) // We can filter on the datasource const datasourceProvider = getFirstDatasourceProvider(schema) switch (blockType) { case 'generator': return generatorSuggestions(currentLine, currentLineUntrimmed, position, isInsideQuotation, onError) case 'datasource': return dataSourceSuggestions(currentLine, isInsideQuotation, datasourceProvider) default: return undefined } } /** * gets suggestions for block type */ function getSuggestionForFirstInsideBlock( blockType: BlockType, schema: PrismaSchema, position: Position, block: Block, ): CompletionList { let suggestions: CompletionItem[] = [] switch (blockType) { case 'generator': suggestions = getSuggestionForGeneratorField(block, schema, position) break case 'model': case 'view': suggestions = getSuggestionForBlockAttribute(block, schema) break case 'type': // No suggestions break } return { items: suggestions, isIncomplete: false, } } export function prismaSchemaWasmCompletions( schema: PrismaSchema, params: CompletionParams, onError?: (errorMessage: string) => void, ): CompletionList | undefined { const completionList = textDocumentCompletion(schema, params, (errorMessage: string) => { if (onError) { onError(errorMessage) } }) if (completionList.items.length === 0) { return undefined } else { return completionList } } export function localCompletions( schema: PrismaSchema, initiatingDocument: TextDocument, params: CompletionParams, onError?: (errorMessage: string) => void, ): CompletionList | undefined { const context = params.context const position = params.position const currentLineUntrimmed = getCurrentLine(initiatingDocument, position.line) const currentLineTillPosition = currentLineUntrimmed.slice(0, position.character - 1).trim() const wordsBeforePosition: string[] = currentLineTillPosition.split(/\s+/) const symbolBeforePosition = getSymbolBeforePosition(initiatingDocument, position) const symbolBeforePositionIsWhiteSpace = symbolBeforePosition.search(/\s/) !== -1 const positionIsAfterArray: boolean = wordsBeforePosition.length >= 3 && !currentLineTillPosition.includes('[') && symbolBeforePositionIsWhiteSpace // datasource, generator, model, type or enum const foundBlock = getBlockAtPosition(initiatingDocument.uri, position.line, schema) if (!foundBlock) { if (wordsBeforePosition.length > 1 || (wordsBeforePosition.length === 1 && symbolBeforePositionIsWhiteSpace)) { return } return getSuggestionForBlockTypes(schema) } if (isFirstInsideBlock(position, currentLineUntrimmed)) { return getSuggestionForFirstInsideBlock(foundBlock.type, schema, position, foundBlock) } // Completion was triggered by a triggerCharacter // triggerCharacters defined in src/server.ts if (context?.triggerKind === CompletionTriggerKind.TriggerCharacter) { switch (context.triggerCharacter as '@' | '"' | '.') { case '@': if (!positionIsAfterFieldAndType(position, initiatingDocument, wordsBeforePosition)) { return } return getSuggestionForFieldAttribute( foundBlock, getCurrentLine(initiatingDocument, position.line), schema, wordsBeforePosition, onError, ) case '"': return getSuggestionForSupportedFields( foundBlock.type, foundBlock.definingDocument.getLineContent(position.line), currentLineUntrimmed, position, schema, onError, ) case '.': // check if inside attribute // Useful to complete composite types if (['model', 'view'].includes(foundBlock.type) && isInsideAttribute(currentLineUntrimmed, position, '()')) { return getSuggestionsForInsideRoundBrackets(currentLineUntrimmed, schema, position, foundBlock) } else { return getSuggestionForNativeTypes(foundBlock, schema, wordsBeforePosition, onError) } } } switch (foundBlock.type) { case 'model': case 'view': case 'type': // check if inside attribute if (isInsideAttribute(currentLineUntrimmed, position, '()')) { return getSuggestionsForInsideRoundBrackets(currentLineUntrimmed, schema, position, foundBlock) } // check if field type if (!positionIsAfterFieldAndType(position, initiatingDocument, wordsBeforePosition)) { return getSuggestionsForFieldTypes(schema, position, currentLineUntrimmed) } return getSuggestionForFieldAttribute( foundBlock, foundBlock.definingDocument.getLineContent(position.line), schema, wordsBeforePosition, onError, ) case 'datasource': case 'generator': // If we're on a line with just whitespace, suggest field names if (currentLineTillPosition.trim() === '') { if (foundBlock.type === 'generator') { const generatorFields = getSuggestionForGeneratorField(foundBlock, schema, position) return { items: generatorFields, isIncomplete: false, } } // For datasource, we could add similar logic here if needed } if (wordsBeforePosition.length === 1 && symbolBeforePositionIsWhiteSpace) { return suggestEqualSymbol(foundBlock.type) } if ( currentLineTillPosition.includes('=') && !currentLineTillPosition.includes(']') && !positionIsAfterArray && symbolBeforePosition !== ',' ) { return getSuggestionForSupportedFields( foundBlock.type, foundBlock.definingDocument.getLineContent(position.line), currentLineUntrimmed, position, schema, onError, ) } break case 'enum': break } }