import { forEach , map , defaultsDeep } from "lodash" ; import { expect } from "chai" ; import { TextDocument , TextDocumentPositionParams , Position , TextDocumentIdentifier , CompletionItem , TextEdit , Range , } from "vscode-languageserver" ; import { UI5SemanticModel } from "@ui5-language-assistant/semantic-model-types" ; import { expectExists } from "@ui5-language-assistant/test-utils" ; import { getCompletionItems } from "../src/completion-items" ; import { Settings , getDefaultSettings } from "@ui5-language-assistant/settings" ; /** Return the first part of a tag name suggestion insert text */ export function getTagName ( textEdit ) { if ( textEdit === undefined ) { return undefined ; } const result = /^([^> ]*)/ . exec ( textEdit . newText ) ; return result ?. [ 1 ] ; } /** Use ⇶ to mark the cursor position */ export function getSuggestions ( xmlSnippet , ui5SemanticModel , settings ? ) { const { document , position } = getXmlSnippetDocument ( xmlSnippet ) ; const uri = { uri : "uri" } ; const textDocPositionParams = { textDocument : uri , position : position , } ; if ( settings === undefined ) { settings = { codeAssist : { deprecated : true , experimental : true } } ; } const allSettings = defaultsDeep ( { } , settings , getDefaultSettings ( ) ) as Settings ; const suggestions = getCompletionItems ( { model : ui5SemanticModel , textDocumentPosition : textDocPositionParams , document , documentSettings : allSettings , } ) ; assertSuggestionsAreValid ( suggestions , xmlSnippet ) ; return suggestions ; } function createTextDocument ( languageId , content ) { return TextDocument . create ( "uri" , languageId , 0 , content ) ; } function getXmlSnippetDocument ( xmlSnippet ) { const xmlSnippetWithoutRanges = xmlSnippet . replace ( /⭲/g , "" ) . replace ( /⭰/g , "" ) ; const xmlText = xmlSnippetWithoutRanges . replace ( "⇶" , "" ) ; const offset = xmlSnippetWithoutRanges . indexOf ( "⇶" ) ; const document = createTextDocument ( "xml" , xmlText ) ; const position = document . positionAt ( offset ) ; return { document , position } ; } /** Use ⭲ to mark range start and ⭰ to mark range end */ export function getRanges ( xmlSnippet ) { const RANGE_START_CHAR = "⭲" ; const RANGE_END_CHAR = "⭰" ; const { document } = getXmlSnippetDocument ( xmlSnippet ) ; const ranges = [ ] ; let xmlTextWithRanges = xmlSnippet . replace ( "⇶" , "" ) ; while ( xmlTextWithRanges . indexOf ( RANGE_START_CHAR ) !== - 1 ) { const rangeStartIndex = xmlTextWithRanges . indexOf ( RANGE_START_CHAR ) ; xmlTextWithRanges = xmlTextWithRanges . replace ( RANGE_START_CHAR , "" ) ; const rangeEndIndex = xmlTextWithRanges . indexOf ( RANGE_END_CHAR ) ; if ( rangeEndIndex === - 1 ) { break ; } xmlTextWithRanges = xmlTextWithRanges . replace ( RANGE_END_CHAR , "" ) ; ranges . push ( { start : document . positionAt ( rangeStartIndex ) , end : document . positionAt ( rangeEndIndex ) , } ) ; } return ranges ; } export function getTextInRange ( xmlSnippet , range ) { const { document } = getXmlSnippetDocument ( xmlSnippet ) ; return document . getText ( range ) ; } function rangeToString ( range ) { return `${ range . start . line }:${ range . start . character }-${ range . end . line }:${ range . end . character }` ; } /** Check the suggestions will be displayed to the user according to the range and filter text */ function assertSuggestionsAreValid ( suggestions , xmlSnippet ) { const { document , position } = getXmlSnippetDocument ( xmlSnippet ) ; forEach ( suggestions , ( suggestion ) => { expectExists ( suggestion . textEdit , "suggestion contains a textEdit" ) ; assertRangeContains ( suggestion . textEdit . range , position , suggestion . label ) ; assertRangesDoNotOverlap ( document , suggestion . label , suggestion . textEdit , suggestion . additionalTextEdits || [ ] ) ; const checkedRange = { start : suggestion . textEdit ?. range . start , end : position , } ; assertFilterMatches ( suggestion . filterText ?? suggestion . label , document . getText ( checkedRange ) , suggestion . label ) ; } ) ; } function assertRangeContains ( range , position , description ) { expect ( range . start . line , `${ description }: range start line` ) . to . equal ( position . line ) ; expect ( range . end . line , `${ description }: range end line` ) . to . equal ( position . line ) ; expect ( range . start . character , `${ description }: range start character` ) . to . be . at . most ( position . character ) ; expect ( range . end . character , `${ description }: range end character` ) . to . be . at . least ( position . character ) ; } function assertRangesDoNotOverlap ( document , description , textEdit , additionalTextEdits ) { const allEdits = map ( [ textEdit ] . concat ( additionalTextEdits ) , ( edit ) => ( { ... edit , startOffset : document . offsetAt ( edit . range . start ) , endOffset : document . offsetAt ( edit . range . end ) , } ) ) ; allEdits . sort ( ( first , second ) => { if ( first . startOffset === second . startOffset && first . endOffset === second . endOffset ) { return 0 ; } if ( first . startOffset < second . startOffset || ( first . startOffset === second . startOffset && first . endOffset < second . endOffset ) ) { return - 1 ; } return 1 ; } ) ; for ( let i = 0 ; i < allEdits . length - 1 ; ++ i ) { const first = allEdits [ i ] ; const second = allEdits [ i + 1 ] ; const overlapping = second . startOffset <= first . endOffset ; expect ( overlapping , `${ description }: found overlapping text edits: "${ first . newText }" at ${ rangeToString ( first . range ) } and "${ second . newText }" at ${ rangeToString ( second . range ) }` ) . to . be . false ; } } function assertFilterMatches ( filterText , text , description ) { let contains = true ; let indexInFilterText = 0 ; forEach ( text , ( character ) => { if ( contains ) { const characterIndex = filterText . indexOf ( character , indexInFilterText ) ; if ( characterIndex < 0 ) { contains = false ; } else { indexInFilterText = characterIndex + 1 ; } } } ) ; expect ( contains , `${ description }: ${ filterText } does not contain all characters from ${ text } in order` ) . to . be . true ; }