@@ -102,135 +102,160 @@ export function completionBehavior(behaviorContext: BehaviorContext): Behavior {
102
102
} ;
103
103
}
104
104
105
- async function getCompletions (
106
- context : CompletionContext ,
107
- cvContext : CodeViewCompletionContext ,
108
- behaviorContext : BehaviorContext
109
- ) : Promise < CompletionResult | null > {
110
-
111
- // get completions
112
- const completions = await behaviorContext . pmContext . ui . codeview ?. codeViewCompletions ( cvContext ) ;
113
- if ( context . aborted || ! completions || completions . items . length == 0 ) {
114
- return null ;
105
+ const compareBySortText = ( a : CompletionItem , b : CompletionItem ) => {
106
+ if ( a . sortText && b . sortText ) {
107
+ return a . sortText . localeCompare ( b . sortText ) ;
108
+ } else {
109
+ return 0 ;
115
110
}
111
+ } ;
116
112
117
- // order completions
118
- const haveOrder = ! ! completions . items ?. [ 0 ] . sortText ;
119
- if ( haveOrder ) {
120
- completions . items = completions . items . sort ( ( a , b ) => {
121
- if ( a . sortText && b . sortText ) {
122
- return a . sortText . localeCompare ( b . sortText ) ;
123
- } else {
124
- return 0 ;
113
+ // compute from
114
+ const itemFrom = ( item : CompletionItem , contextPos : number ) => {
115
+ // compute from
116
+ return item . textEdit
117
+ ? InsertReplaceEdit . is ( item . textEdit )
118
+ ? contextPos - ( item . textEdit . insert . end . character - item . textEdit . insert . start . character )
119
+ : TextEdit . is ( item . textEdit )
120
+ ? contextPos - ( item . textEdit . range . end . character - item . textEdit . range . start . character )
121
+ : contextPos
122
+ : contextPos ;
123
+ } ;
124
+
125
+ /**
126
+ * replaceText for a given CompletionItem is the text that is already in the document
127
+ * that that CompletionItem will replace.
128
+ *
129
+ * Example 1: if you are typing `lib` and get the completion `library`, then this function
130
+ * will give `lib`.
131
+ * Example 2: if you are typing `os.a` and get the completion `abc`, then this function
132
+ * will give `a`.
133
+ */
134
+ const getReplaceText = ( context : CompletionContext , item : CompletionItem ) =>
135
+ context . state . sliceDoc ( itemFrom ( item , context . pos ) , context . pos ) ;
136
+
137
+ const makeCompletionItemApplier = ( item : CompletionItem , context : CompletionContext ) =>
138
+ ( view : EditorView , completion : Completion ) => {
139
+ // compute from
140
+ const from = itemFrom ( item , context . pos ) ;
141
+
142
+ // handle snippets
143
+ const insertText = item . textEdit ?. newText ?? ( item . insertText || item . label ) ;
144
+ if ( item . insertTextFormat === InsertTextFormat . Snippet ) {
145
+ const insertSnippet = snippet ( insertText . replace ( / \$ ( \d + ) / g, "$${$1}" ) ) ;
146
+ insertSnippet ( view , completion , from , context . pos ) ;
147
+ // normal completions
148
+ } else {
149
+ view . dispatch ( {
150
+ ...insertCompletionText ( view . state , insertText , from , context . pos ) ,
151
+ annotations : pickedCompletion . of ( completion )
152
+ } ) ;
153
+ if ( item . command ?. command === "editor.action.triggerSuggest" ) {
154
+ startCompletion ( view ) ;
125
155
}
126
- } ) ;
156
+ }
157
+ } ;
158
+
159
+ const sortTextItemsBoostScore = ( context : CompletionContext , items : CompletionItem [ ] , index : number ) => {
160
+ const total = items . length ;
161
+ const item = items [ index ] ;
162
+ // compute replaceText
163
+ const replaceText = getReplaceText ( context , item ) ;
164
+
165
+ // if the replaceText doesn't start with "." then bury items that do
166
+ if ( ! replaceText . startsWith ( "." ) && item . label . startsWith ( "." ) ) {
167
+ return - 99 ;
127
168
}
128
169
129
- // compute token
130
- const token = context . matchBefore ( / \S + / ) ?. text ;
170
+ // only boost things that have a prefix match
171
+ if ( item . label . toLowerCase ( ) . startsWith ( replaceText ) ||
172
+ ( item . textEdit && item . textEdit . newText . toLowerCase ( ) . startsWith ( replaceText ) ) ||
173
+ ( item . insertText && item . insertText . toLowerCase ( ) . startsWith ( replaceText ) ) ) {
174
+ return - 99 + Math . round ( ( ( total - index ) / total ) * 198 ) ; ;
175
+ } else {
176
+ return - 99 ;
177
+ }
178
+ } ;
131
179
132
- // compute from
133
- const itemFrom = ( item : CompletionItem ) => {
134
- // compute from
135
- return item . textEdit
136
- ? InsertReplaceEdit . is ( item . textEdit )
137
- ? context . pos - ( item . textEdit . insert . end . character - item . textEdit . insert . start . character )
138
- : TextEdit . is ( item . textEdit )
139
- ? context . pos - ( item . textEdit . range . end . character - item . textEdit . range . start . character )
140
- : context . pos
141
- : context . pos ;
142
- } ;
180
+ const defaultBoostScore = ( context : CompletionContext , items : CompletionItem [ ] , index : number ) => {
181
+ const item = items [ index ] ;
143
182
144
- // use order to create boost
145
- const total = completions . items . length ;
146
- const boostScore = ( index : number ) => {
183
+ const replaceText = getReplaceText ( context , item ) ;
147
184
148
- // compute replaceText
149
- const item = completions . items [ index ] ;
150
- const replaceText = context . state . sliceDoc ( itemFrom ( item ) , context . pos ) . toLowerCase ( ) ;
185
+ // if you haven't typed into the completions yet (for example after a `.`) then
186
+ // score items starting with non-alphabetic characters -1, everything else 0.
187
+ if ( replaceText . length === 0 ) return isLetter ( item . label [ 0 ] ) ? 0 : - 1 ;
151
188
152
- if ( haveOrder ) {
189
+ // We filter items by replaceText inclusion before scoring,
190
+ // so i is garaunteed to be an index into `item.label`...
191
+ const i = item . label . toLowerCase ( ) . indexOf ( replaceText . toLowerCase ( ) ) ;
192
+ // and `replaceTextInItermLabel` should be the same as `replaceText` up to upper/lowercase
193
+ // differences.
194
+ const replaceTextInItemLabel = item . label . slice ( i , replaceText . length ) ;
153
195
154
- // if the replaceText doesn't start with "." then bury items that do
155
- if ( ! replaceText . startsWith ( "." ) && item . label . startsWith ( "." ) ) {
156
- return - 99 ;
157
- }
196
+ // mostly counts how many upper/lowercase differences there are
197
+ let diff = simpleStringDiff ( replaceTextInItemLabel , replaceText ) ;
158
198
159
- // only boost things that have a prefix match
160
- if ( item . label . toLowerCase ( ) . startsWith ( replaceText ) ||
161
- ( item . textEdit && item . textEdit . newText . toLowerCase ( ) . startsWith ( replaceText ) ) ||
162
- ( item . insertText && item . insertText . toLowerCase ( ) . startsWith ( replaceText ) ) ) {
163
- return - 99 + Math . round ( ( ( total - index ) / total ) * 198 ) ; ;
164
- } else {
165
- return - 99 ;
166
- }
199
+ // `-i` scores completions better if what you typed is earlier in the completion
200
+ // `-diff/10` mostly tie breaks that score by capitalization differences.
201
+ return - i - diff / 10 ; // 10 is a magic number
202
+ } ;
167
203
168
- } else {
169
- return undefined ;
170
- }
171
- } ;
204
+ async function getCompletions (
205
+ context : CompletionContext ,
206
+ cvContext : CodeViewCompletionContext ,
207
+ behaviorContext : BehaviorContext
208
+ ) : Promise < CompletionResult | null > {
209
+ if ( context . aborted ) return null ;
172
210
173
- // return completions
174
- return {
175
- from : context . pos ,
211
+ // get completions
212
+ const completions = await behaviorContext . pmContext . ui . codeview ?. codeViewCompletions ( cvContext ) ;
213
+ if ( completions === undefined ) return null ;
214
+ if ( completions . items . length == 0 ) return null ;
176
215
177
- options : completions . items
178
- . filter ( item => {
216
+ const itemsHaveSortText = completions . items ?. [ 0 ] . sortText !== undefined ;
179
217
180
- // no text completions that aren't snippets
181
- if ( item . kind === CompletionItemKind . Text &&
182
- item . insertTextFormat !== InsertTextFormat . Snippet ) {
183
- return false ;
184
- }
218
+ const items = itemsHaveSortText ?
219
+ completions . items . sort ( compareBySortText ) :
220
+ completions . items ;
185
221
186
- // compute text to replace
187
- const replaceText = context . state . sliceDoc ( itemFrom ( item ) , context . pos ) . toLowerCase ( ) ;
222
+ // The token is the contents of the line up to your cursor.
223
+ // For example, if you type `os.a` then token will be `os.a`.
224
+ // Note: in contrast, when you type `os.a` replaceText will give `a` for a completion like `abc`.
225
+ const token = context . matchBefore ( / \S + / ) ?. text ;
188
226
189
- // only allow non-text edits if we have no token
190
- if ( ! item . textEdit && token ) {
191
- return false ;
192
- }
227
+ const filteredItems = items . filter ( item => {
228
+ // no text completions that aren't snippets
229
+ if ( item . kind === CompletionItemKind . Text &&
230
+ item . insertTextFormat !== InsertTextFormat . Snippet ) return false ;
231
+
232
+ // only allow non-text edits if we have no token
233
+ if ( item . textEdit === undefined && token ) return false ;
234
+
235
+ // require at least inclusion
236
+ const replaceText = getReplaceText ( context , item ) . toLowerCase ( ) ;
237
+ return item . label . toLowerCase ( ) . includes ( replaceText ) ||
238
+ item . insertText ?. toLowerCase ( ) . includes ( replaceText ) ;
239
+ } ) ;
240
+
241
+ const boostScore = itemsHaveSortText ?
242
+ sortTextItemsBoostScore :
243
+ defaultBoostScore ;
244
+
245
+ const options = filteredItems
246
+ . map ( ( item , index ) : Completion => {
247
+ return {
248
+ label : item . label ,
249
+ detail : ! item . documentation ? item . detail : undefined ,
250
+ type : vsKindToType ( item . kind ) ,
251
+ info : ( ) => infoNodeForItem ( item ) ,
252
+ apply : makeCompletionItemApplier ( item , context ) ,
253
+ boost : boostScore ( context , filteredItems , index )
254
+ } ;
255
+ } ) ;
193
256
194
- // require at least inclusion
195
- return item . label . toLowerCase ( ) . includes ( replaceText ) ||
196
- ( item . insertText && item . insertText . toLowerCase ( ) . includes ( replaceText ) ) ;
197
- } )
198
- . map ( ( item , index ) : Completion => {
199
- return {
200
- label : item . label ,
201
- detail : item . detail && ! item . documentation ? item . detail : undefined ,
202
- type : vsKindToType ( item . kind ) ,
203
- info : ( ) : Node | null => {
204
- if ( item . documentation ) {
205
- return infoNodeForItem ( item ) ;
206
- } else {
207
- return null ;
208
- }
209
- } ,
210
- apply : ( view : EditorView , completion : Completion , from : number ) => {
211
- // compute from
212
- from = itemFrom ( item ) ;
213
-
214
- // handle snippets
215
- const insertText = item . textEdit ?. newText ?? ( item . insertText || item . label ) ;
216
- if ( item . insertTextFormat === InsertTextFormat . Snippet ) {
217
- const insertSnippet = snippet ( insertText . replace ( / \$ ( \d + ) / g, "$${$1}" ) ) ;
218
- insertSnippet ( view , completion , from , context . pos ) ;
219
- // normal completions
220
- } else {
221
- view . dispatch ( {
222
- ...insertCompletionText ( view . state , insertText , from , context . pos ) ,
223
- annotations : pickedCompletion . of ( completion )
224
- } ) ;
225
- if ( item . command ?. command === "editor.action.triggerSuggest" ) {
226
- startCompletion ( view ) ;
227
- }
228
- }
229
- } ,
230
- boost : boostScore ( index )
231
- } ;
232
- } )
233
- } ;
257
+ // return completions
258
+ return { from : context . pos , options } ;
234
259
}
235
260
236
261
@@ -281,7 +306,6 @@ function vsKindToType(kind?: CompletionItemKind) {
281
306
282
307
283
308
function infoNodeForItem ( item : CompletionItem ) {
284
-
285
309
const headerEl = ( text : string , tag : string ) => {
286
310
const header = document . createElement ( tag ) ;
287
311
header . classList . add ( "cm-completionInfoHeader" ) ;
@@ -328,3 +352,15 @@ function infoNodeForItem(item: CompletionItem) {
328
352
return null ;
329
353
}
330
354
}
355
+
356
+ function simpleStringDiff ( str1 : string , str2 : string ) {
357
+ let diff = 0 ;
358
+ for ( let i = 0 ; i < Math . min ( str1 . length , str2 . length ) ; i ++ ) {
359
+ if ( str1 [ i ] !== str2 [ i ] ) diff ++ ;
360
+ }
361
+ return diff ;
362
+ } ;
363
+
364
+ function isLetter ( c : string ) {
365
+ return c . toLowerCase ( ) != c . toUpperCase ( ) ;
366
+ }
0 commit comments