Skip to content

Commit 4f40a80

Browse files
authored
Merge pull request #19 from zardoy/inKeywordCompletions
2 parents 42c4fd1 + 244989a commit 4f40a80

File tree

5 files changed

+159
-1
lines changed

5 files changed

+159
-1
lines changed

README.MD

+4
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ You can quickly disable this plugin functionality by setting this setting to fal
9494
9595
Web-only feature: `import` path resolution
9696

97+
### `in` Keyword Suggestions
98+
99+
[Demo](https://github.com/zardoy/typescript-vscode-plugins/pull/19)
100+
97101
### Highlight non-function Methods
98102

99103
(*enabled by default*)

typescript/src/completionsAtPosition.ts

+12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import _ from 'lodash'
22
import type tslib from 'typescript/lib/tsserverlibrary'
3+
import inKeywordCompletions from './inKeywordCompletions'
34
// import * as emmet from '@vscode/emmet-helper'
45
import isInBannedPosition from './completions/isInBannedPosition'
56
import { GetConfig } from './types'
@@ -186,6 +187,17 @@ export const getCompletionsAtPosition = (
186187
return !banAutoImportPackages.includes(text)
187188
})
188189

190+
const inKeywordCompletionsResult = inKeywordCompletions(position, node, sourceFile, program, languageService)
191+
if (inKeywordCompletionsResult) {
192+
prior.entries.push(...inKeywordCompletionsResult.completions)
193+
Object.assign(
194+
prevCompletionsMap,
195+
_.mapValues(inKeywordCompletionsResult.docPerCompletion, value => ({
196+
documentationOverride: value,
197+
})),
198+
)
199+
}
200+
189201
if (c('suggestions.keywordsInsertText') === 'space') {
190202
prior.entries = keywordsSpace(prior.entries, scriptSnapshot, position, exactNode)
191203
}
+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
export default (position: number, node: ts.Node | undefined, sourceFile: ts.SourceFile, program: ts.Program, languageService: ts.LanguageService) => {
2+
if (!node) return
3+
function isBinaryExpression(node: ts.Node): node is ts.BinaryExpression {
4+
return node.kind === ts.SyntaxKind.BinaryExpression
5+
}
6+
const typeChecker = program.getTypeChecker()
7+
// TODO info diagnostic if used that doesn't exist
8+
if (
9+
ts.isStringLiteralLike(node) &&
10+
isBinaryExpression(node.parent) &&
11+
node.parent.left === node &&
12+
node.parent.operatorToken.kind === ts.SyntaxKind.InKeyword
13+
) {
14+
const quote = node.getText()[0]!
15+
const type = typeChecker.getTypeAtLocation(node.parent.right)
16+
const suggestionsData = new Map<
17+
string,
18+
{
19+
insertText: string
20+
usingDisplayIndexes: number[]
21+
documentations: string[]
22+
}
23+
>()
24+
const types = type.isUnion() ? type.types : [type]
25+
for (let [typeIndex, typeEntry] of types.entries()) {
26+
// improved DX: not breaking other completions as TS would display error anyway
27+
if (!(typeEntry.flags & ts.TypeFlags.Object)) continue
28+
for (const prop of typeEntry.getProperties()) {
29+
const { name } = prop
30+
if (!suggestionsData.has(name)) {
31+
suggestionsData.set(name, {
32+
insertText: prop.name.replace(quote, `\\${quote}`),
33+
usingDisplayIndexes: [],
34+
documentations: [],
35+
})
36+
}
37+
suggestionsData.get(name)!.usingDisplayIndexes.push(typeIndex + 1)
38+
// const doc = prop.getDocumentationComment(typeChecker)
39+
const declaration = prop.getDeclarations()?.[0]
40+
suggestionsData
41+
.get(name)!
42+
.documentations.push(`${typeIndex + 1}: ${declaration && typeChecker.typeToString(typeChecker.getTypeAtLocation(declaration))}`)
43+
}
44+
}
45+
const docPerCompletion: Record<string, string> = {}
46+
const maxUsingDisplayIndex = Math.max(...[...suggestionsData.entries()].map(([, { usingDisplayIndexes }]) => usingDisplayIndexes.length))
47+
const completions: ts.CompletionEntry[] = [...suggestionsData.entries()]
48+
.map(([originaName, { insertText, usingDisplayIndexes, documentations }], i) => {
49+
const name = types.length > 1 && usingDisplayIndexes.length === 1 ? `☆${originaName}` : originaName
50+
docPerCompletion[name] = documentations.join('\n\n')
51+
return {
52+
// ⚀ ⚁ ⚂ ⚃ ⚄ ⚅
53+
name,
54+
kind: ts.ScriptElementKind.string,
55+
insertText,
56+
sourceDisplay: [{ kind: 'text', text: usingDisplayIndexes.join(', ') }],
57+
sortText: `${maxUsingDisplayIndex - usingDisplayIndexes.length}_${i}`,
58+
}
59+
})
60+
.sort((a, b) => a.sortText.localeCompare(b.sortText))
61+
return {
62+
completions,
63+
docPerCompletion,
64+
}
65+
}
66+
return
67+
}

typescript/src/utils.ts

+8
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,14 @@ export const isWeb = () => {
137137
}
138138
}
139139

140+
// spec isnt strict as well
141+
export const notStrictStringCompletion = (entry: ts.CompletionEntry): ts.CompletionEntry => ({
142+
...entry,
143+
// todo
144+
name: `◯${entry.name}`,
145+
insertText: entry.insertText ?? entry.name,
146+
})
147+
140148
export function addObjectMethodResultInterceptors<T extends Record<string, any>>(
141149
object: T,
142150
interceptors: Partial<{

typescript/test/completions.spec.ts

+68-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//@ts-ignore plugin expect it to set globallly
22
globalThis.__WEB__ = false
3+
import { pickObj } from '@zardoy/utils'
34
import { createLanguageService } from '../src/dummyLanguageService'
45
import { getCompletionsAtPosition as getCompletionsAtPositionRaw } from '../src/completionsAtPosition'
56
import type {} from 'vitest/globals'
@@ -73,9 +74,10 @@ const settingsOverride = {
7374
//@ts-ignore
7475
const defaultConfigFunc = await getDefaultConfigFunc(settingsOverride)
7576

76-
const getCompletionsAtPosition = (pos: number, fileName = entrypoint) => {
77+
const getCompletionsAtPosition = (pos: number, { fileName = entrypoint, shouldHave }: { fileName?: string; shouldHave?: boolean } = {}) => {
7778
if (pos === undefined) throw new Error('getCompletionsAtPosition: pos is undefined')
7879
const result = getCompletionsAtPositionRaw(fileName, pos, {}, defaultConfigFunc, languageService, ts.ScriptSnapshot.fromString(files[entrypoint]), ts)
80+
if (shouldHave) expect(result).not.toBeUndefined()
7981
if (!result) return
8082
return {
8183
...result,
@@ -422,3 +424,68 @@ test('Patched navtree (outline)', () => {
422424
]
423425
`)
424426
})
427+
428+
test('In Keyword Completions', () => {
429+
const [pos] = newFileContents(/* ts */ `
430+
declare const a: { a: boolean, b: string } | { a: number, c: number } | string
431+
if ('/*|*/' in a) {}
432+
`)
433+
const completion = pickObj(getCompletionsAtPosition(pos!, { shouldHave: true })!, 'entriesSorted', 'prevCompletionsMap')
434+
// TODO this test is bad case of demonstrating how it can be used with string in union (IT SHOULDNT!)
435+
// but it is here to ensure this is no previous crash issue, indexes are correct when used only with objects
436+
expect(completion).toMatchInlineSnapshot(`
437+
{
438+
"entriesSorted": [
439+
{
440+
"insertText": "a",
441+
"isSnippet": true,
442+
"kind": "string",
443+
"name": "a",
444+
"sourceDisplay": [
445+
{
446+
"kind": "text",
447+
"text": "2, 3",
448+
},
449+
],
450+
},
451+
{
452+
"insertText": "b",
453+
"isSnippet": true,
454+
"kind": "string",
455+
"name": "☆b",
456+
"sourceDisplay": [
457+
{
458+
"kind": "text",
459+
"text": "2",
460+
},
461+
],
462+
},
463+
{
464+
"insertText": "c",
465+
"isSnippet": true,
466+
"kind": "string",
467+
"name": "☆c",
468+
"sourceDisplay": [
469+
{
470+
"kind": "text",
471+
"text": "3",
472+
},
473+
],
474+
},
475+
],
476+
"prevCompletionsMap": {
477+
"a": {
478+
"documentationOverride": "2: boolean
479+
480+
3: number",
481+
},
482+
"☆b": {
483+
"documentationOverride": "2: string",
484+
},
485+
"☆c": {
486+
"documentationOverride": "3: number",
487+
},
488+
},
489+
}
490+
`)
491+
})

0 commit comments

Comments
 (0)