Skip to content

Commit f8f9de7

Browse files
committed
feat: Feature of the month! Meet objectLiteralCompletions for string, arrays and objects!
1 parent 0f423c3 commit f8f9de7

File tree

5 files changed

+239
-1
lines changed

5 files changed

+239
-1
lines changed

src/configurationType.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,4 +288,24 @@ export type Configuration = {
288288
* @default []
289289
*/
290290
'figIntegration.enableWhenStartsWith': string[]
291+
/**
292+
* Propose additional completions in object. Just like `typescript.suggest.objectLiteralMethodSnippets.enabled`, but also for string, arrays and objects
293+
* @default true
294+
*/
295+
'objectLiteralCompletions.moreVariants': boolean
296+
/**
297+
* For `objectLiteralCompletions.moreVariants`, wether to insert newline for objects / arrays
298+
* @default true
299+
*/
300+
'objectLiteralCompletions.insertNewLine': boolean
301+
/**
302+
* For `objectLiteralCompletions.moreVariants`
303+
* @default displayBelow
304+
*/
305+
// 'objectLiteralCompletions.deepVariants': 'disable' | 'displayBelow' | 'replaceNotDeep'
306+
/**
307+
* Also affects builtin typescript.suggest.objectLiteralMethodSnippets, even when additional completions disabled
308+
* @default below
309+
*/
310+
'objectLiteralCompletions.keepOriginal': 'below' | 'above' | 'remove'
291311
}

src/onCompletionAccepted.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,17 @@ export default (tsApi: { onCompletionAccepted }) => {
1515
return
1616
}
1717

18-
const { insertText, documentation = '', kind } = item
18+
const { label, insertText, documentation = '', kind } = item
1919
if (kind === vscode.CompletionItemKind.Keyword) {
2020
if (insertText === 'return ') justAcceptedReturnKeywordSuggestion = true
2121
else if (insertText === 'default ') void vscode.commands.executeCommand('editor.action.triggerSuggest')
22+
return
23+
}
24+
25+
// todo: use cleaner detection
26+
if (typeof insertText === 'object' && typeof label === 'object' && label.detail && [': [],', ': {},', ': "",', ": '',"].includes(label.detail)) {
27+
void vscode.commands.executeCommand('editor.action.triggerSuggest')
28+
return
2229
}
2330

2431
const enableMethodSnippets = vscode.workspace.getConfiguration(process.env.IDS_PREFIX, item.document).get('enableMethodSnippets')
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { GetConfig } from '../types'
2+
3+
export default (
4+
entries: ts.CompletionEntry[],
5+
node: ts.Node,
6+
languageService: ts.LanguageService,
7+
preferences: ts.UserPreferences,
8+
c: GetConfig,
9+
): ts.CompletionEntry[] | void => {
10+
if (entries.length && node) {
11+
const enableMoreVariants = c('objectLiteralCompletions.moreVariants')
12+
const keepOriginal = c('objectLiteralCompletions.keepOriginal')
13+
if (!preferences.includeCompletionsWithObjectLiteralMethodSnippets && !enableMoreVariants) return
14+
// plans to make it hihgly configurable! e.g. if user wants to make some subtype leading (e.g. from [] | {})
15+
if (ts.isIdentifier(node)) node = node.parent
16+
if (ts.isShorthandPropertyAssignment(node)) node = node.parent
17+
const addEntries: ts.CompletionEntry[] = []
18+
const completionIndexesToRemove: number[] = []
19+
entries = [...entries]
20+
if (ts.isObjectLiteralExpression(node)) {
21+
const typeChecker = languageService.getProgram()!.getTypeChecker()!
22+
const objType = typeChecker.getContextualType(node)
23+
if (!objType) return
24+
const properties = objType.getProperties()
25+
for (const property of properties) {
26+
const entry = entries.find(({ name }) => name === property.name)
27+
if (!entry) return
28+
const type = typeChecker.getTypeOfSymbolAtLocation(property, node)
29+
if (!type) continue
30+
if (isMethodCompletionCall(type, typeChecker)) {
31+
if (keepOriginal === 'remove') completionIndexesToRemove.push(entries.indexOf(entry))
32+
continue
33+
}
34+
if (!enableMoreVariants) continue
35+
const getQuotedSnippet = (): [string, string] => {
36+
const quote = tsFull.getQuoteFromPreference(tsFull.getQuotePreference(node.getSourceFile() as any, preferences))
37+
return [`: ${quote}$1${quote},$0`, `: ${quote}${quote},`]
38+
}
39+
const insertObjectArrayInnerText = c('objectLiteralCompletions.insertNewLine') ? '\n\t$1\n' : '$1'
40+
const completingStyleMap = [
41+
[getQuotedSnippet, isStringCompletion],
42+
[[`: [${insertObjectArrayInnerText}],$0`, `: [],`], isArrayCompletion],
43+
[[`: {${insertObjectArrayInnerText}},$0`, `: {}`], isObjectCompletion],
44+
] as const
45+
const insertSnippetVariant = completingStyleMap.find(([, detector]) => detector(type, typeChecker))?.[0]
46+
if (!insertSnippetVariant) continue
47+
const [insertSnippetText, insertSnippetPreview] = typeof insertSnippetVariant === 'function' ? insertSnippetVariant() : insertSnippetVariant
48+
const insertText = entry.name + insertSnippetText
49+
addEntries.push({
50+
...entry,
51+
// todo setting incompatible!!!
52+
sortText: entry.sortText,
53+
labelDetails: {
54+
detail: insertSnippetPreview,
55+
},
56+
insertText,
57+
isSnippet: true,
58+
})
59+
if (keepOriginal === 'remove') entries.splice(entries.indexOf(entry), 1)
60+
}
61+
if ((keepOriginal === 'above' || keepOriginal === 'remove') && preferences.includeCompletionsWithObjectLiteralMethodSnippets) {
62+
const metMethodCompletions: string[] = []
63+
entries = entries.filter((entry, i) => {
64+
if (completionIndexesToRemove.includes(i)) return false
65+
66+
const { detail } = entry.labelDetails ?? {}
67+
if (detail?.startsWith('(') && detail.split('\n')[0]!.trimEnd().endsWith(')')) {
68+
addEntries.push(entry)
69+
metMethodCompletions.push(entry.name)
70+
return false
71+
}
72+
if (
73+
keepOriginal === 'remove' &&
74+
entry.kind === ts.ScriptElementKind.memberFunctionElement &&
75+
!detail &&
76+
metMethodCompletions.includes(entry.name)
77+
) {
78+
return false
79+
}
80+
return true
81+
})
82+
}
83+
return keepOriginal === 'above' ? [...addEntries, ...entries] : [...entries, ...addEntries]
84+
}
85+
}
86+
}
87+
88+
const isMethodCompletionCall = (type: ts.Type, checker: ts.TypeChecker) => {
89+
if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0) return true
90+
if (type.isUnion()) return type.types.some(type => isMethodCompletionCall(type, checker))
91+
}
92+
93+
const isStringCompletion = (type: ts.Type) => {
94+
if (type.flags & ts.TypeFlags.Undefined) return true
95+
if (type.isStringLiteral()) return true
96+
if (type.isUnion()) return type.types.every(type => isStringCompletion(type))
97+
return false
98+
}
99+
100+
const isArrayCompletion = (type: ts.Type, checker: ts.TypeChecker) => {
101+
if (type.flags & ts.TypeFlags.Undefined) return true
102+
if (checker['isArrayLikeType'](type)) return true
103+
if (type.isUnion()) return type.types.every(type => isArrayCompletion(type, checker))
104+
return false
105+
}
106+
107+
const isObjectCompletion = (type: ts.Type) => {
108+
if (type.flags & ts.TypeFlags.Undefined) return true
109+
if (type.flags & ts.TypeFlags.Object) return true
110+
if (type.isUnion()) return type.types.every(type => isObjectCompletion(type))
111+
return false
112+
}

typescript/src/completionsAtPosition.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import boostTextSuggestions from './completions/boostNameSuggestions'
1717
import keywordsSpace from './completions/keywordsSpace'
1818
import jsdocDefault from './completions/jsdocDefault'
1919
import defaultHelpers from './completions/defaultHelpers'
20+
import objectLiteralCompletions from './completions/objectLiteralCompletions'
2021

2122
export type PrevCompletionMap = Record<string, { originalName?: string; documentationOverride?: string | ts.SymbolDisplayPart[] }>
2223

@@ -51,6 +52,7 @@ export const getCompletionsAtPosition = (
5152
* useful as in most cases we work with node that is behind the cursor */
5253
const leftNode = findChildContainingPosition(ts, sourceFile, position - 1)
5354
const exactNode = findChildContainingExactPosition(sourceFile, position)
55+
options?.quotePreference
5456
if (['.jsx', '.tsx'].some(ext => fileName.endsWith(ext))) {
5557
// #region JSX tag improvements
5658
if (node) {
@@ -151,6 +153,8 @@ export const getCompletionsAtPosition = (
151153
}
152154

153155
if (node) prior.entries = defaultHelpers(prior.entries, node, languageService) ?? prior.entries
156+
if (node) prior.entries = objectLiteralCompletions(prior.entries, node, languageService, options ?? {}, c) ?? prior.entries
157+
154158
const banAutoImportPackages = c('suggestions.banAutoImportPackages')
155159
if (banAutoImportPackages?.length)
156160
prior.entries = prior.entries.filter(entry => {

typescript/test/completions.spec.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getNavTreeItems } from '../src/getPatchedNavTree'
1010
import { createRequire } from 'module'
1111
import { findChildContainingPosition } from '../src/utils'
1212
import handleCommand from '../src/specialCommands/handle'
13+
import _ from 'lodash'
1314

1415
const require = createRequire(import.meta.url)
1516
//@ts-ignore plugin expect it to set globallly
@@ -79,6 +80,7 @@ const getCompletionsAtPosition = (pos: number, fileName = entrypoint) => {
7980
return {
8081
...result,
8182
entries: result.completions.entries,
83+
entriesSorted: _.sortBy(result.completions.entries, ({ sortText }) => sortText),
8284
entryNames: result.completions.entries.map(({ name }) => name),
8385
}
8486
}
@@ -256,6 +258,99 @@ test('Switch Case Exclude Covered', () => {
256258
}
257259
})
258260

261+
test('Object Literal Completions', () => {
262+
const [_positivePositions, _negativePositions, numPositions] = fileContentsSpecialPositions(/* ts */ `
263+
interface Options {
264+
mood?: 'happy' | 'sad'
265+
callback?()
266+
additionalOptions?: {
267+
foo?: boolean
268+
}
269+
plugins: Array<{ name: string, setup(build) }>
270+
}
271+
272+
const makeDay = (options: Options) => {}
273+
makeDay({
274+
/*1*/
275+
})
276+
`)
277+
const { entriesSorted } = getCompletionsAtPosition(numPositions[1]!) ?? {}
278+
// todo resolve sorting problem + add tests with other keepOriginal (it was tested manually)
279+
expect(entriesSorted?.map(entry => Object.fromEntries(Object.entries(entry).filter(([, value]) => value !== undefined)))).toMatchInlineSnapshot(`
280+
[
281+
{
282+
"insertText": "plugins",
283+
"isSnippet": true,
284+
"kind": "property",
285+
"kindModifiers": "",
286+
"name": "plugins",
287+
"sortText": "110",
288+
},
289+
{
290+
"insertText": "plugins: [
291+
$1
292+
],$0",
293+
"isSnippet": true,
294+
"kind": "property",
295+
"kindModifiers": "",
296+
"labelDetails": {
297+
"detail": ": [],",
298+
},
299+
"name": "plugins",
300+
"sortText": "116",
301+
},
302+
{
303+
"insertText": "additionalOptions",
304+
"isSnippet": true,
305+
"kind": "property",
306+
"kindModifiers": "optional",
307+
"name": "additionalOptions",
308+
"sortText": "121",
309+
},
310+
{
311+
"insertText": "callback",
312+
"isSnippet": true,
313+
"kind": "method",
314+
"kindModifiers": "optional",
315+
"name": "callback",
316+
"sortText": "122",
317+
},
318+
{
319+
"insertText": "mood",
320+
"isSnippet": true,
321+
"kind": "property",
322+
"kindModifiers": "optional",
323+
"name": "mood",
324+
"sortText": "123",
325+
},
326+
{
327+
"insertText": "mood: \\"$1\\",$0",
328+
"isSnippet": true,
329+
"kind": "property",
330+
"kindModifiers": "optional",
331+
"labelDetails": {
332+
"detail": ": \\"\\",",
333+
},
334+
"name": "mood",
335+
"sortText": "124",
336+
},
337+
{
338+
"insertText": "additionalOptions: {
339+
$1
340+
},$0",
341+
"isSnippet": true,
342+
"kind": "property",
343+
"kindModifiers": "optional",
344+
"labelDetails": {
345+
"detail": ": {}",
346+
},
347+
"name": "additionalOptions",
348+
"sortText": "125",
349+
},
350+
]
351+
`)
352+
})
353+
259354
// TODO move/remove this test from here
260355
test('Patched navtree (outline)', () => {
261356
globalThis.__TS_SEVER_PATH__ = require.resolve('typescript/lib/tsserver')

0 commit comments

Comments
 (0)