Skip to content

Commit 481a8e3

Browse files
authored
Merge pull request #67 from zardoy/develop
feat: also add a way to configure position of original suggestion (including method snippet). Also plugin's completions have no JS limitation.
2 parents 376bf68 + ab8b877 commit 481a8e3

File tree

9 files changed

+283
-6
lines changed

9 files changed

+283
-6
lines changed

.vscode/launch.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,21 @@
2323
"name": "Attach to TS Server",
2424
"type": "node",
2525
"request": "attach",
26-
"protocol": "inspector",
26+
"restart": true,
2727
"port": 9229,
2828
"sourceMaps": true,
2929
"outFiles": [
3030
"${workspaceFolder}/out/**/*.js"
3131
],
3232
}
33+
],
34+
"compounds": [
35+
{
36+
"name": "Extension + TS Plugin",
37+
"configurations": [
38+
"Launch Extension",
39+
"Attach to TS Server"
40+
]
41+
}
3342
]
3443
}

src/configurationType.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,4 +288,29 @@ 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+
* When `moreVariants` enabled, always add as fallback variant if other variant can't be derived
298+
* @default false
299+
*/
300+
'objectLiteralCompletions.fallbackVariant': boolean
301+
/**
302+
* For `objectLiteralCompletions.moreVariants`, wether to insert newline for objects / arrays
303+
* @default true
304+
*/
305+
'objectLiteralCompletions.insertNewLine': boolean
306+
/**
307+
* For `objectLiteralCompletions.moreVariants`
308+
* @default displayBelow
309+
*/
310+
// 'objectLiteralCompletions.deepVariants': 'disable' | 'displayBelow' | 'replaceNotDeep'
311+
/**
312+
* Also affects builtin typescript.suggest.objectLiteralMethodSnippets, even when additional completions disabled
313+
* @default below
314+
*/
315+
'objectLiteralCompletions.keepOriginal': 'below' | 'above' | 'remove'
291316
}

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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { approveCast } from '../utils'
2+
3+
export default (entries: ts.CompletionEntry[], node: ts.Node, languageService: ts.LanguageService): ts.CompletionEntry[] | void => {
4+
if (entries.length && node) {
5+
if (ts.isStringLiteralLike(node) && approveCast(node.parent, ts.isPropertyAssignment) && ts.isObjectLiteralExpression(node.parent.parent)) {
6+
const typeChecker = languageService.getProgram()!.getTypeChecker()!
7+
const type = typeChecker.getContextualType(node.parent.parent)
8+
if (type) {
9+
const properties = type.getProperties()
10+
const propName = node.parent.name.getText()
11+
const completingPropName = properties.find(({ name }) => name === propName)
12+
const defaultValue = completingPropName?.getJsDocTags().find(({ name, text }) => name === 'default' && text?.length)?.text?.[0]?.text
13+
if (defaultValue) {
14+
const entryIndex = entries.findIndex(({ name, kind }) => name === defaultValue && kind === ts.ScriptElementKind.string)
15+
if (entryIndex === -1) return
16+
const entry = entries[entryIndex]!
17+
const newEntries = [...entries]
18+
newEntries.splice(entryIndex, 1, { ...entry, sortText: `z${entry.sortText}`, sourceDisplay: [{ kind: 'text', text: 'Default' }] })
19+
return newEntries
20+
}
21+
}
22+
}
23+
}
24+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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+
entries = [...entries]
18+
if (ts.isObjectLiteralExpression(node)) {
19+
const typeChecker = languageService.getProgram()!.getTypeChecker()!
20+
const objType = typeChecker.getContextualType(node)
21+
if (!objType) return
22+
const properties = objType.getProperties()
23+
for (const property of properties) {
24+
const entry = entries.find(({ name }) => name === property.name)
25+
if (!entry) return
26+
const type = typeChecker.getTypeOfSymbolAtLocation(property, node)
27+
if (!type) continue
28+
if (isMethodCompletionCall(type, typeChecker)) {
29+
if (['above', 'remove'].includes(keepOriginal) && preferences.includeCompletionsWithObjectLiteralMethodSnippets) {
30+
const methodEntryIndex = entries.findIndex(e => e.name === entry.name && isObjectLiteralMethodSnippet(e))
31+
const methodEntry = entries[methodEntryIndex]
32+
if (methodEntry) {
33+
entries.splice(methodEntryIndex, 1)
34+
entries.splice(entries.indexOf(entry) + (keepOriginal === 'below' ? 1 : 0), keepOriginal === 'remove' ? 1 : 0, {
35+
...methodEntry,
36+
// let correctSorting.enable sort it
37+
sortText: entry.sortText,
38+
})
39+
}
40+
}
41+
continue
42+
}
43+
if (!enableMoreVariants) continue
44+
const getQuotedSnippet = (): [string, string] => {
45+
const quote = tsFull.getQuoteFromPreference(tsFull.getQuotePreference(node.getSourceFile() as any, preferences))
46+
return [`: ${quote}$1${quote},$0`, `: ${quote}${quote},`]
47+
}
48+
const insertObjectArrayInnerText = c('objectLiteralCompletions.insertNewLine') ? '\n\t$1\n' : '$1'
49+
const completingStyleMap = [
50+
[getQuotedSnippet, isStringCompletion],
51+
[[`: [${insertObjectArrayInnerText}],$0`, `: [],`], isArrayCompletion],
52+
[[`: {${insertObjectArrayInnerText}},$0`, `: {},`], isObjectCompletion],
53+
] as const
54+
const fallbackSnippet = c('objectLiteralCompletions.fallbackVariant') ? ([': $0,', ': ,'] as const) : undefined
55+
const insertSnippetVariant = completingStyleMap.find(([, detector]) => detector(type, typeChecker))?.[0] ?? fallbackSnippet
56+
if (!insertSnippetVariant) continue
57+
const [insertSnippetText, insertSnippetPreview] = typeof insertSnippetVariant === 'function' ? insertSnippetVariant() : insertSnippetVariant
58+
const insertText = entry.name + insertSnippetText
59+
const index = entries.indexOf(entry)
60+
entries.splice(index + (keepOriginal === 'below' ? 1 : 0), keepOriginal === 'remove' ? 1 : 0, {
61+
...entry,
62+
// todo setting incompatible!!!
63+
sortText: entry.sortText,
64+
labelDetails: {
65+
detail: insertSnippetPreview,
66+
},
67+
insertText,
68+
isSnippet: true,
69+
})
70+
}
71+
return entries
72+
}
73+
}
74+
}
75+
76+
const isObjectLiteralMethodSnippet = (entry: ts.CompletionEntry) => {
77+
const { detail } = entry.labelDetails ?? {}
78+
return detail?.startsWith('(') && detail.split('\n')[0]!.trimEnd().endsWith(')')
79+
}
80+
81+
const isMethodCompletionCall = (type: ts.Type, checker: ts.TypeChecker) => {
82+
if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0) return true
83+
if (type.isUnion()) return type.types.some(type => isMethodCompletionCall(type, checker))
84+
}
85+
86+
const isStringCompletion = (type: ts.Type) => {
87+
if (type.flags & ts.TypeFlags.Undefined) return true
88+
if (type.flags & ts.TypeFlags.StringLike) return true
89+
if (type.isUnion()) return type.types.every(type => isStringCompletion(type))
90+
return false
91+
}
92+
93+
const isArrayCompletion = (type: ts.Type, checker: ts.TypeChecker) => {
94+
if (type.flags & ts.TypeFlags.Any) return false
95+
if (type.flags & ts.TypeFlags.Undefined) return true
96+
if (checker['isArrayLikeType'](type)) return true
97+
if (type.isUnion()) return type.types.every(type => isArrayCompletion(type, checker))
98+
return false
99+
}
100+
101+
const isObjectCompletion = (type: ts.Type) => {
102+
if (type.flags & ts.TypeFlags.Undefined) return true
103+
if (type.flags & ts.TypeFlags.Object) return true
104+
if (type.isUnion()) return type.types.every(type => isObjectCompletion(type))
105+
return false
106+
}

typescript/src/completionsAtPosition.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ import { isGoodPositionBuiltinMethodCompletion } from './completions/isGoodPosit
1010
import improveJsxCompletions from './completions/jsxAttributes'
1111
import arrayMethods from './completions/arrayMethods'
1212
import prepareTextForEmmet from './specialCommands/prepareTextForEmmet'
13-
import objectLiteralHelpers from './completions/objectLiteralHelpers'
1413
import switchCaseExcludeCovered from './completions/switchCaseExcludeCovered'
1514
import additionalTypesSuggestions from './completions/additionalTypesSuggestions'
1615
import boostKeywordSuggestions from './completions/boostKeywordSuggestions'
1716
import boostTextSuggestions from './completions/boostNameSuggestions'
1817
import keywordsSpace from './completions/keywordsSpace'
1918
import jsdocDefault from './completions/jsdocDefault'
19+
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) {
@@ -142,14 +144,17 @@ export const getCompletionsAtPosition = (
142144
// ({ name }) => name === 'toExponential',
143145
// ({ name }) => name === 'toString',
144146
// )
145-
const indexToPatch = prior.entries.findIndex(({ name }) => name === 'toString')
147+
const indexToPatch = prior.entries.findIndex(({ name, kind }) => name === 'toString' && kind !== ts.ScriptElementKind.warning)
146148
if (indexToPatch !== -1) {
147149
prior.entries[indexToPatch]!.insertText = `${prior.entries[indexToPatch]!.insertText ?? prior.entries[indexToPatch]!.name}()`
148150
prior.entries[indexToPatch]!.kind = ts.ScriptElementKind.constElement
149151
// prior.entries[indexToPatch]!.isSnippet = true
150152
}
151153
}
152154

155+
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+
153158
const banAutoImportPackages = c('suggestions.banAutoImportPackages')
154159
if (banAutoImportPackages?.length)
155160
prior.entries = prior.entries.filter(entry => {
@@ -197,7 +202,8 @@ export const getCompletionsAtPosition = (
197202
})
198203
}
199204

200-
if (c('correctSorting.enable')) prior.entries = prior.entries.map((entry, index) => ({ ...entry, sortText: `${entry.sortText ?? ''}${index}` }))
205+
if (c('correctSorting.enable'))
206+
prior.entries = prior.entries.map((entry, index) => ({ ...entry, sortText: `${entry.sortText ?? ''}${index.toString().padStart(4, '0')}` }))
201207

202208
// console.log('signatureHelp', JSON.stringify(languageService.getSignatureHelpItems(fileName, position, {})))
203209
return {

typescript/src/dummyLanguageService.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
// only for basic testing, as vscode is actually using server
2-
import ts from 'typescript/lib/tsserverlibrary'
32
import { nodeModules } from './utils'
43

54
export const createLanguageService = (files: Record<string, string>, { useLib = true }: { useLib?: boolean } = {}) => {

typescript/src/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,9 @@ const wordRangeAtPos = (text: string, position: number) => {
169169
}
170170
return text.slice(startPos + 1, endPos)
171171
}
172+
173+
export function approveCast<TOut extends TIn, TIn = any>(value: TIn | undefined, test: (value: TIn) => value is TOut): value is TOut
174+
export function approveCast<T>(value: T, test: (value: T) => boolean): T | undefined
175+
export function approveCast<T>(value: T, test: (value: T) => boolean): T | undefined {
176+
return value !== undefined && test(value) ? value : undefined
177+
}

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": "110000",
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": "110001",
301+
},
302+
{
303+
"insertText": "additionalOptions",
304+
"isSnippet": true,
305+
"kind": "property",
306+
"kindModifiers": "optional",
307+
"name": "additionalOptions",
308+
"sortText": "120002",
309+
},
310+
{
311+
"insertText": "additionalOptions: {
312+
$1
313+
},$0",
314+
"isSnippet": true,
315+
"kind": "property",
316+
"kindModifiers": "optional",
317+
"labelDetails": {
318+
"detail": ": {},",
319+
},
320+
"name": "additionalOptions",
321+
"sortText": "120003",
322+
},
323+
{
324+
"insertText": "callback",
325+
"isSnippet": true,
326+
"kind": "method",
327+
"kindModifiers": "optional",
328+
"name": "callback",
329+
"sortText": "120004",
330+
},
331+
{
332+
"insertText": "mood",
333+
"isSnippet": true,
334+
"kind": "property",
335+
"kindModifiers": "optional",
336+
"name": "mood",
337+
"sortText": "120005",
338+
},
339+
{
340+
"insertText": "mood: \\"$1\\",$0",
341+
"isSnippet": true,
342+
"kind": "property",
343+
"kindModifiers": "optional",
344+
"labelDetails": {
345+
"detail": ": \\"\\",",
346+
},
347+
"name": "mood",
348+
"sortText": "120006",
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)