Skip to content

Commit d9b1c80

Browse files
committed
feat: commit experimental (but already powerful) way to display only actual JSX components in completions. requires setup, but its worth it!
feat(jsx): filter out keywords after `<` (namespaces also can be filtered out by enabled a new setting)
1 parent 227a6ff commit d9b1c80

File tree

3 files changed

+226
-44
lines changed

3 files changed

+226
-44
lines changed

src/configurationType.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,4 +324,10 @@ export type Configuration = {
324324
*/
325325
// TODO its a bug, change to after & before with fixed behavior
326326
'objectLiteralCompletions.keepOriginal': 'below' | 'above' | 'remove'
327+
/**
328+
* Wether to exclude non-JSX components completions in JSX component locations
329+
* Requires `completion-symbol` patch
330+
* @default false
331+
*/
332+
'experiments.excludeNonJsxCompletions': boolean
327333
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { GetConfig } from '../types'
2+
3+
const reactTypesPath = 'node_modules/@types/react/index.d.ts'
4+
5+
const symbolCache = new Map<string, boolean>()
6+
7+
export default (entries: ts.CompletionEntry[], node: ts.Node, position: number, languageService: ts.LanguageService, c: GetConfig) => {
8+
if (node.getSourceFile().languageVariant !== ts.LanguageVariant.JSX) return
9+
if (ts.isIdentifier(node)) node = node.parent
10+
if (
11+
![ts.SyntaxKind.JsxOpeningFragment, ts.SyntaxKind.JsxOpeningElement, ts.SyntaxKind.JsxSelfClosingElement].includes(node.kind) &&
12+
!isJsxOpeningElem(position, node)
13+
) {
14+
return
15+
}
16+
17+
const nodeText = node.getText().slice(0, position - (node.pos + node.getLeadingTriviaWidth()))
18+
// workaround for <div test |></div>
19+
if (nodeText.includes(' ')) return
20+
if (c('jsxImproveElementsSuggestions.enabled')) {
21+
let lastPart = nodeText.split('.').at(-1)!
22+
if (lastPart.startsWith('<')) lastPart = lastPart.slice(1)
23+
const isStartingWithUpperCase = (str: string) => str[0] && str[0] === str[0].toUpperCase()
24+
// check if starts with lowercase
25+
if (isStartingWithUpperCase(lastPart)) {
26+
entries = entries.filter(entry => isStartingWithUpperCase(entry.name) && ![ts.ScriptElementKind.enumElement].includes(entry.kind))
27+
}
28+
}
29+
30+
const fileName = node.getSourceFile().fileName
31+
const interestedKinds: ts.ScriptElementKind[] = [
32+
ts.ScriptElementKind.variableElement,
33+
ts.ScriptElementKind.functionElement,
34+
ts.ScriptElementKind.constElement,
35+
ts.ScriptElementKind.letElement,
36+
ts.ScriptElementKind.alias,
37+
ts.ScriptElementKind.parameterElement,
38+
ts.ScriptElementKind.memberVariableElement,
39+
ts.ScriptElementKind.memberFunctionElement,
40+
]
41+
const timings = {}
42+
const typeAtLocLog = {}
43+
const program = languageService.getProgram()!
44+
const typeChecker = program.getTypeChecker()!
45+
const nowGetter = tsFull.tryGetNativePerformanceHooks()!.performance
46+
let mark = nowGetter.now()
47+
const startMark = () => {
48+
mark = nowGetter.now()
49+
}
50+
const addMark = (name: string) => {
51+
timings[name] ??= 0
52+
timings[name] += nowGetter.now() - mark
53+
timings[name + 'Count'] ??= 0
54+
timings[name + 'Count']++
55+
}
56+
const getIsJsxComponentSignature = (signature: ts.Signature) => {
57+
let returnType: ts.Type | undefined = signature.getReturnType()
58+
if (!returnType) return
59+
// todo setting to allow any!
60+
if (returnType.flags & ts.TypeFlags.Any) return false
61+
returnType = getPossiblyJsxType(returnType)
62+
if (!returnType) return false
63+
startMark()
64+
// todo(perf) this seems to be taking a lot of time (mui test 180ms)
65+
const typeString = typeChecker.typeToString(returnType)
66+
addMark('stringType')
67+
// todo-low resolve indentifier instead
68+
// or compare node name from decl (invest perf)
69+
if (['Element', 'ReactElement'].every(s => !typeString.startsWith(s))) return
70+
const declFile = returnType.getSymbol()?.declarations?.[0]?.getSourceFile().fileName
71+
if (!declFile?.endsWith(reactTypesPath)) return
72+
return true
73+
}
74+
const getIsEntryReactComponent = (entry: ts.CompletionEntry) => {
75+
// todo add more checks from ref https://github.com/microsoft/TypeScript/blob/e4816ed44cf9bcfe7cebb997b1f44cdb5564dac4/src/compiler/checker.ts#L30030
76+
// todo support classes
77+
const symbol = entry['symbol'] as ts.Symbol
78+
// tsFull.isCheckJsEnabledForFile(sourceFile, compilerOptions)
79+
// symbol.declarations
80+
if (!symbol) return true
81+
// performance: symbol coming from lib cannot be JSX element, so let's skip checking them
82+
// todo other decl
83+
const firstDeclaration = symbol.declarations?.[0]
84+
if (!firstDeclaration) return
85+
// todo-low
86+
const isIntrisicElem = ts.isInterfaceDeclaration(firstDeclaration.parent) && firstDeclaration.parent.name.text === 'IntrinsicElements'
87+
if (isIntrisicElem) return true
88+
// todo check
89+
// todo allow property access
90+
// only intrinsic elements can have lowercase starting and getting type of local variables might be really slow for some reason
91+
if (entry.name[0]?.toLowerCase() === entry.name[0]) return false
92+
const firstDeclarationFileName = firstDeclaration.getSourceFile().fileName
93+
if (firstDeclarationFileName.includes('/node_modules/typescript/lib/lib')) return false
94+
let shouldBeCached = firstDeclarationFileName.includes('node_modules')
95+
if (!shouldBeCached && firstDeclaration.getSourceFile().fileName === fileName) {
96+
// startMark()
97+
const definitionAtPosition = languageService.getDefinitionAtPosition(fileName, firstDeclaration.pos + 1)?.[0]
98+
// addMark('getDefinitionAtPosition')
99+
if (!definitionAtPosition) return
100+
shouldBeCached = definitionAtPosition.fileName.includes('node_modules')
101+
}
102+
const symbolSerialized = `${firstDeclarationFileName}#${symbol.name}`
103+
if (shouldBeCached && symbolCache.has(symbolSerialized)) {
104+
// that caching, not ideal
105+
return symbolCache.get(symbolSerialized)
106+
}
107+
startMark()
108+
const entryType = typeChecker.getTypeOfSymbolAtLocation(symbol, node)
109+
typeAtLocLog[entry.name] = nowGetter.now() - mark
110+
addMark('getTypeAtLocation')
111+
// todo setting to allow any!
112+
if (entryType.flags & ts.TypeFlags.Any) return false
113+
// startMark()
114+
const signatures = typeChecker.getSignaturesOfType(entryType, ts.SignatureKind.Call)
115+
// addMark('signatures')
116+
const result = signatures.length > 0 && signatures.every(signature => getIsJsxComponentSignature(signature))
117+
if (shouldBeCached) symbolCache.set(symbolSerialized, result)
118+
return result
119+
}
120+
121+
// todo inspect div suggestion
122+
console.time('filterJsxComponents')
123+
const newEntries = entries.filter(entry => {
124+
// if (!entry.name[0] || entry.name[0].toLowerCase() === entry.name[0]) return false
125+
if (entry.kind === ts.ScriptElementKind.keyword) return false
126+
// todo
127+
if (c('jsxImproveElementsSuggestions.filterNamespaces') && entry.kind === ts.ScriptElementKind.moduleElement) return false
128+
if (!c('experiments.excludeNonJsxCompletions')) return true
129+
// todo!!!
130+
if (entry.kind === ts.ScriptElementKind.classElement) return false
131+
if (entry.kind === ts.ScriptElementKind.localClassElement) return false
132+
if (!interestedKinds.includes(entry.kind)) return true
133+
const isEntryReactComponent = getIsEntryReactComponent(entry)
134+
return isEntryReactComponent
135+
})
136+
console.timeEnd('filterJsxComponents')
137+
console.log('filterJsxComponentsTimings', JSON.stringify(Object.fromEntries(Object.entries(timings).map(([k, v]) => [k, Math.round(v)]))))
138+
return newEntries
139+
}
140+
141+
const isJsxOpeningElem = (position: number, node: ts.Node) => {
142+
// handle cases like:
143+
// const a = () => <|
144+
// function a() { return <| }
145+
const fullText = node
146+
.getSourceFile()
147+
.getFullText()
148+
.slice(0, position)
149+
// not needed as well
150+
.replace(/[\w\d]+$/, '')
151+
if (!fullText.endsWith('<')) return
152+
// todo remove this
153+
while (ts.isParenthesizedExpression(node)) {
154+
node = node.parent
155+
}
156+
if (node.kind === ts.SyntaxKind.FirstBinaryOperator && node.parent.getText() === '<') {
157+
return true
158+
// const parent = node.parent.parent;
159+
// if (!parent) return false
160+
// if (parent.kind === ts.SyntaxKind.ReturnStatement) return true
161+
// if (ts.is(parent))
162+
}
163+
return false
164+
}
165+
166+
const getReactElementType = (program: ts.Program) => {
167+
const reactDeclSource = program.getSourceFiles().find(name => name.fileName.endsWith(reactTypesPath))
168+
const namespace = reactDeclSource && ts.forEachChild(reactDeclSource, s => ts.isModuleDeclaration(s) && s.name.text === 'React' && s)
169+
if (!namespace || !namespace.body) return
170+
return ts.forEachChild(namespace.body, node => {
171+
if (ts.isInterfaceDeclaration(node) && node.name.text === 'ReactElement') {
172+
return node
173+
}
174+
return
175+
})
176+
}
177+
178+
const getPossiblyJsxType = (type: ts.Type) => {
179+
if (type.isUnion()) {
180+
for (const t of type.types) {
181+
if (t.flags & ts.TypeFlags.Null) continue
182+
if (t.flags & ts.TypeFlags.Object) {
183+
type = t
184+
break
185+
} else {
186+
return
187+
}
188+
}
189+
}
190+
return type.flags & ts.TypeFlags.Object ? type : undefined
191+
}

typescript/src/completionsAtPosition.ts

Lines changed: 29 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import keywordsSpace from './completions/keywordsSpace'
1818
import jsdocDefault from './completions/jsdocDefault'
1919
import defaultHelpers from './completions/defaultHelpers'
2020
import objectLiteralCompletions from './completions/objectLiteralCompletions'
21+
import filterJsxElements from './completions/filterJsxComponents'
2122

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

@@ -52,49 +53,28 @@ export const getCompletionsAtPosition = (
5253
* useful as in most cases we work with node that is behind the cursor */
5354
const leftNode = findChildContainingPosition(ts, sourceFile, position - 1)
5455
const exactNode = findChildContainingExactPosition(sourceFile, position)
55-
options?.quotePreference
56-
if (['.jsx', '.tsx'].some(ext => fileName.endsWith(ext))) {
57-
// #region JSX tag improvements
58-
if (node) {
59-
const { SyntaxKind } = ts
60-
// TODO maybe allow fragment?
61-
const correntComponentSuggestionsKinds = [SyntaxKind.JsxOpeningElement, SyntaxKind.JsxSelfClosingElement]
62-
const nodeText = node.getFullText().slice(0, position - node.pos)
63-
if (correntComponentSuggestionsKinds.includes(node.kind) && c('jsxImproveElementsSuggestions.enabled') && !nodeText.includes(' ') && prior) {
64-
let lastPart = nodeText.split('.').at(-1)!
65-
if (lastPart.startsWith('<')) lastPart = lastPart.slice(1)
66-
const isStartingWithUpperCase = (str: string) => str[0] === str[0]?.toUpperCase()
67-
// check if starts with lowercase
68-
if (isStartingWithUpperCase(lastPart))
69-
// TODO! compare with suggestions from lib.dom
70-
prior.entries = prior.entries.filter(
71-
entry => isStartingWithUpperCase(entry.name) && ![ts.ScriptElementKind.enumElement].includes(entry.kind),
72-
)
56+
if (node) {
57+
// #region Fake emmet
58+
if (
59+
c('jsxPseudoEmmet.enable') &&
60+
leftNode &&
61+
prepareTextForEmmet(fileName, leftNode, sourceFile, position, languageService) !== false &&
62+
ensurePrior() &&
63+
prior
64+
) {
65+
const tags = c('jsxPseudoEmmet.tags')
66+
for (let [tag, value] of Object.entries(tags)) {
67+
if (value === true) value = `<${tag}>$1</${tag}>`
68+
prior.entries.push({
69+
kind: ts.ScriptElementKind.label,
70+
name: tag,
71+
sortText: '!5',
72+
insertText: value,
73+
isSnippet: true,
74+
})
7375
}
74-
// #endregion
75-
76-
// #region Fake emmet
77-
if (
78-
c('jsxPseudoEmmet.enable') &&
79-
leftNode &&
80-
prepareTextForEmmet(fileName, leftNode, sourceFile, position, languageService) !== false &&
81-
ensurePrior() &&
82-
prior
83-
) {
84-
const tags = c('jsxPseudoEmmet.tags')
85-
for (let [tag, value] of Object.entries(tags)) {
86-
if (value === true) value = `<${tag}>$1</${tag}>`
87-
prior.entries.push({
88-
kind: ts.ScriptElementKind.label,
89-
name: tag,
90-
sortText: '!5',
91-
insertText: value,
92-
isSnippet: true,
93-
})
94-
}
95-
}
96-
// #endregion
9776
}
77+
// #endregion
9878
}
9979
if (leftNode && !hasSuggestions && ensurePrior() && prior) {
10080
prior.entries = additionalTypesSuggestions(prior.entries, program, leftNode) ?? prior.entries
@@ -202,10 +182,15 @@ export const getCompletionsAtPosition = (
202182
})
203183
}
204184

205-
if (c('correctSorting.enable'))
206-
prior.entries = prior.entries.map((entry, index) => ({ ...entry, sortText: `${entry.sortText ?? ''}${index.toString().padStart(4, '0')}` }))
185+
if (exactNode) prior.entries = filterJsxElements(prior.entries, exactNode, position, languageService, c) ?? prior.entries
207186

208-
// console.log('signatureHelp', JSON.stringify(languageService.getSignatureHelpItems(fileName, position, {})))
187+
if (c('correctSorting.enable')) {
188+
prior.entries = prior.entries.map(({ ...entry }, index) => ({
189+
...entry,
190+
sortText: `${entry.sortText ?? ''}${index.toString().padStart(4, '0')}`,
191+
symbol: undefined,
192+
}))
193+
}
209194
return {
210195
completions: prior,
211196
prevCompletionsMap,

0 commit comments

Comments
 (0)