import ts from 'typescript' ; import { CancellationToken , CompletionContext , CompletionItem , CompletionList , CompletionTriggerKind , MarkupContent , MarkupKind , Position , Range , TextDocumentIdentifier , TextEdit } from 'vscode-languageserver' ; import { Document , getNodeIfIsInHTMLStartTag , getNodeIfIsInStartTag , getWordRangeAt , isInTag , mapCompletionItemToOriginal , mapRangeToOriginal , toRange } from '../../../lib/documents' ; import { LSConfigManager } from '../../../ls-config' ; import { flatten , getRegExpMatches , isNotNullOrUndefined , pathToUrl } from '../../../utils' ; import { AppCompletionItem , AppCompletionList , CompletionsProvider } from '../../interfaces' ; import { ComponentPartInfo } from '../ComponentInfoProvider' ; import { SvelteDocumentSnapshot , SvelteSnapshotFragment } from '../DocumentSnapshot' ; import { LSAndTSDocResolver } from '../LSAndTSDocResolver' ; import { getMarkdownDocumentation } from '../previewer' ; import { changeSvelteComponentName , convertRange , getCommitCharactersForScriptElement , isInScript , scriptElementKindToCompletionItemKind } from '../utils' ; import { getJsDocTemplateCompletion } from './getJsDocTemplateCompletion' ; import { getComponentAtPosition , isPartOfImportStatement } from './utils' ; export interface CompletionEntryWithIdentifer extends ts . CompletionEntry , TextDocumentIdentifier { position : Position ; } type validTriggerCharacter = '.' | '"' | "'" | '`' | '/' | '@' | '<' | '#' ; type LastCompletion = { key : string ; position : Position ; completionList : AppCompletionList < CompletionEntryWithIdentifer > | null ; } ; export class CompletionsProviderImpl implements CompletionsProvider < CompletionEntryWithIdentifer > { constructor ( private readonly lsAndTsDocResolver , private readonly configManager ) { } /**
     * The language service throws an error if the character is not a valid trigger character.
     * Also, the completions are worse.
     * Therefore, only use the characters the typescript compiler treats as valid.
     */ private readonly validTriggerCharacters = [ '.' , '"' , "'" , '`' , '/' , '@' , '<' , '#' ] as const ; /**
     * For performance reasons, try to reuse the last completion if possible.
     */ private lastCompletion ? : LastCompletion ; private isValidTriggerCharacter ( character ) { return this . validTriggerCharacters . includes ( character as validTriggerCharacter ) ; } async getCompletions ( document , position , completionContext ? , cancellationToken ? ) { if ( isInTag ( position , document . styleInfo ) ) { return null ; } const { lang , tsDoc , userPreferences } = await this . lsAndTsDocResolver . getLSAndTSDoc ( document ) ; const filePath = tsDoc . filePath ; if ( ! filePath ) { return null ; } const triggerCharacter = completionContext ?. triggerCharacter ; const triggerKind = completionContext ?. triggerKind ; const validTriggerCharacter = this . isValidTriggerCharacter ( triggerCharacter ) ? triggerCharacter : undefined ; const isCustomTriggerCharacter = triggerKind === CompletionTriggerKind . TriggerCharacter ; const isJsDocTriggerCharacter = triggerCharacter === '*' ; const isEventOrSlotLetTriggerCharacter = triggerCharacter === ':' ; if ( isCustomTriggerCharacter && ! validTriggerCharacter && ! isJsDocTriggerCharacter && ! isEventOrSlotLetTriggerCharacter ) { return null ; } if ( this . canReuseLastCompletion ( this . lastCompletion , triggerKind , triggerCharacter , document , position ) ) { this . lastCompletion . position = position ; return this . lastCompletion . completionList ; } else { this . lastCompletion = undefined ; } const fragment = await tsDoc . getFragment ( ) ; if ( ! fragment . isInGenerated ( position ) ) { return null ; } const offset = fragment . offsetAt ( fragment . getGeneratedPosition ( position ) ) ; if ( isJsDocTriggerCharacter ) { return getJsDocTemplateCompletion ( fragment , lang , filePath , offset ) ; } if ( cancellationToken ?. isCancellationRequested ) { return null ; } const originalOffset = document . offsetAt ( position ) ; const wordRange = getWordRangeAt ( document . getText ( ) , originalOffset , { left : /[^\s.]+$/ , right : /[^\w$:]/ } ) ; const eventAndSlotLetCompletions = await this . getEventAndSlotLetCompletions ( lang , document , tsDoc , position , wordRange ) ; if ( isEventOrSlotLetTriggerCharacter ) { return CompletionList . create ( eventAndSlotLetCompletions , ! ! tsDoc . parserError ) ; } if ( cancellationToken ?. isCancellationRequested ) { return null ; } const completions = lang . getCompletionsAtPosition ( filePath , offset , { ... userPreferences , triggerCharacter : validTriggerCharacter } ) ?. entries || [ ] ; if ( completions . length === 0 && eventAndSlotLetCompletions . length === 0 ) { return tsDoc . parserError ? CompletionList . create ( [ ] , true ) : null ; } const existingImports = this . getExistingImports ( document ) ; const wordRangeStartPosition = document . positionAt ( wordRange . start ) ; const completionItems = completions . filter ( isValidCompletion ( document , position ) ) . map ( ( comp ) => this . toCompletionItem ( fragment , comp , pathToUrl ( tsDoc . filePath ) , position , existingImports ) ) . filter ( isNotNullOrUndefined ) . map ( ( comp ) => mapCompletionItemToOriginal ( fragment , comp ) ) . map ( ( comp ) => this . fixTextEditRange ( wordRangeStartPosition , comp ) ) . concat ( eventAndSlotLetCompletions ) ; const completionList = CompletionList . create ( completionItems , ! ! tsDoc . parserError ) ; this . lastCompletion = { key : document . getFilePath ( ) || '' , position , completionList } ; return completionList ; } private canReuseLastCompletion ( lastCompletion , triggerKind , triggerCharacter , document , position ) { return ( ! ! lastCompletion && lastCompletion . key === document . getFilePath ( ) && lastCompletion . position . line === position . line && ( ( Math . abs ( lastCompletion . position . character - position . character ) < 2 && ( triggerKind === CompletionTriggerKind . TriggerForIncompleteCompletions || ( triggerCharacter === '.' && isPartOfImportStatement ( document . getText ( ) , position ) ) ) ) || ( Math . abs ( lastCompletion . position . character - position . character ) < 4 && triggerCharacter === ':' && ! ! getNodeIfIsInStartTag ( document . html , document . offsetAt ( position ) ) ) ) ) ; } private getExistingImports ( document ) { const rawImports = getRegExpMatches ( scriptImportRegex , document . getText ( ) ) . map ( ( match ) => ( match [ 1 ] ?? match [ 2 ] ) . split ( ',' ) ) ; const tidiedImports = flatten ( rawImports ) . map ( ( match ) => match . trim ( ) ) ; return new Set ( tidiedImports ) ; } private async getEventAndSlotLetCompletions ( lang , doc , tsDoc , originalPosition , wordRange ) { const componentInfo = await getComponentAtPosition ( lang , doc , tsDoc , originalPosition ) ; if ( ! componentInfo ) { return [ ] ; } const { start , end } = wordRange ; const events = componentInfo . getEvents ( ) . map ( ( event ) => mapToCompletionEntry ( event , 'on:' ) ) ; const slotLets = componentInfo . getSlotLets ( ) . map ( ( slot ) => mapToCompletionEntry ( slot , 'let:' ) ) ; return [ ... events , ... slotLets ] ; function mapToCompletionEntry ( info , prefix ) { const slotName = prefix + info . name ; return { label : slotName , sortText : '-1' , detail : info . name + ': ' + info . type , documentation : info . doc && { kind : MarkupKind . Markdown , value : info . doc } , textEdit : start !== end ? TextEdit . replace ( toRange ( doc . getText ( ) , start , end ) , slotName ) : undefined } ; } } private toCompletionItem ( fragment , comp , uri , position , existingImports ) { const completionLabelAndInsert = this . getCompletionLabelAndInsert ( fragment , comp ) ; if ( ! completionLabelAndInsert ) { return null ; } const { label , insertText , isSvelteComp , replacementSpan } = completionLabelAndInsert ; if ( isSvelteComp && existingImports . has ( label ) ) { return null ; } const textEdit = replacementSpan ? TextEdit . replace ( convertRange ( fragment , replacementSpan ) , insertText ?? label ) : undefined ; return { label , insertText , kind : scriptElementKindToCompletionItemKind ( comp . kind ) , commitCharacters : getCommitCharactersForScriptElement ( comp . kind ) , sortText : isSvelteComp ? '-1' : comp . sortText , preselect : isSvelteComp ? true : comp . isRecommended , textEdit , data : { ... comp , uri , position } } ; } private getCompletionLabelAndInsert ( fragment , comp ) { let { name , insertText , kindModifiers } = comp ; const isScriptElement = comp . kind === ts . ScriptElementKind . scriptElement ; const hasModifier = Boolean ( comp . kindModifiers ) ; const isSvelteComp = this . isSvelteComponentImport ( name ) ; if ( isSvelteComp ) { name = changeSvelteComponentName ( name ) ; if ( this . isExistingSvelteComponentImport ( fragment , name , comp . source ) ) { return null ; } } if ( isScriptElement && hasModifier ) { const label = kindModifiers && ! name . endsWith ( kindModifiers ) ? name + kindModifiers : name ; return { insertText : name , label , isSvelteComp } ; } if ( comp . replacementSpan ) { return { label : name , isSvelteComp , insertText : insertText ? changeSvelteComponentName ( insertText ) : undefined , replacementSpan : comp . replacementSpan } ; } return { label : name , isSvelteComp } ; } private isExistingSvelteComponentImport ( fragment , name , source ? ) { const importStatement = new RegExp ( `import ${ name } from ["'\`][\\s\\S]+\\.svelte["'\`]` ) ; return ! ! source && ! ! fragment . text . match ( importStatement ) ; } /**
     * If the textEdit is out of the word range of the triggered position
     * vscode would refuse to show the completions
     * split those edits into additionalTextEdit to fix it
     */ private fixTextEditRange ( wordRangePosition , completionItem ) { const { textEdit } = completionItem ; if ( ! textEdit || ! TextEdit . is ( textEdit ) ) { return completionItem ; } const { newText , range : { start } } = textEdit ; const wordRangeStartCharacter = wordRangePosition . character ; if ( wordRangePosition . line !== wordRangePosition . line || start . character > wordRangePosition . character ) { return completionItem ; } textEdit . newText = newText . substring ( wordRangeStartCharacter - start . character ) ; textEdit . range . start = { line : start . line , character : wordRangeStartCharacter } ; completionItem . additionalTextEdits = [ TextEdit . replace ( { start , end : { line : start . line , character : wordRangeStartCharacter } } , newText . substring ( 0 , wordRangeStartCharacter - start . character ) ) ] ; return completionItem ; } /**
     * TypeScript throws a debug assertion error if the importModuleSpecifierEnding config is
     * 'js' and there's an unknown file extension - which is the case for `.svelte`. Therefore
     * rewrite the importModuleSpecifierEnding for this case to silence the error.
     */ fixUserPreferencesForSvelteComponentImport ( userPreferences ) { if ( userPreferences . importModuleSpecifierEnding === 'js' ) { return { ... userPreferences , importModuleSpecifierEnding : 'index' } ; } return userPreferences ; } async resolveCompletion ( document , completionItem , cancellationToken ? ) { const { data : comp } = completionItem ; const { tsDoc , lang , userPreferences } = await this . lsAndTsDocResolver . getLSAndTSDoc ( document ) ; const filePath = tsDoc . filePath ; if ( ! comp || ! filePath || cancellationToken ?. isCancellationRequested ) { return completionItem ; } const fragment = await tsDoc . getFragment ( ) ; const errorPreventingUserPreferences = comp . source ?. endsWith ( '.svelte' ) ? this . fixUserPreferencesForSvelteComponentImport ( userPreferences ) : userPreferences ; const detail = lang . getCompletionEntryDetails ( filePath , fragment . offsetAt ( fragment . getGeneratedPosition ( comp . position ) ) , comp . name , { } , comp . source , errorPreventingUserPreferences , comp . data ) ; if ( detail ) { const { detail : itemDetail , documentation : itemDocumentation } = this . getCompletionDocument ( detail ) ; completionItem . detail = itemDetail ; completionItem . documentation = itemDocumentation ; } const actions = detail ?. codeActions ; const isImport = ! ! detail ?. source ; if ( actions ) { const edit = [ ] ; for ( const action of actions ) { for ( const change of action . changes ) { edit . push ( ... this . codeActionChangesToTextEdit ( document , fragment , change , isImport , comp . position ) ) ; } } completionItem . additionalTextEdits = ( completionItem . additionalTextEdits ?? [ ] ) . concat ( edit ) ; } return completionItem ; } private getCompletionDocument ( compDetail ) { const { sourceDisplay , documentation : tsDocumentation , displayParts , tags } = compDetail ; let detail = changeSvelteComponentName ( ts . displayPartsToString ( displayParts ) ) ; if ( sourceDisplay ) { const importPath = ts . displayPartsToString ( sourceDisplay ) ; detail = `Auto import from ${ importPath }\n${ detail }` ; } const markdownDoc = getMarkdownDocumentation ( tsDocumentation , tags ) ; const documentation = markdownDoc ? { value : markdownDoc , kind : MarkupKind . Markdown } : undefined ; return { documentation , detail } ; } private codeActionChangesToTextEdit ( doc , fragment , changes , isImport , originalTriggerPosition ) { return changes . textChanges . map ( ( change ) => this . codeActionChangeToTextEdit ( doc , fragment , change , isImport , originalTriggerPosition ) ) ; } codeActionChangeToTextEdit ( doc , fragment , change , isImport , originalTriggerPosition ) { change . newText = this . changeComponentImport ( change . newText , isInScript ( originalTriggerPosition , doc ) ) ; const scriptTagInfo = fragment . scriptInfo || fragment . moduleScriptInfo ; if ( ! scriptTagInfo ) { const lang = this . configManager . getConfig ( ) . svelte . defaultScriptLanguage ; const scriptLang = lang === 'none' ? '' : ` lang="${ lang }"` ; return TextEdit . replace ( beginOfDocumentRange , `<script${ scriptLang }>${ ts . sys . newLine }${ change . newText }</script>${ ts . sys . newLine }` ) ; } const { span } = change ; const virtualRange = convertRange ( fragment , span ) ; let range ; const isNewImport = isImport && virtualRange . start . character === 0 ; if ( isNewImport && virtualRange . start . line > 1 ) { range = this . mapRangeForNewImport ( fragment , virtualRange ) ; } else { range = mapRangeToOriginal ( fragment , virtualRange ) ; } if ( range . start . line === - 1 || ( range . start . line === 0 && range . start . character <= 1 && span . length === 0 ) || ! isInScript ( range . start , fragment ) ) { range = convertRange ( doc , { start : isInTag ( originalTriggerPosition , doc . scriptInfo ) ? fragment . scriptInfo ?. start || scriptTagInfo . start : isInTag ( originalTriggerPosition , doc . moduleScriptInfo ) ? fragment . moduleScriptInfo ?. start || scriptTagInfo . start : scriptTagInfo . start , length : span . length } ) ; } const editOffset = doc . offsetAt ( range . start ) ; if ( ( editOffset === fragment . scriptInfo ?. start || editOffset === fragment . moduleScriptInfo ?. start ) && ! change . newText . startsWith ( '\r\n' ) && ! change . newText . startsWith ( '\n' ) ) { change . newText = ts . sys . newLine + change . newText ; } return TextEdit . replace ( range , change . newText ) ; } private mapRangeForNewImport ( fragment , virtualRange ) { const sourceMappableRange = this . offsetLinesAndMovetoStartOfLine ( virtualRange , - 1 ) ; const mappableRange = mapRangeToOriginal ( fragment , sourceMappableRange ) ; return this . offsetLinesAndMovetoStartOfLine ( mappableRange , 1 ) ; } private offsetLinesAndMovetoStartOfLine ( { start , end } , offsetLines ) { return Range . create ( Position . create ( start . line + offsetLines , 0 ) , Position . create ( end . line + offsetLines , 0 ) ) ; } private isSvelteComponentImport ( className ) { return className . endsWith ( '__SvelteComponent_' ) ; } private changeComponentImport ( importText , actionTriggeredInScript ) { const changedName = changeSvelteComponentName ( importText ) ; if ( importText !== changedName || ! actionTriggeredInScript ) { return changedName . replace ( ' type ' , ' ' ) ; } return importText ; } } const beginOfDocumentRange = Range . create ( Position . create ( 0 , 0 ) , Position . create ( 0 , 0 ) ) ; const scriptImportRegex = /\bimport\s+{([^}]*?)}\s+?from\s+['"`].+?['"`]|\bimport\s+(\w+?)\s+from\s+['"`].+?['"`]/g ; const svelte2tsxTypes = new Set ( [ 'Svelte2TsxComponent' , 'Svelte2TsxComponentConstructorParameters' , 'SvelteComponentConstructor' , 'SvelteActionReturnType' , 'SvelteTransitionConfig' , 'SvelteTransitionReturnType' , 'SvelteAnimationReturnType' , 'SvelteWithOptionalProps' , 'SvelteAllProps' , 'SveltePropsAnyFallback' , 'SvelteSlotsAnyFallback' , 'SvelteRestProps' , 'SvelteSlots' , 'SvelteStore' ] ) ; function isValidCompletion ( document , position ) { const isNoSvelte2tsxCompletion = ( value ) => value . kindModifiers !== 'declare' || ( ! value . name . startsWith ( '__sveltets_' ) && ! svelte2tsxTypes . has ( value . name ) ) ; const isCompletionInHTMLStartTag = ! ! getNodeIfIsInHTMLStartTag ( document . html , document . offsetAt ( position ) ) ; if ( ! isCompletionInHTMLStartTag ) { return isNoSvelte2tsxCompletion ; } return ( value ) => value . kind !== ts . ScriptElementKind . jsxAttribute && isNoSvelte2tsxCompletion ( value ) ; }